按用户裁定「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、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
224 lines
9.5 KiB
JavaScript
224 lines
9.5 KiB
JavaScript
/**
|
||
* 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}`;
|
||
}
|