Files
MailUI4Agents/plugins/zcode-mail-bridge/hooks/permission.mjs
JianFeeeee c774904c0c 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 没有人类的会话。
2026-09-12 14:09:10 +08:00

271 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* ZCode `PermissionRequest` 钩子 —— AgentMail 的授权桥。
*
* # 它在链路里的位置
*
* ZCode 决定某个工具需要授权时会触发本钩子,并把事件 JSON 写到 stdin
*
* { hook_event_name: "PermissionRequest", tool_name: "Bash",
* tool_input: {...}, session_id: "...", permission_mode: "...", ... }
*
* 我们在 stdout 回一个结论(这是从 CLI 产物里逆出来的 schema不是猜的
*
* {"decision":"approve"} 放行
* {"decision":"block","reason":"..."} 拒绝(并让模型看到原因)
* 什么都不输出 不表态,退回 ZCode 自己的权限流程
*
* schema 是**严格**的:多一个键就会让 ZCode 报
* "Hook stdout failed HookJSONOutput schema validation"
* 所以这里只输出这两个键。
*
* # 为什么自己开 SSE而不是找桥要
*
* 决策是人点出来的,通过网关的 `permission_decision` 事件下发。
* 钩子是**一次性进程**,没有常驻连接可用;而网关的 SSE 是**扇出**的
* `clients` 按唯一 id 存,`SendToAgent` 推给该 Agent 的所有客户端),
* 所以钩子可以自己订阅、拿到自己那条决定、然后退出。
*
* 这样做的直接好处:**交互模式下也能用** —— 人自己开着 ZCode 干活时并没有
* 桥进程在跑,若改成「问桥要结论」,这个功能就只在邮件驱动时才存在。
*
* # 失败一律 fail closed但区分模式
*
* 只有明确同意才放行;看不懂的决策文本一律当拒绝(判定交给共用库)。
* 暂时性失败5xx / 网络)分两种:邮件驱动的会话没有本地界面兜底,
* 所以驳回并说明;交互模式则退回 ZCode 自己的权限流程,让人就地决定。
*/
import { readFileSync } from 'node:fs';
import { GatewayClient } from '../lib/gateway.mjs';
import { createSSEClient } from '../lib/sse-client.js';
import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js';
import { isApproval, isAlwaysDecision } from '../lib/permission-grants.js';
import {
decidePolicy,
describeToolCall,
PERMISSION_EVENT
} from '../lib/hook-policy.mjs';
import { createFileGrantStore, grantsFilePath } from '../lib/grants-file.mjs';
const log = (...parts) => console.error('[agentmail-hook]', ...parts);
/** 等待人工决策的上限。必须**小于** hooks.json 里的 timeoutMs
* 否则会是 ZCode 先把钩子杀掉(报成「钩子失败」),而不是我们给出结论。 */
const WAIT_MS = Number(process.env.AGENTMAIL_PERMISSION_WAIT_MS || 540000);
/** 回一个结论并退出。stdout 只允许出现这一个 JSON 对象。 */
function emit(obj) {
if (obj !== null) process.stdout.write(`${JSON.stringify(obj)}\n`);
process.exit(0);
}
const approve = () => emit({ decision: 'approve' });
const block = reason => emit({ decision: 'block', reason });
const noOpinion = () => emit(null);
function readHookInput() {
try {
return JSON.parse(readFileSync(0, 'utf8'));
} catch (e) {
log('stdin 不是合法 JSON:', e?.message || e);
return null;
}
}
/**
* 等 SSE 建连完成。
*
* 必须先连上再发权限请求:反过来会有一个窗口 —— 人恰好在窗口内点了同意,
* 而事件推送给了当时还不存在的客户端,于是这条决定永远等不到
* (表现为「明明点了同意,工具还是被拒」)。服务端在 AddClient 时会立刻下发
* 一个 `connected` 事件,就用它做信号。
*/
function waitConnected(sseState) {
return new Promise(resolve => {
const timer = setTimeout(() => {
log('SSE 建连等待超时,仍然继续(可能错过极早到达的决策)');
resolve();
}, 5000);
sseState.onConnected = () => {
clearTimeout(timer);
resolve();
};
});
}
async function askHuman({ client, baseURL, input, toolName, relayKey, sessionId }) {
const sseState = { onConnected: null };
const decisions = [];
let waiter = null;
const sse = createSSEClient({
authHeaders: () => client.authHeaders(),
baseURL,
path: '/api/v1/events/stream',
log,
onEvent: (evt, data) => {
if (evt === 'connected' && sseState.onConnected) sseState.onConnected();
if (evt !== 'permission_decision') return;
// 只认自己那条:同一 Agent 可能有多个钩子进程同时在等
// (模型并行发起两个 Bash按 relay_key 配对才不会互相拿到对方的决定。
if (data?.relay_key && data.relay_key !== relayKey) return;
if (waiter) {
const w = waiter;
waiter = null;
w(data);
} else {
decisions.push(data);
}
}
});
try {
await waitConnected(sseState);
// 不传 `to`:决策人由服务端按 会话 owner → 线索里最近的人类 → 409 解析。
// 插件只有本地上下文,猜不出「这条 Agent 链最初是谁派的活」。
await client.post('/permission/request', {
question: `是否允许执行 ${toolName}`,
options: ['同意', '一直同意', '拒绝'],
context: [
describeToolCall(toolName, input.tool_input),
process.env.AGENTMAIL_MAIL_SUBJECT
? `\n触发任务:${process.env.AGENTMAIL_MAIL_SUBJECT}`
: '',
process.env.AGENTMAIL_REPLY_TO ? `任务来自:${process.env.AGENTMAIL_REPLY_TO}` : ''
]
.filter(Boolean)
.join('\n'),
session_id: sessionId || '',
relay_key: relayKey
});
const decision = decisions.shift() ?? (await new Promise(resolve => {
// 挂上等待者;超时后也要把 waiter 摘掉,否则后续事件会去 resolve
// 一个已经没人听的 promise并让 sse.stop 之后的日志显得诡异)。
waiter = resolve;
setTimeout(() => {
if (waiter !== resolve) return;
waiter = null;
resolve(null);
}, WAIT_MS).unref?.();
}));
return decision;
} finally {
sse.stop();
}
}
async function main() {
const input = readHookInput();
if (!input) return noOpinion();
const toolName = input.tool_name;
const mode = process.env.AGENTMAIL_PERMISSION_MODE;
const policy = decidePolicy({
event: input.hook_event_name,
toolName,
mode
});
log(`事件 ${input.hook_event_name} 工具 ${toolName} 档位 ${mode || '(默认)'}${policy.action}`);
if (policy.action === 'none') return noOpinion();
if (policy.action === 'approve') return approve();
if (policy.action === 'block') return block(policy.reason);
// ── 问人 ──
const client = new GatewayClient(process.env);
const missing = client.checkConfig();
if (missing.length) {
log(`未配置:${missing.join('、')}`);
// 没配好就无法问人。交互模式下退回本地流程仍然可用;
// 邮件驱动的会话没有本地界面,必须当场说清楚而不是静默挂住。
return process.env.AGENTMAIL_SESSION_ID
? block(`AgentMail 授权桥未配置(缺少 ${missing.join('、')}),无法征求授权,已拒绝 ${toolName}`)
: noOpinion();
}
const sessionId = process.env.AGENTMAIL_SESSION_ID || '';
const relayKey = clampRelayKey(
`${sessionId || input.session_id || 'zcode'}:${input.tool_use_id || toolName}`
);
// 「一直同意」要真的记住:钩子一封一进程,所以授权表落盘。
const grants = createFileGrantStore(grantsFilePath(process.env));
const grantScope = sessionId || input.session_id || '';
if (grants.isGranted(grantScope, toolName)) {
log(`${toolName} 在本会话已获「一直同意」(${grantsFilePath(process.env)}),直接放行`);
return approve();
}
let decision;
try {
decision = await askHuman({ client, baseURL: client.baseURL, input, toolName, relayKey, sessionId });
} catch (e) {
// 409 = 服务端判定这条任务链上没有人类,永远不会有人来点头。
// 永久失败4xx同样不会因重试而改变 —— 两者都必须当场拒绝,
// 让模型从工具报错里看到原因并自己改道(挂死时连重试机会都没有)。
if (isPermanentFailure(e)) {
const b = e?.body && typeof e.body === 'object' ? e.body : {};
const reason = [
b.error || `权限询问无法送达HTTP ${e?.status}`,
typeof e?.body === 'string' ? e.body : '',
b.detail || '',
b.suggestion || ''
]
.filter(Boolean)
.join('\n');
log(`权限询问永久失败,当场拒绝 ${relayKey}${reason}`);
return block(reason);
}
const detail = e?.message || String(e);
log(`权限询问暂时失败:${detail}`);
if (process.env.AGENTMAIL_SESSION_ID) {
// 邮件驱动:没有本地界面兜底,退回本地决策等于守卫消失。
return block(
`无法把 ${toolName} 的授权请求送达给人(${detail})。` +
`这条会话由邮件驱动、没有本地界面,因此不放行。` +
`请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。`
);
}
return noOpinion();
}
if (decision === null) {
return block(
`等待授权超时(${Math.round(WAIT_MS / 1000)} 秒内没有人决策),未执行 ${toolName}`
);
}
const text = decision.decision ?? '';
if (isApproval(text)) {
if (isAlwaysDecision(text) && grants.grant(grantScope, toolName, text)) {
log(`记下「一直同意」:会话 ${grantScope}${toolName} 后续免批`);
}
log(`授权 ${relayKey} 获批(${text}${decision.decided_by ? ` by ${decision.decided_by}` : ''}`);
return approve();
}
return block(
[
`用户拒绝了这次 ${toolName} 调用。`,
decision.note ? `说明:${decision.note}` : '',
decision.decided_by ? `(由 ${decision.decided_by} 决定)` : ''
]
.filter(Boolean)
.join('\n')
);
}
main().catch(e => {
// 钩子自身崩了**不能**静默退回 ZCode 的权限流程 —— 那在有本地界面时是
// 合理兜底,在邮件驱动时等于守卫消失。所以这里区分模式,并把原因写在
// stderr进 ZCode 日志)供人排查。
log('钩子异常:', e?.stack || e);
if (process.env.AGENTMAIL_SESSION_ID) {
block(`AgentMail 授权钩子内部错误:${e?.message || e}。未执行工具。`);
}
noOpinion();
});