feat(zcode): yolo + 自有工具面 + 我们自己的执行门禁(headless 真正能干活了)

按用户裁定「yolo_own_tools」实现:平台让开(--mode yolo),它自带的一切
「能动机器」的工具被 --disallowed-tools 拿掉,执行类动作改由我们自己的
run_command / write_file 承担,而门禁就在这两个工具里 —— 逐次向发件人请示。

## 为什么必须走这条路(实测,不是推断)

MCP 工具的 needsApproval 在产物里**硬编码为 true**(与 annotations 无关),
而 build/edit 档的判定最后一条是「需要审批 → ask」;headless 没有审批客户端
可问 ⇒ **每个 MCP 工具都被拒**(连 read_inbox 都调不动)。
我们本想让平台把询问转给钩子,但 PermissionRequest 在本版本(3.10.2 / CLI 0.16.5)
**不可靠**:有时压根不注册,触发时也无条件在 ~5ms 内失败、命令从未被 spawn
(用「钩子写 marker 文件」的副作用验证)。

于是选择只剩两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
后者才既可用又可审计。代价(平台不再提供第二道防线)写进了 README 的残余风险。

## 新增

- `lib/approval.mjs`:授权往返的唯一实现(钩子与工具共用,否则必然漂移)。
  三条不可动摇的规矩:只有明确同意才放行(判据是共用库的前缀白名单,
  不是「不等于拒绝」);永久失败(409/4xx)当场拒绝并把服务端建议带给模型;
  暂时失败看有没有本地界面 —— 判据用**调用方传的 sessionId**(单一事实来源,
  不再另读环境变量)。自己开 SSE 等决定,先建连再发请求。
- `lib/action-tools.mjs`:`run_command` / `write_file`。输出上限、超时上限、
  默认 cwd=工作区;拒绝时**抛错**(MCP 层转 isError)而不是返回「已处理」——
  opencode 上「工具失败但报成功」导致模型连试 6 次后放弃整个任务的教训。
  平台保护目录(网关数据库/插件代码/服务单元/密钥目录)**无论谁批准都不写**,
  且判定在门禁之前(不消耗人的注意力)——防的是自我强化:邮件驱动的 Agent
  可能被来信诱导去改自己的插件代码,改完下一轮就换了一套规则。
- `REVIEWED_DENYLIST`(32 项):逐条按「不拿掉会怎样」分类。名单来自 CLI 产物里
  模型可见工具名的**权威注册表**(aIn 那个 28 项数组)+ 另一份更宽的候选集并集,
  **不采信模型自述**(基线里它用某个没点名的方式真的创建了文件)。
  最容易被漏掉的是 `js` / `mcp__node_repl__js`:它挂在 MCP 上、
  产物里自述「can run arbitrary JavaScript with full Node privileges, like Bash」。
- 提示词的能力说明(分档):告诉模型自带工具被禁、动手要用哪两个工具、
  会被请示;并明确「被拒是业务结果,不要重试、不要绕道」。

## 修掉三个真缺陷(都是实测撞出来的)

1. **幂等键按「会话+工具」取 → 同会话第二次调用被静默吞掉**。
   网关对重复 relay_key 返回 **HTTP 200** `{status:"duplicate_relay"}` 并提前返回:
   不建请求、不发邮件、**永远不会有人来决策**。于是工具干等 → 被 MCP 调用超时
   砍掉 → 模型回报「30 秒内未获批准」。从状态码到措辞全看不出问题,归因还完全
   错了(像是人没理它)。改为**按调用唯一**(保留会话/工具前缀便于反查),
   并把 duplicate_relay 当成可读的拒绝(fail fast,不再干等)。
2. **授权窗口被 MCP 调用超时截断**。ZCode 对 MCP 工具调用有超时(默认量级 30 秒),
   而门禁要等人。已在插件清单声明 `mcpServers.agentmail.timeoutMs=600000`
   (实测生效:40 秒的命令没被砍,墙钟 50 秒通过),并让门禁**自己**把等待夹到
   timeoutMs - 余量之下(`resolveWaitMs`)——被客户端杀掉时连理由都发不出去,
   所以必须由我们自己先 settle。
