Files
MailUI4Agents/plugins/zcode-mail-bridge/hooks/permission.mjs
JianFeeeee d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00

291 lines
12 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 有**两种**含义正确反应相反2026-09-14与 dsh/pi 桥同一处缺陷):
*
* - 「这条链上没有人类」→ 当场拒绝(下面的理由);
* - 「**本档根本不该问**」→ 服务端在回包里带 `permission_mode`。若是 full 档,
* 这次询问本就不该发生full 档工具调用无需审批),正确反应是**放行**。
*
* 什么时候会走到第二种:补投路径漏传档位(`lib/catchup.js`,同日已修),
* 于是这一轮按 workspace 档拦下来问人,服务端按真实档位回 409 ——
* 旧代码把它当「无人可问」拒绝,让一条 full 档会话的工具调用**全被自己人拦死**。
*
* **只认服务端明说的 full**plan 档与"链上没有人类"照旧拒绝(猜宽了就是提权)。
* 这个桥不写会话状态(没有 sandbox/approval 旋钮),所以只需修这一层;
* dsh 桥那边还要把权威档位**写回会话**,否则沙箱仍然是窄的。
*/
if (Number(e?.status) === 409 && String(e?.body?.permission_mode || '') === 'full') {
log(`服务端判定本会话为 full 档,放行本次询问(${relayKey}):无需审批`);
return approve();
}
// 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();
});