Files
MailUI4Agents/plugins/opencode-mail-bridge/test/permission-mode.test.mjs
JianFeeeee a60ab66a40 refactor(plugins): 删掉「摆了一套权限规则却没人调用、且形状没人能消费」的死代码
# 问题

`lib/permission-mode.js` 里的 `opencodePermissions(mode)` 实现完整、注释详实
(含 6 条实测结论)、还有 9 条测试把行为钉住;opencode 的 `index.js` 第 45 行
确实 import 了它 —— 然后**全文件再没有第二次出现**。

静态审计要花力气才能发现它是死的,而读代码的人会理所当然地以为
「opencode 的 plan 档由这套规则拦着」。实际 opencode 是 advisory。

比 9.17(给了按钮不实现)更深一层:那只是没实现,这个是**看起来像实现**。

# 它不只是没接上,而是接不上

查 1.18.29 的 SDK 类型定义,三条都排除:

  SessionCreateData.body 只有 { parentID, title };query 只有 { directory }
      → 会话级根本没有 permission,也没有 agent
  permission 只存在于 Config / AgentConfig,且是 map 形状
      → { edit, bash, webfetch, doom_loop, external_directory } → ask|allow|deny
  Permission 事件(permission.updated 的 properties)没有 action 字段
      → { id, type, pattern?, sessionID, messageID, callID?, title, metadata, time }

而函数返回的是 `{permission, action, pattern}[]` —— **与三者都不匹配**,
并且 deny 了 `task`(本版本 permission 的合法键里没有 task)。

所以不是「加一行调用就生效」,而是**输出没有任何消费者**。

# 为什么 opencode 的 per-session 强制做不到(如实说明)

Config / AgentConfig 是配置文件级(全局或项目级)。邮件桥若靠改配置给某条会话
加 plan 限制,会连带锁住这个人**其他所有**会话的同一工具 —— 一条 plan 档的邮件
把人身兼的其他工作一起禁掉,不可接受。

opencode 目前唯一的拦截路径是「它自己先问 → 桥转发 → 服务端按档位 409 →
桥当场 block」,**前提是它的配置恰好是 ask**;若配置直接 allow,桥连
permission.updated 都看不到。这就是它只能是 advisory 的原因,不是缺工作量。

# 改动

- 删除 `opencodePermissions` 及其 doc 注释、`OpencodePermissionRule` 接口声明
  (三份共用库同时改,改后 md5 仍逐字节相同)
- 删除 opencode/index.js 里那行未使用的 import
- 删除 9 条针对该函数的断言(三桥各 9 条)
- **6 条实测结论没有丢** —— 搬进 `docs/PLUGIN-CONTRACT.md` 新增的 §9.18,
  连同上面那三条类型证据与「为什么 per-session 做不到」

删掉而非保留,是因为留下的就是陷阱:函数存在、注释写着「实测过」、
测试还全绿,唯一缺的是调用点 —— 下一个人会以为档位在这里被强制。

# 验证

- `deploy/check-shared-libs.sh` → 共用模块三方同源
- 三桥 `npm test` 全绿:pi 400 / dsh 360 / opencode 311,0 失败
  (删除前 pi 409 / opencode 320,各 −9 即被删断言;dsh 另有并发提交新增测试,
  净 −9 后为 360)
- `node --check` 四个改动文件全过;ESM 动态 import 该 lib 成功,
  导出里已无 `opencodePermissions`,index.js 只引用仍存在的符号
- 本改动不影响运行时行为(删的是一个从未执行的函数与一个未使用的 import)
2026-09-11 23:59:24 +08:00