3. **`--allowed-tools` 在 help 里写着但解析器不认**(`Unknown option`)。
   留着会拼出一条永远跑不起来的命令行,现在 `buildRunArgs` 直接抛错并指出
   替代方案。我在这里误判过一次:先看到「文件没创建」就以为白名单生效,
   其实进程只是没退到 usage。判据缺了「进程真的执行了」这一环。

## 自报改成如实

detectModeEnforcement 以前拿「钩子已注册」当 native 的凭据 —— yolo 下钩子
根本不会触发,那等于替一个不存在的能力背书。现在先看**我们那条链**是否就绪
(yolo + 禁用清单里真的有 Bash/js),就绪才报 native,并在理由里点明谁在把关
(实测输出:「执行类动作只能经我们自己的门禁…平台自带危险工具已禁用 32 项」)。

## 验证

- 单测 376/376(新增 47 条)。重点在反向对照:一句「拒绝/deny/空串/平台自己的
  shutdown 哨兵都不放行」之外,还验了「别人的决策不能拿来用(relay_key 配对)」、
  「超时必须真的拒绝」、「同一会话两次调用必须用不同的幂等键」、
  「重复请求要当场拒绝而不是干等」;执行工具的每条拒绝场景都配一个**文件系统断言**
  (「抛错了」不等于「副作用没发生」),保护目录还验了 `..`/`./` 绕不过去。
- 真模型端到端(`/root/e2e-zcode-gate/run.py`,13/13):
  批 → 命令真执行(文件内容=标记);拒 → 命令真没执行(文件不存在)
  且回信把成因说成「人拒绝」而**不是**「超时」;同会话第三次调用仍能产生新请求
  并在获批后执行。判据本身也修了两处(授权请求邮件里带标记会被误当成回信;
  备注在通过项旁边显示会误导)。
- 部署:`deploy/redeploy-plugin.sh zcode` 快照切换 + 握手自检;
  驱动单元改为跑快照(生产不跑仓库工作区),env 与清单超时的关系写进注释。
- 顺手清掉一个遗留驱动进程(跑的是仓库路径的旧代码、连着网关 SSE、会抢邮件)。

## 判据纪律(本轮又踩到、已写进代码注释)

「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
This commit is contained in:
2026-09-12 19:05:54 +08:00
parent 10ff50e899
commit a00cbf36fc
16 changed files with 1963 additions and 164 deletions

View File

