Files
MailUI4Agents/plugins/zcode-mail-bridge/lib/action-tools.mjs
JianFeeeee a00cbf36fc 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、会抢邮件)。

## 判据纪律(本轮又踩到、已写进代码注释)

「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
2026-09-12 19:05:54 +08:00

337 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 我们自己的「会动机器」的工具 —— 每一次执行都要先过人的批准。
*
* # 为什么要有这些工具
*
* headless邮件驱动模式下ZCode 平台的授权询问**没有客户端可以问**
* 每个 MCP 工具的 `needsApproval` 在产物里是硬编码的 `true`,而引擎找不到
* 审批客户端时直接判 deny`permission.resolved: deny, "No permission client
* configured for X"`。我们试过让平台自己问人PermissionRequest 钩子),
* 在本版本3.10.2 / CLI 0.16.5**根本不可靠**:有时钩子压根不注册,
* 触发时也无条件在 ~5ms 内失败、命令从未被 spawn用「钩子写 marker 文件」
* 的副作用验证过)。
*
* 所以换一条路:**让 ZCode 走 `--mode yolo`**(平台不再拦我们的工具),
* 同时用 `--disallowed-tools` 把它自带的危险工具Bash/Write/Edit/js/…)
* 全部禁掉,只留只读的 Read/Glob/Grep。需要动手时模型改用**我们这几个工具**
* 而门禁就在我们自己的代码里 —— 这也是唯一能真正落地的地方:
* 我们能控制它的判据、日志与失败语义。
*
* # 安全边界(必须诚实地说清楚)
*
* `yolo` 意味着**平台不再有任何权限判定**。安全完全来自两件事:
*
* 1. `--disallowed-tools` 清单是否完整(见 src/turn-mode.mjs 的 REVIEWED_DENYLIST
* 它是一张黑名单,漏掉一个能动机器的工具就等于开一个洞。
* 2. 本文件的门禁。默认拒绝;只有「明确同意」才放行。
*
* # 规矩
*
* - 只读的工具不需要批准(读信、查地址)。需要批准的是**会改变机器状态的**
* 执行命令、写文件。
* - 拒绝时**抛错**MCP 层会把 `isError: true` 交给模型),而不是返回一句
* 「已处理」。opencode 上「工具失败但报成功」导致模型连试 6 次、最后放弃
* 整个任务的教训:失败必须让模型看见原因,它才有机会改道。
* - 批准之前**不产生任何副作用**(不建文件、不建目录)。
*/
import { execFile } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { requestApproval, tierOf, DEFAULT_WAIT_MS } from './approval.mjs';
/** 命令输出上限。整段回灌会挤掉模型真正需要的上下文(实测 1MB 的构建日志
* 能把一轮对话直接顶爆),所以按字节截断并明确告知被截断了多少。 */
const OUTPUT_LIMIT = 16000;
/**
* 把一段文本包进 markdown 代码块。
*
* 不能直接写在模板字符串里 —— 三个反引号会把模板字符串**提前结束**
* 报出来的是「Invalid or unexpected token」而不是「你写错了引号」
* 一眼看不出是这里。用拼接就没有这个陷阱。
*/
const fenced = text => '```\n' + text + '\n```';
/** 单条命令的默认/最长超时。超时上限必须存在:没有它,一条 `sleep 1e9`
* 会让这一轮永远跑不完,而驱动的回合超时到了就杀进程 —— 人会看到
* 「处理失败」,却不知道只是有个命令没停。 */
const DEFAULT_CMD_TIMEOUT_MS = 120000;
const MAX_CMD_TIMEOUT_MS = 900000;
/**
* 无论谁批准都不许写的路径。
*
* 这不是不信任人,而是**防自我强化**:邮件驱动的 Agent 可能被来信诱导去改
* 平台的网关数据库、systemd 单元或它自己的插件代码,改完下一轮就换了一套
* 规则,而人类看到的是一封看起来合理的申请。这类改动应当是人工部署动作,
* 不该经授权流程走私进来。
*/
const PROTECTED_PREFIXES = [
'/opt/agentmail/data', // 网关数据库(邮件、授权、附件)
'/opt/agentmail/plugins', // 我们自己的插件代码
'/etc/systemd', // 服务单元
'/etc/agentmail', // 各桥的环境文件(含密钥)
'/root/.agentmail-zcode', // 本 Agent 的凭据与会话状态
'/root/.ssh'
];
const str = (v, fallback = '') => (typeof v === 'string' ? v : fallback);
function clamp(text, limit = OUTPUT_LIMIT) {
const s = String(text ?? '');
if (s.length <= limit) return { text: s, truncated: 0 };
// 留头部:报错通常出现在开头,而进度条/日志尾部噪音最多。
return { text: s.slice(0, limit), truncated: s.length - limit };
}
function protectedHit(p) {
const abs = resolve(p);
return PROTECTED_PREFIXES.find(prefix => abs === prefix || abs.startsWith(`${prefix}/`));
}
/**
* 等人工决策的上限,必须**明显小于** MCP 调用的超时。
*
* 这一条是实测出来的,而且它曾经以最难发现的方式失败:工具在等授权,
* 人在界面上还没来得及反应,**客户端**先把这次工具调用掐了(默认 30 秒)。
* 模型拿到的是一句「调用超时」于是它在回信里写「30 秒内未获批准」——
* 看起来像人没理它,实际是**门禁的等待窗口被截断了**,而且**看起来完全正常**。
*
* 所以这里不信任环境变量:它可能被配成一个比 MCP 超时还大的值。
* 真正的上限是插件清单里 `mcpServers.agentmail.timeoutMs`(我们的工具就是它
* 在调),而等待必须留出余量让门禁**自己**先 settle —— 被客户端杀掉时,
* 我们连一条「等超时了」的理由都发不出去。
*/
const APPROVAL_MARGIN_MS = 30000;
/**
* 从插件清单里读 MCP 服务器声明的 `timeoutMs`(本插件自己的清单)。
* 读不到就返回 null —— 此时沿用环境变量,并在日志里说清楚没校到。
*/
export function resolveMcpTimeoutMs(manifestPath) {
const file =
manifestPath || fileURLToPath(new URL('../.zcode-plugin/plugin.json', import.meta.url));
try {
const cfg = JSON.parse(readFileSync(file, 'utf8'));
const t = cfg?.mcpServers?.agentmail?.timeoutMs;
return Number.isFinite(t) && t > 0 ? t : null;
} catch {
return null;
}
}
/**
* 算出实际要等多久。
*
* @returns {{waitMs:number, capped:boolean, mcpTimeoutMs:number|null}}
* `capped` 为真时调用方应当写一条日志 —— 被夹小意味着
* 「人能用来批准的时间比配置里写的少」,这件事必须能被看见。
*/
export function resolveWaitMs(env = process.env, mcpTimeoutMs = resolveMcpTimeoutMs()) {
const want = Number(env.AGENTMAIL_PERMISSION_WAIT_MS) || DEFAULT_WAIT_MS;
if (!mcpTimeoutMs) return { waitMs: want, capped: false, mcpTimeoutMs: null };
const limit = mcpTimeoutMs - APPROVAL_MARGIN_MS;
if (limit <= 0 || want <= limit) return { waitMs: want, capped: false, mcpTimeoutMs };
return { waitMs: limit, capped: true, mcpTimeoutMs };
}
/**
* 构建这些工具。
*
* @param {object} opts
* @param {any} opts.client 网关客户端
* @param {object} [opts.env] 环境(默认 process.env测试可注入
* @param {object} [opts.grants] 「一直同意」表(钩子与工具共用同一份落盘文件)
* @param {Function} [opts.log]
* @param {Function} [opts.createSSE] 仅测试用:注入假 SSE 以精确控制授权时序
*/
export function buildActionTools({ client, env = process.env, grants = null, log = () => {}, createSSE }) {
const tier = tierOf(env);
const sessionId = str(env.AGENTMAIL_SESSION_ID).trim();
const workspace = str(env.AGENTMAIL_WORKSPACE_ROOT) || str(env.ZCODE_PROJECT_DIR) || process.cwd();
const wait = resolveWaitMs(env);
const waitMs = wait.waitMs;
if (wait.capped) {
log(
`授权等待被夹到 ${Math.round(waitMs / 1000)}s` +
`MCP 调用超时只有 ${wait.mcpTimeoutMs}ms清单里的 timeoutMs` +
`而配置想等 ${Math.round((Number(env.AGENTMAIL_PERMISSION_WAIT_MS) || DEFAULT_WAIT_MS) / 1000)}s。` +
`不夹的话调用会先被杀掉,人会以为「没人批准」而不是「来不及」。`
);
}
/** 统一的问人入口:把「谁在问、问什么、上下文」凑好,交给共用模块。 */
async function gate({ toolName, question, context }) {
const r = await requestApproval({
client,
toolName,
question,
context,
sessionId,
waitMs,
grants,
tier,
// 幂等键带上会话与工具:网关按它去重,同一个动作重复问不会刷屏。
relayKeySeed: `zcode-tool:${sessionId || 'local'}:${toolName}`,
log,
createSSE
});
if (!r.allowed) {
// 抛错而不是返回字符串:让模型看见 isError 与原因。
throw new Error(`未获批准,未执行 ${toolName}\n${r.reason}`);
}
log(`${toolName} 获批(${r.via}${r.decidedBy ? `, ${r.decidedBy}` : ''}`);
return r;
}
return [
{
name: 'run_command',
// 会改变机器状态 → destructiveHint:false 但非只读。平台在 yolo 下不再判定,
// 但注解仍要如实填写:它决定别的档位/宿主下这个工具的可见性。
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
description:
'在本机执行一条 shell 命令并返回输出。' +
'**这会先向发件人申请授权**plan 档一律不允许full 档免问workspace 档现场问人)。' +
'未获批准时本工具报错且不会执行任何东西。' +
'命令在 bash 里运行;工作目录默认是本会话的工作区。' +
`输出超过 ${OUTPUT_LIMIT} 字符会被截断(会标明截断了多少)。`,
inputSchema: {
type: 'object',
properties: {
command: { type: 'string', description: '要执行的命令(经 bash -c 执行)' },
cwd: { type: 'string', description: '工作目录,默认本会话工作区' },
timeout_ms: {
type: 'number',
description: `超时毫秒(默认 ${DEFAULT_CMD_TIMEOUT_MS},上限 ${MAX_CMD_TIMEOUT_MS}`
},
purpose: {
type: 'string',
description: '为什么要执行它,一句话 —— 会展示给批准人看,请写具体'
}
},
required: ['command']
},
async run(args) {
const a = args && typeof args === 'object' ? args : {};
const command = str(a.command).trim();
if (!command) throw new Error('command 不能为空');
const cwd = str(a.cwd) || workspace;
const timeout = Math.min(
Number.isFinite(a.timeout_ms) && a.timeout_ms > 0 ? a.timeout_ms : DEFAULT_CMD_TIMEOUT_MS,
MAX_CMD_TIMEOUT_MS
);
const purpose = str(a.purpose).trim();
await gate({
toolName: 'run_command',
question:
'Agent 请求执行一条命令:\n\n' +
fenced(clamp(command, 2000).text) +
`\n\n目录:${cwd}`,
context: [
purpose ? `用途:${purpose}` : '',
'批准后该命令将在本机执行。拒绝后 Agent 会收到拒绝原因,可以改道。'
]
.filter(Boolean)
.join('\n')
});
const started = Date.now();
try {
const { stdout, stderr } = await new Promise((res, rej) => {
execFile(
'/bin/bash',
['-c', command],
{ cwd, timeout, maxBuffer: 4 * 1024 * 1024, killSignal: 'SIGTERM' },
(err, stdout, stderr) => {
if (err) return rej(Object.assign(err, { stdout, stderr }));
res({ stdout, stderr });
}
);
});
return renderResult({ code: 0, stdout, stderr, ms: Date.now() - started });
} catch (e) {
// 非零退出码与超时都不是「工具坏了」——它们是命令的真实结果,
// 必须原样告诉模型(它靠 stderr 判断下一步),所以这里不抛错。
const o = clamp(e?.stdout);
const er = clamp(e?.stderr);
const timedOut = e?.killed || e?.signal === 'SIGTERM';
const exit = typeof e?.code === 'number' ? e.code : e?.signal || '未知';
return (
`退出码:${exit}${timedOut ? `(超时被终止,上限 ${timeout}ms` : ''}` +
`耗时:${Date.now() - started}ms\n` +
truncNote('stdout', o) +
truncNote('stderr', er)
);
}
}
},
{
name: 'write_file',
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
description:
'把一个文件写到本机(覆盖写,父目录会自动创建)。' +
'**这会先向发件人申请授权**;未获批准时报错且不会创建任何文件或目录。' +
'平台自身的目录(部署、数据库、服务配置)无论谁批准都拒绝写入。',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: '绝对路径,或相对工作区的路径' },
content: { type: 'string', description: '文件内容UTF-8' },
purpose: { type: 'string', description: '为什么要写它,一句话,会展示给批准人看' }
},
required: ['path', 'content']
},
async run(args) {
const a = args && typeof args === 'object' ? args : {};
const raw = str(a.path).trim();
if (!raw) throw new Error('path 不能为空');
if (typeof a.content !== 'string') throw new Error('content 必须是字符串');
const target = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
// 保护路径在我们的门禁**之前**判定:即使有人点了同意也不放行,
// 因为这类改动不该走授权流程(见 PROTECTED_PREFIXES 的注释)。
const hit = protectedHit(target);
if (hit) {
throw new Error(
`拒绝写入 ${target}:它在平台保护目录 ${hit} 之下。` +
`这类改动必须由人工部署完成。请把要写的内容放进回信,或改写到工作区内。`
);
}
await gate({
toolName: 'write_file',
question: `Agent 请求写入文件:\n\n${target}\n\n内容 ${a.content.length} 字符`,
context: [
str(a.purpose).trim() ? `用途:${str(a.purpose).trim()}` : '',
'内容预览(前 800 字符):',
clamp(a.content, 800).text
]
.filter(Boolean)
.join('\n')
});
await mkdir(dirname(target), { recursive: true });
await writeFile(target, a.content, 'utf8');
return `已写入 ${target}${Buffer.byteLength(a.content, 'utf8')} 字节)。`;
}
}
];
}
function truncNote(label, { text, truncated }) {
const tail = truncated ? `\n[${label} 被截断,省略 ${truncated} 字符]` : '';
const body = text === '' ? `(${label} 为空)` : text;
return `${label}:\n${body}${tail}\n`;
}
function renderResult({ code, stdout, stderr, ms }) {
const o = clamp(stdout);
const e = clamp(stderr);
return `退出码:${code}\n耗时:${ms}ms\n` + truncNote('stdout', o) + truncNote('stderr', e);
}