Files
MailUI4Agents/plugins/zcode-mail-bridge/src/turn-mode.mjs
JianFeeeee a00cbf36fc 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、会抢邮件)。

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

「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
2026-09-12 19:05:54 +08:00

224 lines
9.5 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.

/**
* AgentMail 的档位 → ZCode 的 `--mode` + `--disallowed-tools`。
*
* # 背景:平台的授权询问在 headless 下无法落地
*
* 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` 都调不动。
*
* 我们本来打算让平台把询问转给我们(`PermissionRequest` 钩子),在本版本
* (ZCode 3.10.2 / CLI 0.16.5)**做不到**:钩子有时根本不注册,触发时也无条件
* 在 ~5ms 内失败、命令从未被 spawn(用「钩子写 marker 文件」的副作用验证过)。
* 详见 README 的「已知缺口」。
*
* # 采用的姿态:平台让开,门禁由我们自己拿
*
* --mode yolo 平台不再做任何权限判定
* --disallowed-tools <清单> 把它自带的一切「能动机器」的工具全部拿掉
* (我们的 run_command / write_file 在 lib/action-tools.mjs 里自己问人)
*
* 于是安全边界只在两处:**本文件那张审过的清单**,以及我们自己的门禁。
* 平台不再提供第二道防线 —— 这是这个姿态必须在 README 里说清楚的代价。
*
* # 为什么不能反过来用白名单
*
* CLI 的 `--allowed-tools` 在 help 里写着,但**解析器不认**
* (`Unknown option '--allowed-tools'`)。实测过一次误判:先看到「文件没被创建」
* 就以为白名单生效,其实进程只是没跑起来。所以这里只能用黑名单,
* 而黑名单的完整性必须靠**穷举工具名**来保证(见 REVIEWED_DENYLIST)。
*/
import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permission-mode.js';
/** 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。
*
* | 档位 | mode | 谁在把关 |
* |------|------|---------|
* | plan | `plan` | 平台 + 我们(我们的门禁在 plan 档一律拒执行类工具) |
* | workspace | `yolo` | **我们自己的门禁**(每次执行前问人) |
* | full | `yolo` | 发件人已声明全权,门禁直接放行 |
*
* 三个档位都用同一张禁用清单:即使 full 档,危险的自带工具也不还回去。
* 理由是**只有一条代码路径**才不会有「哪个档忘了加」的缺陷;而且我们的
* `run_command` 已经覆盖了 Bash 的能力,还额外带来输出上限、保护目录与审计日志。
*/
const MODE_FOR_TIER = {
[MODE_PLAN]: 'plan',
workspace: 'yolo',
[MODE_FULL]: 'yolo'
};
/**
* 解析档位映射。允许用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖,格式
* `workspace:build,full:yolo`(逗号分隔)。
*
* 存在的理由:将来平台修好钩子(或换版本后行为变了),应该**只改配置就能恢复**
* 成 build/plan,而不是等一次发版。
*/
export function resolveModeMap(env = process.env) {
const raw = String(env.AGENTMAIL_ZCODE_MODE_MAP || '').trim();
if (!raw) return { ...MODE_FOR_TIER };
const out = { ...MODE_FOR_TIER };
for (const pair of raw.split(',')) {
const [tier, mode] = pair.split(':').map(s => s.trim());
if (!tier || !ZCODE_MODES.includes(mode)) continue;
out[normalizeMode(tier)] = mode;
}
return out;
}
/**
* @param {string} tier AgentMail 的档位(plan / workspace / full)
* @param {object} [env]
* @returns {'build'|'edit'|'plan'|'yolo'}
*/
export function zcodeModeForTier(tier, env = process.env) {
const t = normalizeMode(tier) || DEFAULT_MODE;
return resolveModeMap(env)[t] || 'plan';
}
/**
* 本次调用要禁用的自带工具。
*
* `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}(只读:平台会拒非只读工具,我们的门禁也会拒执行类工具)`;
}
if (t === MODE_FULL) {
return `${t} 档 → --mode ${mode}(发件人声明全权:我们自己的门禁直接放行,平台不自带危险工具)`;
}
if (mode === 'yolo') {
return (
`${t} 档 → --mode ${mode}(平台不做权限判定,危险的自带工具已被禁用;` +
`执行类动作由 AgentMail 门禁**逐次向发件人请示**)`
);
}
if (mode === 'build' || mode === 'edit') {
return `${t} 档 → --mode ${mode}(平台自带工具会走到 AgentMail 授权钩子)`;
}
return `${t} 档 → --mode ${mode}`;
}