按用户裁定「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、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
219 lines
9.2 KiB
JavaScript
219 lines
9.2 KiB
JavaScript
/**
|
||
* 跑一轮的测试:参数拼装 + stream-json 解析 + 假进程的整轮行为。
|
||
*
|
||
* 不需要真的 ZCode、也不需要模型 —— 用可注入的 spawn 造一个说同样协议的假进程。
|
||
* 这样「--mode 忘了传」「结果行没解析对」「超时不杀进程树」这些问题
|
||
* 都能在本地断言,而不是等到线上某封信没人回。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { EventEmitter } from 'node:events';
|
||
import { buildRunArgs, parseStreamLine, runTurn } from '../src/zcode-run.mjs';
|
||
|
||
/** 造一个假子进程:按脚本吐 stdout/stderr,然后以指定退出码关闭。 */
|
||
function fakeSpawn({ stdout = '', stderr = '', exitCode = 0, onStart, neverExit = false } = {}) {
|
||
const calls = [];
|
||
const fn = (file, args, opts) => {
|
||
calls.push({ file, args, opts });
|
||
const child = new EventEmitter();
|
||
child.stdout = new EventEmitter();
|
||
child.stderr = new EventEmitter();
|
||
child.pid = 4242;
|
||
child.kill = () => true;
|
||
onStart?.(child, { file, args, opts });
|
||
setImmediate(() => {
|
||
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
|
||
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
|
||
if (!neverExit) {
|
||
child.exitCode = exitCode;
|
||
child.emit('close', exitCode);
|
||
}
|
||
});
|
||
return child;
|
||
};
|
||
fn.calls = calls;
|
||
return fn;
|
||
}
|
||
|
||
// ─── 参数拼装 ─────────────────────────────────────────────────────
|
||
test('★ 参数里必须带 --mode(不传等于默认 yolo,会绕过授权)', () => {
|
||
const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' });
|
||
assert.ok(args.includes('--mode'));
|
||
assert.equal(args[args.indexOf('--mode') + 1], 'build');
|
||
});
|
||
|
||
test('★ 漏传 mode 直接抛错,而不是悄悄退回默认', () => {
|
||
// 这是刻意的:静默退回意味着「授权系统消失但没人发现」。
|
||
assert.throws(() => buildRunArgs({ prompt: 'p', cwd: '/tmp' }), /mode/);
|
||
});
|
||
|
||
test('默认用 stream-json,便于把过程写进日志', () => {
|
||
const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' });
|
||
assert.equal(args[args.indexOf('--output-format') + 1], 'stream-json');
|
||
});
|
||
|
||
test('resume 只在有时才带(首轮不该带空 --resume)', () => {
|
||
const first = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' });
|
||
assert.equal(first.includes('--resume'), false);
|
||
const next = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build', resumeSessionId: 'sess_1' });
|
||
assert.equal(next[next.indexOf('--resume') + 1], 'sess_1');
|
||
});
|
||
|
||
test('maxTurns 与禁用清单按需传递', () => {
|
||
const args = buildRunArgs({
|
||
prompt: 'p',
|
||
cwd: '/tmp',
|
||
mode: 'plan',
|
||
maxTurns: 6,
|
||
disallowedTools: ['Bash', 'Write']
|
||
});
|
||
assert.equal(args[args.indexOf('--max-turns') + 1], '6');
|
||
assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'Bash,Write');
|
||
});
|
||
|
||
test('★ --allowed-tools 被拒于拼参数阶段(本版本 CLI 不认这个选项)', () => {
|
||
// 实测:CLI 的 help 里写着 --allowed-tools,但解析器报 `Unknown option`,
|
||
// 然后打印 usage 并退出。如果这里静默拼进去,调用方要等一两分钟后
|
||
// 拿到一段 usage 文本才能开始查 —— 而且很容易被当成"模型没照做"。
|
||
// 所以错误必须在这里就报,且说清替代方案。
|
||
assert.throws(
|
||
() => buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'plan', allowedTools: ['Read'] }),
|
||
/不支持 --allowed-tools/
|
||
);
|
||
// 反向对照:空的 allowedTools 不该报错(调用方可能无条件传一个数组)。
|
||
assert.doesNotThrow(() => buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'plan', allowedTools: [] }));
|
||
});
|
||
|
||
// ─── 解析 ─────────────────────────────────────────────────────────
|
||
test('result 行取出 sessionId 与 response', () => {
|
||
const r = parseStreamLine(
|
||
JSON.stringify({ type: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3 })
|
||
);
|
||
assert.deepEqual(r, { kind: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3, projection: undefined });
|
||
});
|
||
|
||
test('普通事件行归为 event', () => {
|
||
const e = parseStreamLine(JSON.stringify({ type: 'tool.call.started', toolName: 'Bash' }));
|
||
assert.equal(e.kind, 'event');
|
||
assert.equal(e.event.toolName, 'Bash');
|
||
});
|
||
|
||
test('非 JSON / 空行返回 null(不抛)', () => {
|
||
for (const line of ['', ' ', 'not json', '[1,2]', 'null', '42']) {
|
||
assert.equal(parseStreamLine(line), null, JSON.stringify(line));
|
||
}
|
||
});
|
||
|
||
test('★ 输出格式变了会被计数,而不是静默当成没输出', async () => {
|
||
// 若 CLI 换掉了输出格式,所有的行都会变成不可解析 —— 那时必须能看见
|
||
// 「解析不了的行有 N 条」,否则现象是「回合跑完了但什么都没回」。
|
||
const spawn = fakeSpawn({ stdout: 'human readable output\nmore text\n' });
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.unparsable, 2);
|
||
assert.equal(r.response, '');
|
||
});
|
||
|
||
// ─── 整轮行为 ─────────────────────────────────────────────────────
|
||
test('整轮:解析事件、取出最终回复与会话 id', async () => {
|
||
const spawn = fakeSpawn({
|
||
stdout:
|
||
JSON.stringify({ type: 'tool.call.started', toolName: 'Read' }) +
|
||
'\n' +
|
||
JSON.stringify({ type: 'result', sessionId: 'sess_9', response: '结论:可以' }) +
|
||
'\n'
|
||
});
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.sessionId, 'sess_9');
|
||
assert.equal(r.response, '结论:可以');
|
||
assert.equal(r.events.length, 1);
|
||
assert.equal(r.exitCode, 0);
|
||
});
|
||
|
||
test('最后一行没有换行也能收到', async () => {
|
||
const spawn = fakeSpawn({ stdout: JSON.stringify({ type: 'result', sessionId: 's', response: 'ok' }) });
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.response, 'ok');
|
||
});
|
||
|
||
test('分块到达(一条 JSON 被切成两半)也能拼回来', async () => {
|
||
const payload = JSON.stringify({ type: 'result', sessionId: 'sess_split', response: '完整' });
|
||
const half = Math.floor(payload.length / 2);
|
||
const spawn = fakeSpawn({
|
||
onStart: child => {
|
||
setImmediate(() => {
|
||
child.stdout.emit('data', Buffer.from(payload.slice(0, half)));
|
||
child.stdout.emit('data', Buffer.from(`${payload.slice(half)}\n`));
|
||
child.emit('close', 0);
|
||
});
|
||
},
|
||
neverExit: true
|
||
});
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.response, '完整');
|
||
});
|
||
|
||
test('非零退出码原样带出(不吞成成功)', async () => {
|
||
const spawn = fakeSpawn({ stdout: '', stderr: 'boom\n', exitCode: 7 });
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.exitCode, 7);
|
||
assert.match(r.stderrTail, /boom/);
|
||
});
|
||
|
||
test('spawn 本身失败时给可读结果,而不是抛出去', async () => {
|
||
const spawn = () => {
|
||
throw new Error('ENOENT');
|
||
};
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.exitCode, -1);
|
||
assert.match(r.stderrTail, /ENOENT/);
|
||
});
|
||
|
||
test('★ 超时会标记 timedOut 并杀进程树', async () => {
|
||
const signals = [];
|
||
const spawn = fakeSpawn({
|
||
neverExit: true,
|
||
onStart: child => {
|
||
child.kill = sig => {
|
||
signals.push(sig);
|
||
};
|
||
}
|
||
});
|
||
// 孩子永不退出:这是最坏情况 —— 既不退也不报错。
|
||
const r = await runTurn(
|
||
{ prompt: 'p', cwd: '/tmp', mode: 'build', turnTimeoutMs: 60, killGraceMs: 60, settleGraceMs: 60 },
|
||
{ spawn }
|
||
);
|
||
assert.equal(r.timedOut, true, '必须报告超时');
|
||
assert.equal(r.exitCode, -1);
|
||
// ★ 更关键的是**它一定会结束**:不结束的话驱动会对这封信永远挂住,
|
||
// 而队列是串行的,后面的信全都不再被处理。
|
||
// 升级阶梯:先礼后兵。只断言「杀过」会漏掉「SIGTERM 之后没升级」——
|
||
// 那种情况下要等满宽限期才能收尾,而工具子进程可能已经跑完了坏事。
|
||
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
|
||
});
|
||
|
||
test('进程组隔离:非 win32 平台用 detached 起', async () => {
|
||
const spawn = fakeSpawn({ stdout: '' });
|
||
await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
const opts = spawn.calls[0].opts;
|
||
if (process.platform !== 'win32') assert.equal(opts.detached, true);
|
||
assert.equal(opts.cwd, '/tmp');
|
||
});
|
||
|
||
test('注入的环境变量会传给子进程(钩子靠它判断档位与有无本地界面)', async () => {
|
||
const spawn = fakeSpawn({ stdout: '' });
|
||
await runTurn(
|
||
{
|
||
prompt: 'p',
|
||
cwd: '/tmp',
|
||
mode: 'build',
|
||
env: { AGENTMAIL_SESSION_ID: 'sess-x', AGENTMAIL_PERMISSION_MODE: 'workspace' }
|
||
},
|
||
{ spawn }
|
||
);
|
||
const env = spawn.calls[0].opts.env;
|
||
assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-x');
|
||
assert.equal(env.AGENTMAIL_PERMISSION_MODE, 'workspace');
|
||
});
|