/** * 我们自己的「会动机器」的工具 —— 每一次执行都要先过人的批准。 * * # 为什么要有这些工具 * * 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); }