/** * 档位 → ZCode `--mode` + `--disallowed-tools` 的测试。 * * 这一对参数是**授权系统长什么样**的开关。ZCode 的判定里 yolo 一律 allow, * 而 `--prompt` 的默认 mode 就是 yolo —— 也就是说 `--mode` 漏传或写错, * 平台自己那道防线会静默消失。我们现在的姿态是**故意让平台让开**, * 于是安全边界完全落在两处:这张审过的禁用清单,以及我们自己的门禁。 * 所以本文件的两组断言是配对的: * * 1. mode 映射:哪些档位允许平台「不问」 * 2. 禁用清单:平台不问的时候,它自带的一切「能动机器」的工具是否都被拿掉 * * 单独看任何一组都推不出「安全」:yolo + 完整清单 = 门禁在我们手里; * yolo + 漏一项 = 有一条路可以不过门禁。所以第 2 组里有一条**穷举性**的断言。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { zcodeModeForTier, denylistForTier, ourGateIsActive, modeReachesPermissionHook, describeTier, ZCODE_MODES, REVIEWED_DENYLIST } from '../src/turn-mode.mjs'; test('★ workspace 与 full 都是 yolo(门禁在我们手里),plan 仍是 plan', () => { // 为什么 workspace 也敢用 yolo:平台上没有第二道防线可用 —— // MCP 工具的 needsApproval 硬编码为真,headless 没有审批客户端 ⇒ build/edit 档下 // 连 read_inbox 都被拒(全不可用);PermissionRequest 钩子在本版本不可靠(见 README)。 // 于是选择是「平台问、但问不到人 → 全拒」还是「平台不问、我们自己问」。 // 后者才是真的可用且仍然可审计。 assert.equal(zcodeModeForTier('plan'), 'plan'); assert.equal(zcodeModeForTier('workspace'), 'yolo'); assert.equal(zcodeModeForTier('full'), 'yolo'); }); test('★ 映射可被 AGENTMAIL_ZCODE_MODE_MAP 覆盖(平台修好后不必等发版)', () => { assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:build' }), 'build'); assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:plan,full:plan' }), 'plan'); // 非法值被忽略,不改变默认 assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:nonsense' }), 'yolo'); assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: '' }), 'yolo'); }); test('★ 认不出来的档位不会变成全权:门禁仍然在管', () => { // 共用库的 normalizeMode 把一切认不出来的值归到 **workspace**(不是原样退回、 // 也不是报错)。所以「mode 是不是 yolo」已经不是安全性质了 —— workspace 也是 yolo。 // 真正的性质是:**只有 full 档能让门禁闭嘴**,而 full 只能由"完全匹配的小写 full"触发。 for (const tier of ['nonsense', undefined, '', 'PLAN', 'Plan', 'FULL', 'Full', 'x', null]) { assert.notEqual( zcodeModeForTier(tier), 'full', `档位 ${JSON.stringify(tier)} 不该被当成 full` ); assert.equal(ourGateIsActive(tier), true, `档位 ${JSON.stringify(tier)} 下门禁必须在管`); } // 反向对照:只有真正的小写 full 才关掉门禁。 assert.equal(ourGateIsActive('full'), false); assert.equal(ourGateIsActive('workspace'), true); assert.equal(ourGateIsActive('plan'), true); }); test('产出的 mode 必须是 ZCode 认识的值', () => { for (const tier of ['plan', 'workspace', 'full', 'x', undefined]) { assert.ok(ZCODE_MODES.includes(zcodeModeForTier(tier)), tier); } }); test('只有 build / edit 会让危险操作走到平台授权钩子', () => { assert.equal(modeReachesPermissionHook('build'), true); assert.equal(modeReachesPermissionHook('edit'), true); // plan 由 ZCode 自己就拒了;yolo 直接放行 —— 两者都不产生询问。 // 注意:yolo 下「没有询问」不再等于「没人把关」,所以日志必须另有说法(见下)。 assert.equal(modeReachesPermissionHook('plan'), false); assert.equal(modeReachesPermissionHook('yolo'), false); }); test('★ 反向对照:plan 与 workspace 都不产生平台询问,但原因不同', () => { const plan = describeTier('plan'); const workspace = describeTier('workspace'); assert.notEqual(plan, workspace); assert.match(plan, /只读/); // workspace 的说明必须点出「谁在把关」——否则人看到「--mode yolo」会以为 // 授权系统被关掉了,而真实情况是平台不问、我们逐次请示。 assert.match(workspace, /门禁|请示/); assert.match(workspace, /禁用/); // 显式覆盖回 build 时,说明恢复成「平台会问、钩子转达」 assert.match(describeTier('workspace', 'build'), /授权钩子/); }); test('★ 三个档位都拿到同一张禁用清单(只有一条代码路径)', () => { // 如果某个档位「忘了」加禁用清单,那一档就会多出 Bash/Write/js —— 而它们 // 恰好是绕过门禁的方式。所以这里逐个档位验,而不是只验默认档。 const base = denylistForTier('workspace', {}); for (const tier of ['plan', 'workspace', 'full', 'nonsense', undefined]) { assert.deepEqual(denylistForTier(tier, {}), base, `档位 ${tier} 的清单不一致`); } }); test('★ 穷举性:一切「能动机器」的自带工具都在清单里', () => { // 这份名单来自 CLI 产物里模型可见工具名的权威注册表(aIn 那个 28 项数组), // 并与另一处更宽的候选集取并集。不采信模型自述 —— 实测基线里它用某个 // 没点名的方式真的创建了文件。 // // 断言方式刻意选「逐项列出 + 已审阅」而不是「与某个运行时清单对比」: // 后者需要一个可信来源,而唯一的来源就是这份清单本身(循环论证)。 // 所以这条测试的作用是**把审阅结论钉住** —— 新增/删除一项都必须来改它。 const mustBlock = [ // 机器改动 'Bash', 'Write', 'Edit', 'ApplyPatch', 'NotebookEdit', 'LSP', // rename 会应用工作区编辑 'EnterWorktree', 'ExitWorktree', // 等价于 Bash 的 JS 执行通道(挂在 MCP 上,最容易漏) 'js', 'mcp__node_repl__js', 'js_reset', 'js_add_node_module_dir', 'mcp__node_repl__js_reset', 'mcp__node_repl__js_add_node_module_dir', // 延迟执行:把危险动作挪到没人看着的时候 'CronCreate', 'CronUpdate', 'CronDelete', 'CronList', 'ScheduleWakeup', 'Workflow', // 子代理 / 后台任务(工具集是否继承本清单未验证) 'Agent', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', // 绕过 AgentMail 的对外通道 'SendMessage', 'RespondToCoordinator', // 档位逃生门 'EnterPlanMode', 'ExitPlanMode' ]; for (const name of mustBlock) { assert.ok(REVIEWED_DENYLIST.includes(name), `禁用清单缺少 ${name}`); } }); test('★ 保留的必须是只读或纯本地状态(不能顺手把执行能力留下来)', () => { // 反向对照:清单是黑名单,漏一项就是开一个洞。反过来「多禁」只会少个能力, // 所以这里的断言是**确保没有把危险的东西留在允许侧**。 const dangerous = ['Bash', 'Write', 'Edit', 'ApplyPatch', 'js', 'mcp__node_repl__js', 'Agent']; for (const name of dangerous) { assert.ok(!['Read', 'Glob', 'Grep', 'TodoWrite'].includes(name), '测试自身写错了'); assert.ok(REVIEWED_DENYLIST.includes(name), `${name} 必须被禁`); } }); test('★ 禁用清单可以被配置整表替换(收紧与放宽都要能改)', () => { // 整表替换而不是追加:收紧(连 WebFetch 一起拿掉)与本地调试(临时还回 Bash) // 是同一个旋钮的两端。 assert.deepEqual(denylistForTier('workspace', { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: 'Bash Write' }), [ 'Bash', 'Write' ]); assert.deepEqual(denylistForTier('workspace', { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: 'Bash,Write' }), [ 'Bash', 'Write' ]); // 空串 = 一张空清单,与「没设置」不同(没设置要用默认清单)。 // 这个区分很重要:把空串当默认会让「我想全放开」变成「我在用默认」, // 而两者只差一个环境变量的有无。 assert.deepEqual(denylistForTier('workspace', { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: '' }), []); assert.ok(denylistForTier('workspace', {}).length > 20); });