/** * 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}`; }