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,239 @@
/**
* 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。
*
* ## 分工
*
* **AgentMail 声明,平台执行,插件只翻译。** 这个模块是「翻译」那一步的
* 唯一实现:把 `plan` / `workspace` / `full` 翻成各平台原生的沙箱/审批配置。
*
* 为什么不让插件自己按工具名猜着拦:那会同时违反 I-1平台原生信号是唯一
* 真相来源)与 I-4插件只搬运不决策而且四个插件对「workspace 到底管
* 什么」必然各猜一套 —— 同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。
*
* ## 为什么必须「向更严取整」
*
* 平台表达不出精确档位时,一律往更严的方向走,并如实上报自己做到了什么
* native / advisory。pi 就是例子write/edit 能查 `input.path` 判断越界,
* 而 bash 命令要碰哪些文件是解析不出来的 —— 于是 workspace 档下 pi 只能
* 「每条 bash 都问人」,比声明的更严。
*
* 不定这条规则的后果:不同插件会朝不同方向取整,而往宽松取整是静默失效
* (人以为收紧了,实际没有)。
*/
/** 只读:查资料、读代码、出方案,一个字都不许写。 */
export const MODE_PLAN = 'plan';
/** 本目录内可动手,越界要问人。默认档。 */
export const MODE_WORKSPACE = 'workspace';
/** 自动放行,不问人。 */
export const MODE_FULL = 'full';
/** 全部合法档位,按宽松程度递增。顺序是 modeAtMost 的依据。 */
export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL];
/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */
export const DEFAULT_MODE = MODE_WORKSPACE;
/** 平台有原生拦截点,档位被完整执行。 */
export const ENFORCE_NATIVE = 'native';
/**
* 平台有原生拦截点,但覆盖不完整(有已知缺口)。
*
* 实测例子DSH 的 Landlock 沙箱受内核 ABI 版本限制,能拦下大部分写入与命令
* 执行,但并非全部路径。只给 native / advisory 两个取值会逼出一个假陈述:
* 标 native 是高估(人会当成硬保证),标 advisory 是低估(它确实在拦)。
*/
export const ENFORCE_PARTIAL = 'partial';
/** 平台没有拦截点,档位只写进提示词。 */
export const ENFORCE_ADVISORY = 'advisory';
/**
* 把外部输入收敛成合法档位。
*
* 非法值 → 默认档(**不是** full。拼错一个档位名不该换来更大的权限。
* 与 Gateway 的 NormalizePermissionMode 同语义。
*
* @param {unknown} mode
* @returns {string}
*/
export function normalizeMode(mode) {
return MODES.includes(mode) ? mode : DEFAULT_MODE;
}
/**
* 收敛强制力取值。空串或非法值 → advisory。
*
* 保守方向是 advisory 而不是 native不能替一个没自报过的平台宣称
* 「档位在这里是被强制的」。
*
* 但**显式自报的值一律原样保留**(含 partial那是平台自己的事实陈述
* 把它降级到任一极端都是在替它说假话。
*
* @param {unknown} e
* @returns {string}
*/
export function normalizeEnforcement(e) {
return e === ENFORCE_NATIVE || e === ENFORCE_PARTIAL || e === ENFORCE_ADVISORY
? e
: ENFORCE_ADVISORY;
}
/**
* 取两个档位里更严的那一个。
*
* 先归一化再比较 —— 两个脏值都变成默认档,于是结果与参数顺序无关(可交换)。
* Gateway 侧的 ModeAtMost 曾因为「modeRank 把未知值当最严、Normalize 把它
* 归到默认档」而不可交换,单元测试当场抓到。两边保持同一套语义。
*
* @param {string} a
* @param {string} b
* @returns {string}
*/
export function modeAtMost(a, b) {
const na = normalizeMode(a);
const nb = normalizeMode(b);
return MODES.indexOf(na) <= MODES.indexOf(nb) ? na : nb;
}
/**
* 这一档会不会产生权限邮件(即需不需要人来点头)。
*
* 只有 workspace 档需要人plan 档当场拒绝、full 档自动放行,两者都不问人。
* 插件据此决定要不要把平台的权限钩子接到 `/permission/request`。
*
* @param {string} mode
* @returns {boolean}
*/
export function modeNeedsHuman(mode) {
return normalizeMode(mode) === MODE_WORKSPACE;
}
/**
* DSH 的沙箱模式。
*
* 三档与 DSH 原生的三档**一一对应** —— 这不是巧合,是同一个问题的同一个答案
* (见 `@deepseek-ai/dsh-sandbox-policy` 的 SANDBOX_MODES
*
* @param {string} mode
* @returns {'read-only'|'workspace-write'|'danger-full-access'}
*/
export function dshSandboxMode(mode) {
switch (normalizeMode(mode)) {
case MODE_PLAN: return 'read-only';
case MODE_FULL: return 'danger-full-access';
default: return 'workspace-write';
}
}
/**
* DSH 的审批策略。
*
* 关键实测:`danger-full-access` 对应 `approval: "never"`,而
* `ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"`
* **在 waterfall 之前短路** —— 于是 `approval/request` 钩子根本不触发。
*
* 这解释了一个此前查不清的现象:本机 dsh 配了 `defaultPreset: danger-full-access`
* 所以整个权限转邮件链路从来没在 dsh 上跑起来过。
*
* @param {string} mode
* @returns {'ask'|'never'}
*/
export function dshApprovalPolicy(mode) {
return modeNeedsHuman(mode) ? 'ask' : 'never';
}
/**
* pi 侧应当守卫的工具名。
*
* pi 只有 `tool_call` 钩子能 `{block:true}`,没有沙箱 —— 所以档位靠
* 「拦哪些工具」表达:
*
* - plan 拦 bash/write/edit读类工具 read/grep/find/ls 不拦)
* - workspace 拦同样三个,但 write/edit 可以查 `input.path` 判越界,
* bash 无法判断 → 一律问人(向更严取整)
* - full 不拦
*
* @param {string} mode
* @returns {string[]}
*/
export function piGuardedTools(mode) {
return normalizeMode(mode) === MODE_FULL ? [] : ['bash', 'write', 'edit'];
}
/**
* pi 在某档位下,某次工具调用该不该直接拒绝(不问人)。
*
* plan 档下所有被守卫的工具都直接拒绝 —— 该档语义就是「这轮不动手」,
* 没什么可问人的,模型该把方案写在回信里。
*
* workspace 档返回 false走问人流程。full 档不会进到这里。
*
* @param {string} mode
* @returns {boolean}
*/
export function piBlocksOutright(mode) {
return normalizeMode(mode) === MODE_PLAN;
}
/**
* 给模型看的档位说明,放进提示词。
*
* 三种强制力必须说三种话:
* - native :「会被拦下」—— 模型可以依赖它
* - partial「大部分会被拦下但有已知缺口」—— 不能依赖它
* - advisory「平台不拦靠你自己遵守」
* 把 partial 当成 native 会让模型以为越界一定被拦,于是不必自己小心;
* 当成 advisory 又会让它以为平台完全没有拦截机制,在本可以依赖的边界上过度保守。
*
* @param {{mode: string, enforcement: string, workspace?: string}} ctx
* @returns {string}
*/
export function modeBriefing({ mode, enforcement, workspace }) {
const m = normalizeMode(mode);
const e = normalizeEnforcement(enforcement);
const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录';
if (m === MODE_FULL) {
return '本任务权限档位full全权。工具调用不需要额外授权。';
}
if (m === MODE_PLAN) {
if (e === ENFORCE_NATIVE) {
return [
'本任务权限档位plan只读。',
'写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
if (e === ENFORCE_PARTIAL) {
return [
'本任务权限档位plan只读。',
'平台会拦截写文件、改文件与执行命令,但**拦截覆盖不完整**(沙箱能力受平台/内核限制,有已知缺口)。',
'因此不要把「会被拦下」当成保证:请主动只查与想,把结论、方案与需要人工执行的步骤写在回信里。',
'需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
return [
'本任务权限档位plan只读。',
'**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。',
'请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。',
].join('\n');
}
if (e === ENFORCE_NATIVE) {
return [
`本任务权限档位workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`,
'授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。',
].join('\n');
}
if (e === ENFORCE_PARTIAL) {
return [
`本任务权限档位workspace。请把改动限制在 ${dir} 内;越界写入与命令执行会向人类请求授权。`,
'但**沙箱覆盖不完整**(有已知缺口),不要依赖「越界一定被拦」:请主动守住边界,需要改该目录之外的东西时先在回信里说明。',
].join('\n');
}
return [
`本任务权限档位workspace。请把改动限制在 ${dir} 内。`,
'**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。',
].join('\n');
}