第二步:让 ZCode 上的 Bash/Write/Edit 授权走 AgentMail 的人工审批,
而不是只靠本地界面。
钩子契约从 CLI 产物里逆出来(不猜协议):
- 输入走 stdin:{hook_event_name, tool_name, tool_input, session_id, permission_mode…}
- 输出走 stdout,schema **严格**:{"decision":"approve"} / {"decision":"block","reason"}
多一个键就会报 "Hook stdout failed HookJSONOutput schema validation"
- 空输出 / 不以 { 开头 = 不表态;exit 2 = 拒绝;其它非零 = 钩子失败
- 注入的环境变量含 ZCODE_PLUGIN_ROOT / ZCODE_PLUGIN_DATA / ZCODE_SESSION_ID
(MCP 配置里用 ZCODE_SESSION_ID 反而会抛「需要运行时会话上下文」)
档位判定与 pi 桥逐条对齐(plan 直接拒 / workspace 问人 / full 批准),
判定逻辑抽成纯函数 lib/hook-policy.mjs 以便穷举:
其中 full 档必须**返回批准而不是不表态** —— 钩子一旦触发说明 ZCode 本会去问人,
不表态等于让那个询问照常发生,full 档就退化成了 workspace 档。
钩子自己开 SSE 等决定,不依赖桥进程:网关的 SSE 是扇出的
(clients 按唯一 id 存,SendToAgent 推给该 Agent 的所有客户端),
一次性进程也能订阅到自己那条 permission_decision。这样交互模式下同样可用
(人自己开着 ZCode 干活时并没有桥在跑)。先建连再发请求是有意的:
反过来会有一个窗口,人在窗口内点的同意推送给当时还不存在的客户端。
fail closed 但区分模式:永久失败(409/4xx)一律拒绝;暂时失败在
AGENTMAIL_SESSION_ID 非空(邮件驱动、没有本地界面兜底)时拒绝,
交互模式则不表态让人就地决定。
「一直同意」落盘(lib/grants-file.mjs):钩子是一个事件一个进程,
不落盘那个选项就是骗人的。判定仍交给共用的 permission-grants.js。
共用模块同源范围扩到 9 个(新增 permission-mode / relay-key /
permission-grants / sse-client)—— 档位语义与决策判定分叉会让「同意」
在 ZCode 上悄悄变成另一种意思。
验证:
- 单元 229/229(新增 hook-policy 14 项、grants-file 8 项,含反向对照)
- 共用模块四方同源检查通过
- 授权桥端到端 5/5,全部带反向对照:
同意→approve;拒绝→block 且原因必须来自人的拒绝(不能是超时兜底);
plan 档拒绝且**不产生**任何权限邮件;无人可问(409)→fail closed;
非守卫工具→不表态
- `zcode plugins list` → agentmail@inline [enabled],hooks: 1,
mcp: plugin:agentmail:agentmail
我自己写错的两处判据(都已修,值得记下):
1. 待决权限列表里有历史积压(实测 6 条,含其它 Agent 的条目),
只按「第一条新的」取会拿到无关请求 —— 于是人点了同意而钩子在等自己那条,
最后超时。第一版还把这个超时误报成「拒绝路径通过」。
现在按「启动前快照差集 + session_id + agent_name」三重过滤。
2. 「无人可问」控制组最初传了个非 UUID 的 session id,走的是 400(参数错),
验不到 409 那条真实路径。改为真的造一条只有 Agent 没有人类的会话。
99 lines
4.2 KiB
JavaScript
99 lines
4.2 KiB
JavaScript
/**
|
||
* 授权钩子策略层的测试。
|
||
*
|
||
* 档位判定是**给产品定的、不是给平台定的**:同一条「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}`);
|
||
});
|