feat(zcode): 授权桥 —— PermissionRequest 钩子把危险工具授权交给人

第二步:让 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 没有人类的会话。
This commit is contained in:
2026-09-12 14:09:10 +08:00
parent e0e6f86d94
commit c774904c0c
17 changed files with 2558 additions and 0 deletions

View File

@ -0,0 +1,98 @@
/**
* 授权钩子策略层的测试。
*
* 档位判定是**给产品定的、不是给平台定的**同一条「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 档的语义正是免掉它。
// 若这里返回 nonefull 档就变成了 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}`);
});