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:
@ -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 服务器):
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user