@ -1,31 +1,34 @@
/**
* AgentMail 的档位 → ZCode `--mode` 的映射。
* AgentMail 的档位 → ZCode 的 `--mode` + `--disallowed-tools`。
*
* # 为什么这个映射必须存在,而且不能想当然
* # 背景:平台的授权询问在 headless 下无法落地
*
* ZCode 的权限判定里有一条:
* ZCode 的 MCP 工具 `needsApproval` 在产物里是**硬编码 `true`**(`Ari()` 里
* `let d=!0`,与 `annotations` 无关)。而 `build`/`edit` 档的判定最后一条是
* 「需要审批 → ask」,headless 没有审批客户端可问 ⇒ **每个 MCP 工具全被拒**
* (`permission.resolved: deny, "No permission client configured for X"`),
* 模型连 `read_inbox` 都调不动。
*
* t.mode === "yolo" ? this.allow(t, i, "mode.yolo", "Yolo mode bypasses permission prompts")
* 我们本来打算让平台把询问转给我们(`PermissionRequest` 钩子),在本版本
* (ZCode 3.10.2 / CLI 0.16.5)**做不到**:钩子有时根本不注册,触发时也无条件
* 在 ~5ms 内失败、命令从未被 spawn(用「钩子写 marker 文件」的副作用验证过)。
* 详见 README 的「已知缺口」。
*
* 也就是 **`yolo` 会绕过全部权限询问**,PermissionRequest 钩子根本不会触发 ——
* 我们的授权桥会**静默消失**(不是报错,是没有询问,看起来一切正常)。
* # 采用的姿态:平台让开,门禁由我们自己拿
*
* 而 `--prompt` 的**默认 mode 就是 `yolo`**(`--mode` 的 help 写着
* "default: yolo for --prompt")。所以驱动若图省事不传 `--mode`,
* 人就会以为「授权系统在管事」,实际每一条命令都已经自动放行了。
* --mode yolo 平台不再做任何权限判定
* --disallowed-tools <清单> 把它自带的一切「能动机器」的工具全部拿掉
* (我们的 run_command / write_file 在 lib/action-tools.mjs 里自己问人)
*
* 反过来也不能一律传 `build`:档位的意义就是三种不同的行为。
* 于是安全边界只在两处:**本文件那张审过的清单**,以及我们自己的门禁。
* 平台不再提供第二道防线 —— 这是这个姿态必须在 README 里说清楚的代价。
*
* # 依据(从 CLI 产物里读出的规则表,不是猜)
* # 为什么不能反过来用白名单
*
* - `checkBuildMode`:只读放行;critical/high 风险 → **ask**;
* 有副作用 / 需要审批 → **ask**。`Bash` 属 destructive(high)→ ask;
* `Write`/`Edit` 有 workspace 副作用 → ask。
* - `checkEditMode`:`permissionName === "edit"` 的工作区文件编辑放行,其余退回 build。
* - plan:`mode.plan.nonReadOnly` → 非只读一律**拒**。
* - yolo:一律放行。
*
* 于是映射为:plan → plan,workspace → build,full → yolo。
* CLI 的 `--allowed-tools` 在 help 里写着,但**解析器不认**
* (`Unknown option '--allowed-tools'`)。实测过一次误判:先看到「文件没被创建」
* 就以为白名单生效,其实进程只是没跑起来。所以这里只能用黑名单,
* 而黑名单的完整性必须靠**穷举工具名**来保证(见 REVIEWED_DENYLIST)。
*/
import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permission-mode.js';
@ -33,53 +36,116 @@ import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permis
/** ZCode 认识的 headless mode(`normalizePromptMode` 只接受这四个)。 */
export const ZCODE_MODES = ['build', 'edit', 'plan', 'yolo'];
/**
* 审过的禁用清单。
*
* # 依据
*
* 名单来自 CLI 产物里**模型可见工具名的权威注册表**(`aIn` 那个 28 项数组,
* 含 `ApplyPatch`/`Bash`/`js`/`mcp__node_repl__js` 等),并与另一处更宽的候选集
* 取并集。不采信模型的自述 —— 实测让模型「列出可用工具」时,它给的是它以为的
* 清单,而基线里它**用某个没点名的方式真的创建了文件**。
*
* # 分类(每一类都必须能说出「不拿掉会怎样」)
*
* - **机器改动**:`Bash` 任意命令;`Write`/`Edit`/`ApplyPatch`/`NotebookEdit` 写文件;
* `LSP`(rename 会应用工作区编辑);`EnterWorktree`/`ExitWorktree` 改仓库。
* - **等价于 Bash 的执行通道**:`js` 及其 MCP 别名 —— 产物里它自己的描述就是
* 「Node REPL can run arbitrary JavaScript with full Node privileges
* (require/process), like Bash」,`riskLevel: "high"`。这是最容易被漏掉的一个:
* 它挂在 **MCP** 上,看起来不像自带工具。同一族的 `js_reset` /
* `js_add_node_module_dir` 单独看无害(只清状态/改模块解析路径),
* 但拿掉更省心:它们对邮件驱动的一轮会话没有任何用处。
* - **延迟执行**(绕过当期审批,把危险动作挪到没人看着的时候):
* `CronCreate`/`CronUpdate`/`CronDelete`/`CronList`、`ScheduleWakeup`、`Workflow`。
* - **子代理**:`Agent`/`Task` —— 子会话的工具集是否继承本清单**未经验证**,
* 不拿掉就等于开一个洞。代价可接受:headless 是一封邮件一个进程,
* 跑完就退,子代理本就没什么意义。
* - **后台任务编排**:`TaskCreate`/`TaskGet`/`TaskList`/`TaskOutput`/`TaskStop`/
* `TaskUpdate`(同上,进程活不过一轮)。
* - **绕过 AgentMail 的对外通道**:`SendMessage`/`RespondToCoordinator` ——
* 它们能直接给别的会话/协作者发消息,绕过「邮件是唯一交互范式」,
* 于是绕过我们全部的预算、中继跳数与可见性治理。
* - **档位逃生门**:`EnterPlanMode`/`ExitPlanMode` —— 中途切档会让平台的判定
* 与我们自己的门禁临时错位(plan 档下平台会拒掉我们声明为 destructive 的工具),
* 表现为「同一个动作刚才可以、现在被拒」而没人能解释。
*
* # 保留的(只读或纯本地状态)
*
* `Read` `Glob` `Grep` `WebFetch` `WebSearch` `web_search` `TodoRead` `TodoWrite`
* `GoalRead` `ReadSessionContext` `AskUserQuestion` `Skill`。
*
* 其中 `WebFetch`/`WebSearch` 是**网络出向**:它们读不到本机文件,但能把
* 上下文里的内容编码进 URL 发出去。这是已知且接受的残余风险(README 记一笔),
* 要收紧就把它们加进 `AGENTMAIL_ZCODE_DISALLOWED_TOOLS`。
*/
export const REVIEWED_DENYLIST = [
// 机器改动
'Bash',
'Write',
'Edit',
'ApplyPatch',
'NotebookEdit',
'LSP',
'EnterWorktree',
'ExitWorktree',
// 等价于 Bash 的 JS 执行通道
'js',
'js_reset',
'js_add_node_module_dir',
'mcp__node_repl__js',
'mcp__node_repl__js_reset',
'mcp__node_repl__js_add_node_module_dir',
// 延迟执行
'Workflow',
'CronCreate',
'CronUpdate',
'CronDelete',
'CronList',
'ScheduleWakeup',
// 子代理
'Agent',
'Task',
// 后台任务编排
'TaskCreate',
'TaskGet',
'TaskList',
'TaskOutput',
'TaskStop',
'TaskUpdate',
// 绕过 AgentMail 的对外通道
'SendMessage',
'RespondToCoordinator',
// 档位逃生门
'EnterPlanMode',
'ExitPlanMode'
];
/**
* 档位 → ZCode mode。
*
* # 三个模式在 headless 下的真实行为(逐条实测 + 逆代码,見 README)
* | 档位 | mode | 谁在把关 |
* |------|------|---------|
* | plan | `plan` | 平台 + 我们(我们的门禁在 plan 档一律拒执行类工具) |
* | workspace | `yolo` | **我们自己的门禁**(每次执行前问人) |
* | full | `yolo` | 发件人已声明全权,门禁直接放行 |
*
* | mode | ZCode 自带工具 | 本插件的 MCP 工具 | 结果 |
* |-------|---------------|------------------|------|
* | build | Bash/Write/Edit → ask | **全部 → ask** | 没有交互式审批客户端 ⇒ **全部被拒** |
* | edit | 文件编辑放行,其余同 build | 同 build | 同上 |
* | plan | 非只读一律拒(fail-closed)| **非破坏性的直接放行** | 只读可用 |
* | yolo | 全部放行 | 全部放行 | 全可用,但**没有任何审批** |
*
* 关键在于 MCP 工具的 `needsApproval` 是**硬编码为 true** 的(`Ari()` 里 `let d=!0`),
* 与 `annotations` 无关;而 `build` 档的判定最后一条是
* `needsApproval || destructive || sideEffectScope !== "none" → ask`。
* 于是 **`build` 档下每一个 MCP 工具都要审批**,而 headless 模式没有审批客户端 →
* 全被拒。实测:模型连 `read_inbox` 都调不动,只能靠提示词里带的信息猜。
*
* 而 `plan` 档有一条 `permissionName === "mcp" && !destructive → allow`,
* 所以**声明了 `destructiveHint: false` 的 MCP 工具在 plan 档下直接放行**。
* 实测:模型用 read_inbox 读出了只存在于邮件正文里的标记。
*
* # 为什么 workspace 档映射到 plan 而不是 build
*
* `build` 在本环境下等于「什么都不能做」(连读信都被拒)——那不是保守,
* 是不可用。而 `plan` 是**真的 fail-closed**:危险的自带工具被平台直接拒,
* 我们能用的只有自己声明的非破坏性工具。人在 workspace 档要的是「能干活」,
* 拿不到;那就退到「至少能读能回」,并**在日志里说清楚为什么**,
* 而不是静默地变成一个什么都干不了的代理。
*
* 要真让它动手,只有两条路(见 README 的「已知缺口」):
* 1) 平台修好 PermissionRequest 钩子 → 审批能送达人;
* 2) 显式改用 yolo(`AGENTMAIL_ZCODE_MODE_MAP`)+ `--disallowed-tools`
* 把危险的自带工具拿掉,只留我们自己的工具面。
* 三个档位都用同一张禁用清单:即使 full 档,危险的自带工具也不还回去。
* 理由是**只有一条代码路径**才不会有「哪个档忘了加」的缺陷;而且我们的
* `run_command` 已经覆盖了 Bash 的能力,还额外带来输出上限、保护目录与审计日志。
*/
const MODE_FOR_TIER = {
[MODE_PLAN]: 'plan',
workspace: 'plan',
workspace: 'yolo',
[MODE_FULL]: 'yolo'
};
/**
* 解析档位映射。允许用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖,格式
* `workspace:yolo,full:yolo`(逗号分隔)。
* `workspace:build,full:yolo`(逗号分隔)。
*
* 存在的理由:将来平台修好钩子(或本机换版本后行为变了),
* 应该**只改配置就能恢复**成 build,而不是等一次发版。
* 存在的理由:将来平台修好钩子(或换版本后行为变了),应该**只改配置就能恢复**
* 成 build/plan,而不是等一次发版。
*/
export function resolveModeMap(env = process.env) {
const raw = String(env.AGENTMAIL_ZCODE_MODE_MAP || '').trim();
@ -104,28 +170,54 @@ export function zcodeModeForTier(tier, env = process.env) {
}
/**
* 这个 mode 是否会让危险操作走到我们的授权钩子。
* 本次调用要禁用的自带工具。
*
* 用于启动自检与日志 —— 「驱动跑起来了但一次授权询问都没发生」有两种成因
* (真没人碰危险工具 / mode 把询问绕过了),它们必须以不同的方式被看见。
* `AGENTMAIL_ZCODE_DISALLOWED_TOOLS` 是**整表替换**(不是追加):
* 收紧(把 WebFetch 也拿掉)与放宽(临时还回 Bash,比如本地调试)都靠它。
* 传空串表示「一张空清单」—— 需要与「没设置」区分开,所以用 `=== undefined` 判。
*/
export function denylistForTier(tier, env = process.env) {
const raw = env.AGENTMAIL_ZCODE_DISALLOWED_TOOLS;
if (raw === undefined || raw === null) return [...REVIEWED_DENYLIST];
return String(raw)
.split(/[\s,]+/)
.map(s => s.trim())
.filter(Boolean);
}
/**
* 这个 mode 是否会让危险操作走到平台的授权询问。
*
* 只用于启动自检与日志。`yolo` 恒为 false —— 而那正是我们现在**故意**要的:
* 平台不问,我们自己的门禁问。这件事必须以不同方式被看见,不能被理解成
* 「授权系统消失了」。
*/
export function modeReachesPermissionHook(mode) {
return mode === 'build' || mode === 'edit';
}
/** 我们自己的门禁是否在这次调用里生效(只有非 yolo 时才没有)。 */
export function ourGateIsActive(tier) {
return normalizeMode(tier) !== MODE_FULL;
}
/** 权限档位的人话解释,写进日志与回信里,方便复盘「当时是什么档」。 */
export function describeTier(tier, mode = zcodeModeForTier(tier)) {
const t = normalizeMode(tier) || DEFAULT_MODE;
if (t === MODE_PLAN) return `${t} 档 → --mode ${mode}(只读,ZCode 自己就会拒非只读工具)`;
if (t === MODE_FULL) return `${t} 档 → --mode ${mode}(全权,刻意绕过审批)`;
if (mode === 'plan') {
if (t === MODE_PLAN) {
return `${t} 档 → --mode ${mode}(只读:平台会拒非只读工具,我们的门禁也会拒执行类工具)`;
}
if (t === MODE_FULL) {
return `${t} 档 → --mode ${mode}(发件人声明全权:我们自己的门禁直接放行,平台不自带危险工具)`;
}
if (mode === 'yolo') {
return (
`${t} 档 → --mode ${mode}(本档本应「危险操作问人」,本平台 headless 做不到:` +
`MCP 工具在 build 档下恒需审批而无人可批,只能退到只读)`
`${t} 档 → --mode ${mode}(平台不做权限判定,危险的自带工具已被禁用;` +
`执行类动作由 AgentMail 门禁**逐次向发件人请示**)`
);
}
if (mode === 'build' || mode === 'edit') {
return `${t} 档 → --mode ${mode}(危险操作会走到 AgentMail 授权钩子)`;
return `${t} 档 → --mode ${mode}(平台自带工具会走到 AgentMail 授权钩子)`;
}
return `${t} 档 → --mode ${mode}`;
}