216 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* lib/permission-mode.js 的测试 —— 四个平台逐字节共用。
*
* 这些判据编码了六条 opencode 实测结论。不实测就写代码会做出「看起来对但
* 管不住」的东西,所以每条结论都在这里钉死,改坏了会当场失败。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing,
} from '../lib/permission-mode.js';
// ─── 归一化 ───
test('合法档位原样返回', () => {
for (const m of MODES) assert.equal(normalizeMode(m), m);
});
test('非法档位 fail-closed 到默认档,不是 full', () => {
for (const bad of ['', 'FULL', 'full-access', 'workspace-write', null, undefined, 42, {}]) {
assert.equal(normalizeMode(bad), DEFAULT_MODE, `${String(bad)} 应当归到默认档`);
}
assert.notEqual(DEFAULT_MODE, MODE_FULL, '默认档不能是 full');
});
test('强制力保守方向是 advisory', () => {
for (const ok of [ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY]) {
assert.equal(normalizeEnforcement(ok), ok, `${ok} 是平台自报的事实,必须原样保留`);
}
for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) {
assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY);
}
});
test('档位顺序必须是 plan < workspace < fullmodeAtMost 的依据)', () => {
assert.deepEqual(MODES, [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]);
});
// ─── modeAtMost ───
test('modeAtMost 取更严的一档', () => {
assert.equal(modeAtMost(MODE_PLAN, MODE_FULL), MODE_PLAN);
assert.equal(modeAtMost(MODE_FULL, MODE_PLAN), MODE_PLAN);
assert.equal(modeAtMost(MODE_WORKSPACE, MODE_FULL), MODE_WORKSPACE);
assert.equal(modeAtMost(MODE_FULL, MODE_FULL), MODE_FULL);
});
// Gateway 侧曾因为「未知值当最严 vs 归到默认档」两套语义而不可交换,
// 单元测试当场抓到。两边保持同一套语义。
test('modeAtMost 可交换(脏值也不例外)', () => {
const all = [...MODES, 'garbage', '', null];
for (const a of all) {
for (const b of all) {
assert.equal(modeAtMost(a, b), modeAtMost(b, a),
`不可交换:(${a},${b})`);
}
}
});
test('脏值不得把 plan 抬成更宽松的档', () => {
assert.equal(modeAtMost('garbage', MODE_PLAN), MODE_PLAN);
});
// ─── modeNeedsHuman ───
test('只有 workspace 档需要人点头', () => {
assert.equal(modeNeedsHuman(MODE_PLAN), false, 'plan 档当场拒绝,不问人');
assert.equal(modeNeedsHuman(MODE_WORKSPACE), true);
assert.equal(modeNeedsHuman(MODE_FULL), false, 'full 档自动放行,不问人');
});
test('脏档位按默认档处理,即需要人(宁可多问一次)', () => {
assert.equal(modeNeedsHuman('garbage'), true);
assert.equal(modeNeedsHuman(''), true);
});
// ─── DSH ───
test('DSH 三档与原生沙箱一一对应', () => {
assert.equal(dshSandboxMode(MODE_PLAN), 'read-only');
assert.equal(dshSandboxMode(MODE_WORKSPACE), 'workspace-write');
assert.equal(dshSandboxMode(MODE_FULL), 'danger-full-access');
});
// 关键实测danger-full-access → approval:"never" → decide() 在 waterfall
// 之前短路 return "rejected"approval/request 钩子根本不触发。
test('DSH 审批策略只在 workspace 档是 ask', () => {
assert.equal(dshApprovalPolicy(MODE_WORKSPACE), 'ask');
assert.equal(dshApprovalPolicy(MODE_PLAN), 'never');
assert.equal(dshApprovalPolicy(MODE_FULL), 'never');
});
test('DSH 脏档位按默认档workspace-write + ask', () => {
assert.equal(dshSandboxMode('garbage'), 'workspace-write');
assert.equal(dshApprovalPolicy('garbage'), 'ask');
});
// ─── pi ───
test('pi 在 full 档不守卫任何工具', () => {
assert.deepEqual(piGuardedTools(MODE_FULL), []);
});
test('pi 在 plan / workspace 档守卫 bash / write / edit', () => {
for (const m of [MODE_PLAN, MODE_WORKSPACE]) {
const g = piGuardedTools(m);
assert.ok(g.includes('bash'));
assert.ok(g.includes('write'));
assert.ok(g.includes('edit'));
}
});
test('pi 不守卫读类工具', () => {
const g = piGuardedTools(MODE_WORKSPACE);
for (const t of ['read', 'grep', 'find', 'ls']) {
assert.equal(g.includes(t), false, `${t} 是读类工具,不该守卫`);
}
});
test('pi 在 plan 档直接拒绝,不走问人流程', () => {
assert.equal(piBlocksOutright(MODE_PLAN), true);
assert.equal(piBlocksOutright(MODE_WORKSPACE), false);
assert.equal(piBlocksOutright(MODE_FULL), false);
});
// ─── modeBriefing ───
test('full 档的说明不提授权', () => {
const s = modeBriefing({ mode: MODE_FULL, enforcement: ENFORCE_NATIVE });
assert.match(s, /full/);
assert.equal(/授权/.test(s.replace('不需要额外授权', '')), false);
});
// advisory 与 native 措辞必须不同:假装 advisory 是强制的会让模型以为
// 越界会被拦,于是不必自己小心 —— 那比做不到本身更危险。
test('advisory 必须明说平台无法强制这一档', () => {
const adv = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_ADVISORY });
const nat = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_NATIVE });
assert.match(adv, /无法强制/);
assert.equal(/无法强制/.test(nat), false, 'native 不该说无法强制');
assert.notEqual(adv, nat, '两种强制力的措辞必须不同');
});
test('workspace 档的 advisory 版同样明说', () => {
const adv = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_ADVISORY, workspace: '/tmp/x' });
assert.match(adv, /无法强制/);
assert.match(adv, /\/tmp\/x/, '要带上具体目录');
});
test('native 的 workspace 说明要交代「授权可能被拒」', () => {
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE, workspace: '/srv/app' });
assert.match(s, /\/srv\/app/);
assert.match(s, /拒绝/, '被拒时该怎么办必须说清楚,否则模型会反复重试');
});
test('plan 档的说明必须告诉模型「把方案写在回信里」', () => {
for (const e of [ENFORCE_NATIVE, ENFORCE_ADVISORY]) {
const s = modeBriefing({ mode: MODE_PLAN, enforcement: e });
assert.match(s, /回信/, '不给出路的话模型只会反复撞墙');
}
});
test('缺 workspace 时用兜底措辞,不出现 undefined', () => {
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE });
assert.equal(/undefined/.test(s), false);
assert.equal(/`` /.test(s), false);
});
test('脏输入不炸且按默认档', () => {
const s = modeBriefing({ mode: 'garbage', enforcement: 'garbage' });
assert.match(s, /workspace/);
assert.match(s, /无法强制/, '脏强制力按 advisory 处理');
});
// ─── partial有拦截点但覆盖不完整 ───
//
// 这个取值的全部意义就是「不许说假话」。因此判据不是「措辞好看」,
// 而是它与两个极端的说法**都不同**,且明确交代「不要依赖会被拦」。
test('partial 三档措辞两两不同(不能与任一极端混同)', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const nat = modeBriefing({ mode, enforcement: ENFORCE_NATIVE });
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
const adv = modeBriefing({ mode, enforcement: ENFORCE_ADVISORY });
assert.notEqual(par, nat, `${mode}: partial 不能与 native 同措辞(那是高估)`);
assert.notEqual(par, adv, `${mode}: partial 不能与 advisory 同措辞(那是低估)`);
}
});
test('partial 必须交代「覆盖不完整」且不得说「无法强制」', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
assert.match(par, /不完整|缺口/, `${mode}: 必须说清覆盖不完整`);
assert.equal(/无法强制/.test(par), false,
`${mode}: partial 平台确实在拦,「无法强制」是错的`);
}
});
test('partial 不能把「会被拦下」当成保证', () => {
const plan = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
// native 版说「都会被平台拦下」partial 版必须收回这个承诺
assert.equal(/都会被平台拦下/.test(plan), false,
'partial 下承诺「都会拦下」会让模型不必自己小心 —— 那是 native 才成立的话');
assert.match(plan, /不要依赖|主动/, '必须给出「主动自律」的指引');
});
test('partial 与 native 一样要求把方案写在回信里(出路不能消失)', () => {
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
assert.match(s, /回信/);
});