/** * 授权钩子策略层的测试。 * * 档位判定是**给产品定的、不是给平台定的**:同一条「plan 档」在 ZCode 上 * 必须与 pi 桥同义。这层是纯函数,所以可以被穷举 —— 真去起一个 ZCode 会话 * 验一遍的代价高得多,而档位判断错了的后果是「有人以为自己在只读档, * 实际被跑了命令」。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { decidePolicy, isGuardedTool, describeToolCall, PERMISSION_EVENT } from '../lib/hook-policy.mjs'; const call = (toolName, mode) => decidePolicy({ event: PERMISSION_EVENT, toolName, mode }); test('非 PermissionRequest 事件一律不表态', () => { for (const event of ['PreToolUse', 'PostToolUse', 'Stop', undefined, '']) { assert.equal(decidePolicy({ event, toolName: 'Bash', mode: 'workspace' }).action, 'none'); } }); test('守卫工具名大小写无关,且含 ApplyPatch 别名', () => { for (const n of ['Bash', 'bash', 'BASH', 'Write', 'edit', 'ApplyPatch']) { assert.equal(isGuardedTool(n), true, n); } for (const n of ['Read', 'Grep', 'Glob', 'mcp__agentmail__send_mail', '', null]) { assert.equal(isGuardedTool(n), false, String(n)); } }); test('未在守卫表里的工具不表态(退回 ZCode 自己的权限流程)', () => { for (const n of ['Read', 'Grep', 'WebFetch']) { assert.equal(call(n, 'workspace').action, 'none', n); } }); test('workspace 档:问人', () => { assert.equal(call('Bash', 'workspace').action, 'ask'); }); test('档位省略时按默认(workspace)处理', () => { assert.equal(call('Bash', undefined).action, 'ask'); assert.equal(call('Bash', '').action, 'ask'); }); test('★ full 档:批准,而不是不表态', () => { // 判据的关键。ZCode 的钩子一旦被触发,说明 ZCode **本会**去问人; // 「不表态」等于让那个询问照常发生 —— 而 full 档的语义正是免掉它。 // 若这里返回 none,full 档就变成了 workspace 档(发件人以为给了全权, // 结果每一步还在等人点)。pi 桥在该档是「不拦截」,ZCode 上的等价物就是批准。 assert.equal(call('Bash', 'full').action, 'approve'); }); test('★ plan 档:直接拒绝,且文案与 pi 桥同源', () => { const r = call('Bash', 'plan'); assert.equal(r.action, 'block'); assert.match(r.reason, /plan 档下不允许执行 Bash/); assert.match(r.reason, /把方案写在回信里/); assert.match(r.reason, /改成 workspace/); }); test('plan 档对非守卫工具仍然不表态(读与查本来就允许)', () => { assert.equal(call('Read', 'plan').action, 'none'); }); test('★ 反向对照:只翻转档位,结论必须跟着变', () => { // 同样的工具名,三个档必须给出三个不同结论。 // 没有这条,「无论什么档都返回 ask」也会让上面的断言通过。 const results = ['plan', 'workspace', 'full'].map(m => call('Bash', m).action); assert.deepEqual(results, ['block', 'ask', 'approve']); }); test('未知档位按默认处理,不会静默变成 full', () => { // 拼错的档位若被当成 full,等于把一个打字错误变成「免授权」。 assert.equal(call('Bash', 'worjspace').action, 'ask'); }); // ─── 摘要文本 ───────────────────────────────────────────────────── test('Bash 的摘要给出命令本身', () => { const s = describeToolCall('Bash', { command: 'rm -rf /tmp/x' }); assert.match(s, /rm -rf \/tmp\/x/); }); test('Write/Edit 的摘要给出文件路径(三种字段名都认)', () => { for (const key of ['file_path', 'path', 'filePath']) { assert.match(describeToolCall('Write', { [key]: '/tmp/a.txt' }), /\/tmp\/a\.txt/, key); } }); test('缺字段时给出可读的占位而不是崩', () => { assert.match(describeToolCall('Write', {}), /未给出/); assert.equal(typeof describeToolCall('Bash', undefined), 'string'); }); test('过长命令被截断(写进邮件正文的东西不能无限长)', () => { const s = describeToolCall('Bash', { command: 'x'.repeat(5000) }); assert.ok(s.length < 900, `实际长度 ${s.length}`); });