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

@ -43,7 +43,7 @@ import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
import { normalizeMode } from '../lib/permission-mode.js';
import { clampRelayKey } from '../lib/relay-key.js';
import { explicitSendsFile, readExplicitSends } from '../lib/explicit-sends.mjs';
import { zcodeModeForTier, modeReachesPermissionHook, describeTier } from './turn-mode.mjs';
import { zcodeModeForTier, denylistForTier, modeReachesPermissionHook, describeTier } from './turn-mode.mjs';
import { buildMailPrompt, replySubject, renderTurnFailure } from './prompt.mjs';
import { runTurn, DEFAULT_CLI } from './zcode-run.mjs';
import { isMainModule } from '../lib/is-main.mjs';
@ -77,26 +77,57 @@ export function zcodeSessionFallback(sessionKey, rootOverride) {
/**
* 自报给网关的强制力。
*
* **只声明得出来的事**ZCode 上的档位强制全部靠插件里的 PermissionRequest 钩子,
* 钩子没被注册(插件没启用 / 被禁 / 清单被改坏)时我们什么也拦不住,
* 那时还报 native 就是在替一个不存在的能力背书 —— 而这个声明的用途正是
* 让人相信「这一档在这里是被强制的」。
* **只声明得出来的事**。而且要分开两件事,它们以前被当成了同一件:
*
* 查的是插件自己的 `hooks/hooks.json`(驱动就住在这个插件里),
* 不需要额外的配置项。
* 1. **平台自己的权限引擎**PermissionRequest 钩子)—— 只在 `--mode build/edit`
* 下才会问。现在档位映射把 workspace 映到 `yolo`(平台恒 allow、钩子根本不会触发
* 所以**拿「钩子已注册」当强制力证据是错的**。桌面(有界面)时它仍然有用,
* 但 headless 驱动这条路上不是它。
* 2. **我们自己的门禁**`lib/action-tools.mjs`)—— `yolo` + `--disallowed-tools`
* 之后,能动机器的只剩我们两个工具,而它们每次都要过 `requestApproval`。
* 这个才是这条路上真正拦得住东西的那一层。
*
* 于是判据改成:先看我们自己那条链能不能真的拦住(模块在不在 + 禁用清单够不够),
* 再说平台那一层是否还参与。报 native 的含义是「该档位真的被强制,而非口头约定」。
*
* 查的是驱动自己的文件(它住在插件里),不需要额外配置项。
*/
export function detectModeEnforcement({ hooksFile } = {}) {
export function detectModeEnforcement({ hooksFile, env = process.env } = {}) {
const file = hooksFile || fileURLToPath(new URL('../hooks/hooks.json', import.meta.url));
// 我们自己那条链:必须是 yolo平台不问+ 一张够长的禁用清单。
const mode = zcodeModeForTier('workspace', env);
const denied = denylistForTier('workspace', env);
const gateReady = mode === 'yolo' && denied.includes('Bash') && denied.includes('js');
// 平台那条链:钩子清单里真的注册了 PermissionRequest桌面模式下才用得上
let hookRegistered = false;
let hookNote;
try {
const cfg = JSON.parse(readFileSync(file, 'utf8'));
const entries = cfg?.hooks?.PermissionRequest;
if (Array.isArray(entries) && entries.some(e => Array.isArray(e?.hooks) && e.hooks.length > 0)) {
return { enforcement: 'native', reason: `钩子已注册${file}` };
}
return { enforcement: 'advisory', reason: `钩子清单里没有 PermissionRequest${file}` };
hookRegistered = Array.isArray(entries) && entries.some(e => Array.isArray(e?.hooks) && e.hooks.length > 0);
hookNote = hookRegistered ? `钩子已注册(${file}` : `钩子清单里没有 PermissionRequest${file}`;
} catch (e) {
return { enforcement: 'advisory', reason: `读不到钩子清单(${file}${e?.message || e}` };
hookNote = `读不到钩子清单(${file}${e?.message || e}`;
}
if (gateReady) {
return {
enforcement: 'native',
reason:
`执行类动作只能经我们自己的门禁run_command / write_file 逐次请示),` +
`平台自带危险工具已禁用 ${denied.length} 项(--mode ${mode}` +
(hookRegistered ? ';桌面模式另有平台钩子' : '')
};
}
if (hookRegistered && modeReachesPermissionHook(mode)) {
return { enforcement: 'native', reason: hookNote };
}
return {
enforcement: 'advisory',
reason: hookRegistered ? `钩子已注册但不参与(--mode ${mode}` : `${hookNote},且门禁自检未通过`
};
}
/** 描述错误:把「网关可达但返回 4xx」与「连不上」分开 —— 两者的应对完全不同。 */
@ -195,15 +226,18 @@ export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = p
const sessionId = data?.session_id || '';
const tier = normalizeMode(data?.permission_mode);
const mode = zcodeModeForTier(tier);
// 危险的自带工具一律拿掉(三个档位同一张审过的清单);需要动手时,
// 模型改用我们自己的 run_command / write_file门禁就在那里面。
const disallowedTools = denylistForTier(tier);
const cwd = resolveCwd(data);
const prev = sessions.get(sessionId);
const resume = prev?.zcodeSessionId || '';
logFn(`处理 ${data.mail_id}${describeTier(tier, mode)}cwd=${cwd}${resume ? `|续会话 ${resume}` : ''}`);
if (!modeReachesPermissionHook(mode) && tier !== 'plan') {
// 只有 full 档会走到这里,且是刻意的。写日志是因为「没有权限询问」
// 在 yolo 下是预期行为,在 build 下则是缺陷 —— 两者必须能区分
logFn(`注意:--mode ${mode} 会产生权限询问(本档如此设计`);
logFn(`已禁用 ${disallowedTools.length} 个自带工具Bash/Write/Edit/js/…),执行类动作走 AgentMail 门禁`);
if (modeReachesPermissionHook(mode)) {
// 平台会问、我们的钩子会转达 —— 说明档位映射被配置改回了 build/edit
logFn(`注意:--mode ${mode} 下平台自带工具会产生权限询问(映射被覆盖过?`);
}
const prompt = buildMailPrompt({ agentName: CFG.agentName, data });
@ -215,6 +249,7 @@ export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = p
mode,
maxTurns: CFG.maxTurns,
resumeSessionId: resume || undefined,
disallowedTools,
turnTimeoutMs: CFG.turnTimeoutMs,
cliPath: CFG.cliPath,
// 注入给 ZCode 进程(→ 继承给插件、钩子、MCP 服务器):

View File

@ -16,6 +16,54 @@
*/
import { inboundHeadline, replyInstruction } from '../lib/relay-policy.js';
import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permission-mode.js';
/**
* 告诉模型它的执行能力到底长什么样。
*
* 不说清楚会发生两件都很糟的事:模型去调 `Bash` 然后拿到一个看不懂的
* 「工具不存在」(于是它反复换名字试,浪费整轮),或者它以为自己不能动手,
* 把本来能做完的活写成「建议你自己执行」。
*
* 同时要把**授权这件事本身**讲明白:`run_command`/`write_file` 会先向发件人
* 申请,被拒时工具会报错并给出原因。模型必须知道「被拒」是一个正常的、
* 需要它改道的结果,而不是重试同一个动作的理由。
*/
export function capabilityNote(tier) {
const t = normalizeMode(tier) || DEFAULT_MODE;
const head =
'## 你在这个平台上的执行能力\n\n' +
'ZCode 自带的 Bash / Write / Edit / js 等工具**已被禁用**(不是故障,是本平台的策略:' +
'邮件驱动的会话里没有本地界面可以给人审批,所以平台不做权限判定,改由我们自己的门禁负责)。';
if (t === MODE_PLAN) {
return (
head +
'\n\n当前是 **plan 档**:只读。你**不能**执行命令或写文件(`run_command`/`write_file` 在' +
'本档一律拒绝,不必尝试)。请把需要动手的步骤写进回信,说明「执行什么、为什么」。'
);
}
const gate =
t === MODE_FULL
? '当前是 **full 档**:发件人已声明全权,你的执行类调用**直接生效,不会打扰任何人**。'
: '当前是 **workspace 档**:每类执行动作**第一次调用会先向发件人申请授权**' +
'(他可以在界面上选「同意」「一直同意」或「拒绝」)。';
return (
head +
'\n\n需要动手时请改用这两个工具\n' +
'- `run_command`:执行一条 shell 命令(工作目录默认是本会话工作区)\n' +
'- `write_file`:写一个文件(父目录自动创建;平台自身的部署/数据库/服务配置目录' +
'无论谁批准都拒绝写入)\n' +
'\n' +
gate +
'\n\n被拒绝时工具会**报错并给出原因**(含决策人)。这是正常的业务结果,不是故障:' +
'不要重试同一个动作、也不要绕道去找其它执行手段,而应当在回信里说明' +
'「哪一步被拒了、为什么需要它、希望对方怎么做」。\n' +
'读写与搜索用自带的 Read / Glob / Grep这些只读工具始终可用。'
);
}
/** 去掉已有的 Re:/答复前缀,避免 `Re: Re: Re: …` 越滚越长。 */
export function replySubject(subject) {
@ -103,7 +151,9 @@ export function buildMailPrompt({ agentName, data, kind = 'mail', reused = false
'',
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
'然后处理其中的请求。',
...replyInstruction({ fromHuman, replyAddress: data?.reply_address })
...replyInstruction({ fromHuman, replyAddress: data?.reply_address }),
'',
capabilityNote(data?.permission_mode)
);
return lines.join('\n');
}

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` 属 destructivehigh→ ask
* `Write`/`Edit` 有 workspace 副作用 → ask。
* - `checkEditMode``permissionName === "edit"` 的工作区文件编辑放行,其余退回 build
* - plan`mode.plan.nonReadOnly` → 非只读一律**拒**。
* - yolo一律放行。
*
* 于是映射为plan → planworkspace → buildfull → 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}`;
}

View File

@ -58,9 +58,18 @@ export function buildRunArgs({
args.push('--mode', mode);
if (maxTurns) args.push('--max-turns', String(maxTurns));
if (resumeSessionId) args.push('--resume', resumeSessionId);
// `--allowed-tools` 在 help 里写着,但 CLI 的解析器**不认**它
// `Unknown option '--allowed-tools'`),会直接退到 usage。
// 留着这个参数会拼出一条永远跑不起来的命令行,所以在拼参数这一步就报错,
// 而不是等到一两分钟后拿到一段 usage 文本才去查。
if (Array.isArray(allowedTools) && allowedTools.length) {
args.push('--allowed-tools', allowedTools.join(','));
throw new Error(
'本版本 ZCode CLI 不支持 --allowed-tools解析器报 Unknown option' +
'只能用 --disallowed-tools 黑名单,见 src/turn-mode.mjs 的 REVIEWED_DENYLIST'
);
}
// 逗号分隔help 说「Comma or space-separated」实测两种都行
// 但逗号不受调用方是否把参数拼成单个 argv 的影响。
if (Array.isArray(disallowedTools) && disallowedTools.length) {
args.push('--disallowed-tools', disallowedTools.join(','));
}
@ -102,6 +111,7 @@ export function parseStreamLine(line) {
*
* @param {{prompt:string, cwd:string, mode:string, maxTurns?:number,
* resumeSessionId?:string, turnTimeoutMs?:number,
* disallowedTools?:string[],
* env?:Record<string,string>, cliPath?:string, nodePath?:string,
* onChild?:(kill:(signal?:string)=>void)=>void,
* onEvent?:(event:any)=>void}} opts