按用户裁定「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、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
160 lines
7.5 KiB
JavaScript
160 lines
7.5 KiB
JavaScript
/**
|
||
* 由一封邮件构造驱动 ZCode 的提示词。
|
||
*
|
||
* 结构与 pi / dsh / opencode 三桥**同源**(复用 `lib/relay-policy.js` 的
|
||
* `inboundHeadline` 与 `replyInstruction`):同一封邮件派到不同 Agent 上,
|
||
* 模型该看到同样的交代。措辞一旦在某个平台走样,就会出现「同一封邮件
|
||
* 在这个 Agent 上会回信、在那个 Agent 上装死」这种只在单平台复现的问题。
|
||
*
|
||
* 两个关键信号都来自服务端,不由插件猜:
|
||
*
|
||
* - `from_human`:决定「插件会不会替你回信」。猜错的代价不对称 ——
|
||
* 把 Agent 的来信说成人的来信,会让模型以为有人会替它开口而什么都不做;
|
||
* 反之只是多调一次 send_mail。
|
||
* - `in_reply_to`:这封是回信还是新任务。模型分不清这两者时,会把对方一句
|
||
* 「已收到」当成新待办再做一遍(Agent↔Agent 客套循环的真正成因)。
|
||
*/
|
||
|
||
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) {
|
||
const base = String(subject ?? '')
|
||
.replace(/^\s*(re|答复|回复)\s*[::]\s*/gi, '')
|
||
.trim();
|
||
return base ? `Re: ${base}` : '本轮工作总结';
|
||
}
|
||
|
||
/**
|
||
* 一轮完全跑不起来时的回信正文。
|
||
*
|
||
* **必须发这封信**:模型一次都没跑起来 → 会话里没有任何产出 → 自动转发什么也
|
||
* 不会发 → 发件人只会觉得「信发出去了,然后再无音讯」。邮件驱动的会话没有
|
||
* 本地界面可以让人看见错误,回信是唯一的出口。
|
||
*
|
||
* 为什么不复用共用的 `renderFailureReport`:它的**建议**是平台特有的
|
||
* (「调整可用模型范围」),而 ZCode 跑不起来的常见成因是没登录、缺模型配置、
|
||
* CLI 本身报错 —— 照着那句建议去后台改模型范围,什么也解决不了。
|
||
*
|
||
* @param {{kind: string, error: string}[]} failures
|
||
* @param {string} subject
|
||
*/
|
||
export function renderTurnFailure(failures, subject) {
|
||
const list = Array.isArray(failures) ? failures : [];
|
||
const lines = [
|
||
`本次未能处理「${subject || '(无主题)'}」:ZCode 一轮都没能跑完。`,
|
||
'',
|
||
`已尝试 ${list.length} 次:`,
|
||
''
|
||
];
|
||
list.forEach((f, i) => {
|
||
lines.push(`${i + 1}. **${f?.kind || '失败'}**`);
|
||
lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`);
|
||
});
|
||
lines.push('');
|
||
lines.push('常见成因(按可能性):');
|
||
lines.push('- **没有登录**:ZCode 的模型访问要 OAuth,未登录时 headless 会直接报缺模型配置;');
|
||
lines.push(' 在服务器上跑 `node <zcode.cjs> login`,或直接开一次客户端扫码。');
|
||
lines.push('- **模型配置缺失**:`~/.zcode/cli/config.json` 里没有显式 provider。');
|
||
lines.push('- **CLI 路径不对**:`AGENTMAIL_ZCODE_CLI` 指向的 `zcode.cjs` 不存在或不可执行。');
|
||
lines.push('- **工作目录不可写**:ZCode 要在里面建会话状态。');
|
||
lines.push('');
|
||
lines.push('修好后可以重新把原邮件发一次,或直接回复这封信。');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* @param {{agentName: string, data: any, kind?: string, reused?: boolean}} input
|
||
* @returns {string}
|
||
*/
|
||
export function buildMailPrompt({ agentName, data, kind = 'mail', reused = false }) {
|
||
// 权限结论不是「一封新邮件」,而是我们自己在等的那件事有了答复。
|
||
// 当成普通邮件处理,模型会去 read_inbox 找一封其实已经不需要读的信。
|
||
if (kind === 'permission') {
|
||
return [
|
||
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
|
||
`(决策人:${data?.decided_by || '用户'})。`,
|
||
'请据此继续后续工作。'
|
||
].join('\n');
|
||
}
|
||
|
||
const fromHuman = data?.from_human === true;
|
||
const lines = [
|
||
inboundHeadline({
|
||
inReplyTo: data?.in_reply_to,
|
||
fromHuman,
|
||
catchup: data?.catchup,
|
||
reused
|
||
}),
|
||
'',
|
||
`发件人:${data?.from_name || 'unknown'}`,
|
||
`主题:${data?.subject || '(无主题)'}`,
|
||
`邮件 ID:${data?.mail_id || 'unknown'}`
|
||
];
|
||
|
||
if (data?.in_reply_to) lines.push(`回的是你那封:${data.in_reply_to}`);
|
||
if (!reused) lines.push(`身份:你是 ${agentName}`);
|
||
// 服务端算好的回信地址。带上它是因为模型**确实会**自己发信(抄送第三方、
|
||
// 分多封交代不同的事)。让它自己拼三维地址的话,`.new` 会被拼进去,
|
||
// 于是回信静默开出一条新会话,原线索里再无下文。
|
||
if (data?.reply_address) lines.push(`回信地址:${data.reply_address}`);
|
||
|
||
lines.push(
|
||
'',
|
||
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
|
||
'然后处理其中的请求。',
|
||
...replyInstruction({ fromHuman, replyAddress: data?.reply_address }),
|
||
'',
|
||
capabilityNote(data?.permission_mode)
|
||
);
|
||
return lines.join('\n');
|
||
}
|