第二步:让 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 没有人类的会话。
106 lines
4.2 KiB
JavaScript
106 lines
4.2 KiB
JavaScript
// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。
|
||
//
|
||
// # 这是什么
|
||
//
|
||
// 权限询问默认是**每次都问**:模型每调一次 bash 就发一封邮件等人点头。
|
||
// 这在「跑一条命令看看」的场景下是对的,在「审查这个工程」的场景下是灾难 ——
|
||
// 实测同一条会话被问了 15 次 bash,人点了 15 次「同意」,全是同一类操作。
|
||
//
|
||
// 「一直同意」就是人对此的回答:这条会话里这个工具,别再问了。
|
||
//
|
||
// # 为什么需要一个独立模块
|
||
//
|
||
// 因为它的**作用域**是唯一容易搞错的地方,而搞错的后果是静默的越权:
|
||
//
|
||
// - 作用域太宽(全局 / 只按工具名)→ 人为「审查 llmsproxy」批准的 bash,
|
||
// 会静默授权另一个发件人派来的另一条任务。那不是他批准的东西。
|
||
// - 作用域太窄(按 toolCallId)→ 等于没有免批,每条命令还是一封邮件。
|
||
//
|
||
// 正确的粒度是 **(会话, 工具名)**:人看到的那句「是否允许执行 bash?」
|
||
// 就是在这个粒度上提的问,授权范围不该超出提问范围。
|
||
//
|
||
// # 为什么只在内存里
|
||
//
|
||
// 会话结束(进程重启)即失效,这是有意的。长期免批该由平台自己的 settings
|
||
// 管(pi 的 settings.json、opencode 的 permission 配置),不该让一个守护进程
|
||
// 的内存变成事实上的安全策略 —— 那种策略没人能审计,重启后又悄悄消失。
|
||
|
||
/**
|
||
* 判定一个决策文本是不是「永久同意」。
|
||
*
|
||
* **必须精确匹配**,不能用前缀匹配。`/^同意/` 会把「同意」也算成 always,
|
||
* 于是人点一次单次授权,后面所有命令都不再问了 —— 那是把单次授权
|
||
* 静默升级成永久授权,比不实现这个功能危险得多。
|
||
*
|
||
* @param {string} decision 人点的选项原文
|
||
* @returns {boolean}
|
||
*/
|
||
export function isAlwaysDecision(decision) {
|
||
return /^(一直同意|always|allow-always|allow_always)$/i.test(String(decision ?? '').trim());
|
||
}
|
||
|
||
/**
|
||
* 判定一个决策文本是不是「同意」(含永久同意)。
|
||
*
|
||
* fail closed:认不出的文本一律当拒绝。空串、`shutdown`(关停时唤醒等待者
|
||
* 用的哨兵值)、以及任何没见过的选项都走这一支 —— 放行一个没人批准的
|
||
* 危险操作,比让它失败严重得多。
|
||
*
|
||
* @param {string} decision
|
||
* @returns {boolean}
|
||
*/
|
||
export function isApproval(decision) {
|
||
return /^(同意|一直同意|allow|approve|always|yes)/i.test(String(decision ?? '').trim());
|
||
}
|
||
|
||
/**
|
||
* 免批授权表:`会话 id -> Set<工具名>`。
|
||
*
|
||
* 用 Map<string, Set<string>> 而不是 Set<`${session}:${tool}`>:
|
||
* 会话结束时要能一次清掉它的全部授权(`revokeSession`),
|
||
* 拼接键的话得遍历整张表按前缀删,而工具名里出现 `:` 就会误删。
|
||
*/
|
||
export function createGrantStore() {
|
||
/** @type {Map<string, Set<string>>} */
|
||
const grants = new Map();
|
||
|
||
return {
|
||
/** 这条会话的这个工具是否已获免批。 */
|
||
isGranted(sessionId, toolName) {
|
||
if (!sessionId || !toolName) return false;
|
||
return grants.get(sessionId)?.has(toolName) ?? false;
|
||
},
|
||
|
||
/**
|
||
* 记下一条免批授权。只在决策文本确实是「一直同意」时才记 ——
|
||
* 判定交给 isAlwaysDecision,调用方不要自己写正则。
|
||
* @returns {boolean} 是否真的记下了(便于调用方决定要不要打日志)
|
||
*/
|
||
grant(sessionId, toolName, decision) {
|
||
if (!sessionId || !toolName) return false;
|
||
if (!isAlwaysDecision(decision)) return false;
|
||
let set = grants.get(sessionId);
|
||
if (!set) grants.set(sessionId, (set = new Set()));
|
||
set.add(toolName);
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* 撤销整条会话的免批。
|
||
*
|
||
* 换模型重开会话时必须调:授权是人对**那次**上下文的判断,
|
||
* 新会话重跑一遍提示,不该继承上一条的授权。
|
||
*/
|
||
revokeSession(sessionId) {
|
||
grants.delete(sessionId);
|
||
},
|
||
|
||
/** 仅用于测试与诊断:当前授权总数。 */
|
||
size() {
|
||
let n = 0;
|
||
for (const set of grants.values()) n += set.size;
|
||
return n;
|
||
},
|
||
};
|
||
}
|