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:
2026-09-12 19:05:54 +08:00
parent 10ff50e899
commit a00cbf36fc
16 changed files with 1963 additions and 164 deletions

View File

@ -0,0 +1,354 @@
/**
* 授权往返(lib/approval.mjs)的测试。
*
* 这是全项目最该被测死的一块:它决定「什么算同意」。所以每一组都配了
* **反向对照** —— 不是只验「同意时放行了」,而要同时验「别的任何东西都不放行」。
*
* 时序靠注入的假 SSE 控制:真网关的 SSE 是扇出的,假实现只需要保留
* `onEvent` 回调并在合适的时候投喂事件,就能精确复现「先建连、再发请求、
* 决策在请求之后到达」以及几个边界(决策在请求之前就到了 / 一直没到 /
* 来的是别人的决策)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { requestApproval, tierOf, hasLocalUi } from '../lib/approval.mjs';
import { createGrantStore } from '../lib/permission-grants.js';
/** 可控的假 SSE:把 onEvent 抓住,测试自己决定何时投喂什么。 */
function makeSSE() {
const s = { onEvent: null, connected: false, stopped: false };
const factory = ({ onEvent }) => {
s.onEvent = onEvent;
// 真实现在建连后立刻下发 connected;这里用 microtask 复现「不等它也能跑」。
queueMicrotask(() => {
s.connected = true;
onEvent('connected', {});
});
return { stop: () => { s.stopped = true; } };
};
return { factory, s };
}
/** 记录请求体;可选在请求成功后投喂一条决定。
* `onRequest` 的**返回值会被当作 HTTP 响应体**返回给被测代码 ——
* 这一点至关重要:网关的幂等命中是一个 200 + `{status:"duplicate_relay"}`,
* 判据就看它。之前这里硬编码 `return {}`,把响应体丢了,于是「重复请求」
* 那条测试变成干等到超时,而失败信息看起来像被测代码的 bug。
*/
function makeClient({ onRequest } = {}) {
const state = { requests: [] };
const client = {
baseURL: 'http://gw.test',
authHeaders: () => ({ 'X-Agent-Secret': 's' }),
async post(path, body) {
state.requests.push({ path, body });
if (onRequest) {
const res = await onRequest(state, body);
return res === undefined ? {} : res;
}
return {};
}
};
return { client, state };
}
const base = env => ({
toolName: 'run_command',
question: '要执行一条命令',
context: 'echo hi',
sessionId: 'sess-1',
log: () => {},
env,
...env
});
test('★ plan 档直接拒绝执行类工具,且根本不发请求', async () => {
const { factory } = makeSSE();
const { client, state } = makeClient();
const r = await requestApproval({
...base({ tier: 'plan', createSSE: factory })
});
assert.equal(r.allowed, false);
assert.equal(r.via, 'tier');
assert.match(r.reason, /plan 档/);
// 反向对照:不该在「注定拒绝」的档位上去打扰人。
assert.equal(state.requests.length, 0, 'plan 档不该发出授权请求');
});
test('★ full 档直接放行', async () => {
const { client } = makeClient();
const r = await requestApproval({ toolName: 'run_command', tier: 'full', sessionId: 's1' });
assert.equal(r.allowed, true);
assert.equal(r.via, 'tier');
});
test('★ 「一直同意」命中时不发请求(钩子与工具共用同一张表)', async () => {
const grants = createGrantStore();
grants.grant('sess-1', 'run_command', '一直同意');
const { client, state } = makeClient();
const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants });
assert.equal(r.allowed, true);
assert.equal(r.via, 'grant');
assert.equal(state.requests.length, 0);
});
test('★ 人同意 → 放行,且请求里带上了 relay_key 与选项', async () => {
const { factory, s } = makeSSE();
const { client, state } = makeClient({
onRequest: async st => {
// 真网关是「先受理、后有人决策」,所以决策必须晚于请求。
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意', decided_by: 'gui-lab' }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, true);
assert.equal(r.via, 'human');
assert.equal(r.decidedBy, 'gui-lab');
const sent = state.requests[0].body;
assert.equal(sent.session_id, 'sess-1');
assert.ok(sent.relay_key, '请求必须带 relay_key(决定回执怎么配对)');
assert.deepEqual(sent.options, ['同意', '一直同意', '拒绝']);
assert.equal(s.stopped, true, 'SSE 必须被关掉(否则短命进程不退出)');
});
test('★ 「一直同意」放行并落进授权表;「同意」不落', async () => {
for (const [decision, shouldPersist] of [
['一直同意', true],
['同意', false]
]) {
const { factory, s } = makeSSE();
const grants = createGrantStore();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants, createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, true, decision);
assert.equal(
grants.isGranted('sess-1', 'run_command'),
shouldPersist,
`${decision} 的落表行为不对`
);
}
});
test('★ 人拒绝 → 不放行,且原因里带上决策人', async () => {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() =>
s.onEvent('permission_decision', {
relay_key: st.requests[0].body.relay_key,
decision: '拒绝',
decided_by: 'gui-lab',
note: '这条命令会删数据'
})
);
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'human');
assert.match(r.reason, /拒绝/);
assert.match(r.reason, /gui-lab/);
assert.match(r.reason, /会删数据/);
});
test('★ 反向对照:一切「不是明确同意」的文本都不放行', async () => {
// 判据是「在放行白名单里」,不是「不等于拒绝」。所以拒绝、看不懂的东西、
// 平台自己的 shutdown 哨兵、空串都不能放行。
//
// 注意白名单本身是共用库的前缀匹配(`^同意|一直同意|allow|approve|always|yes`,
// 四个桥共用同一份)。所以「不同意」不放行(前缀不是同意),而「同意吧」放行 ——
// 后者是刻意接受的:决策文本来自界面按钮,前缀匹配是为了容错,不是为了放宽。
// 这里把两类都钉住,避免哪天有人把前缀匹配改成 includes 而无人发现
// (那会让「我不同意」变成同意)。
for (const decision of ['', 'maybe', 'ok?', 'shutdown', 'deny', '拒绝', '不同意', '否', 'no', undefined, null]) {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 });
assert.equal(r.allowed, false, `decision=${JSON.stringify(decision)} 不该放行`);
}
// 反向对照的对照:确实在白名单里的必须放行,否则上面全绿可能只是因为门槛坏死了。
for (const decision of ['同意', '一直同意', 'allow', 'yes']) {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 });
assert.equal(r.allowed, true, `decision=${JSON.stringify(decision)} 应当放行`);
}
});
test('★ 超时 → 拒绝(不能靠「没消息就是好消息」)', async () => {
const { factory } = makeSSE();
const { client } = makeClient(); // 从不投喂决策
const t0 = Date.now();
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 120 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'timeout');
assert.match(r.reason, /超时/);
assert.ok(Date.now() - t0 >= 100, '必须真的等过,而不是立刻返回');
});
test('★ 别人的决策不能拿来用(relay_key 配对)', async () => {
// 同一个 Agent 可能同时有多个调用在等(模型并行发起两个动作)。
// 若不按 relay_key 过滤,B 的同意会放行 A。
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
const mine = st.requests[0].body.relay_key;
queueMicrotask(() => {
s.onEvent('permission_decision', { relay_key: `${mine}-other`, decision: '同意' });
setTimeout(() => s.onEvent('permission_decision', { relay_key: mine, decision: '拒绝' }), 30);
});
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false, '拿到别人的「同意」就是越权放行');
assert.match(r.reason, /拒绝/);
});
test('★ 永久失败(409 无人可问)当场拒绝,并把服务端建议带给模型', async () => {
const { factory } = makeSSE();
const err = Object.assign(new Error('409'), {
status: 409,
body: { error: '本线索内找不到可决策的人类', suggestion: '请让发件人把档位改成 full' }
});
const { client } = makeClient({
onRequest: async () => {
throw err;
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'permanent-failure');
assert.match(r.reason, /找不到可决策的人类/);
assert.match(r.reason, /改成 full/, '服务端的建议必须原样带给模型,否则它只能盲试');
});
test('★ 暂时失败:没有本地界面时必须拒绝(fail closed)', async () => {
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => {
throw new Error('ECONNREFUSED');
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'transport');
assert.match(r.reason, /没有本地界面/);
});
test('★ 暂时失败:有本地界面时明确说明没有放行', async () => {
// 桌面模式下平台自己还有流程,所以这里不放行是安全的 —— 但**不能说**放行了。
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => {
throw new Error('ECONNREFUSED');
}
});
const r = await requestApproval({ toolName: 'Bash', tier: 'workspace', client, createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.match(r.reason, /授权询问失败/);
});
test('★ 「有没有本地界面」由调用方传的 sessionId 判定(单一事实来源)', async () => {
// 反向对照:同一个暂时失败,在「有会话」与「没会话」下必须给出不同的拒绝理由。
// 这里刻意把 process.env.AGENTMAIL_SESSION_ID 设成反的,验证模块**不看它** ——
// 两个事实来源不一致时,谁也说不清到底算有界面还是没界面。
const prev = process.env.AGENTMAIL_SESSION_ID;
process.env.AGENTMAIL_SESSION_ID = '来自进程环境的干扰值';
try {
const mk = () => {
const { factory } = makeSSE();
const { client } = makeClient({ onRequest: async () => { throw new Error('boom'); } });
return { factory, client };
};
const a = mk();
const withSession = await requestApproval({
...base({}), client: a.client, tier: 'workspace', createSSE: a.factory, waitMs: 500
});
assert.match(withSession.reason, /没有本地界面/, '带会话 = 邮件驱动,必须 fail closed');
const b = mk();
const noSession = await requestApproval({
toolName: 'Bash', tier: 'workspace', client: b.client, createSSE: b.factory, waitMs: 500
});
assert.doesNotMatch(noSession.reason, /没有本地界面/, '不带会话 = 有界面,不该说成没界面');
} finally {
if (prev === undefined) delete process.env.AGENTMAIL_SESSION_ID;
else process.env.AGENTMAIL_SESSION_ID = prev;
}
});
test('tierOf / hasLocalUi 的判据', () => {
assert.equal(tierOf({}), 'workspace');
assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'full' }), 'full');
// 认不出来的值 → workspace(共用库的约定),不是「免问」
assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'FULL' }), 'workspace');
// 有会话 id = 邮件驱动 = 没有本地界面
assert.equal(hasLocalUi({}), true);
assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: 'sess-1' }), false);
assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: ' ' }), true, '空白串不算会话');
});
// ─── 幂等键必须按「这一次调用」唯一 ─────────────────────────────────────
// 一个实测缺陷,失败方式极隐蔽:键取成「会话+工具」之后,同一会话里**第二次**
// run_command 被网关判成重复请求 → HTTP 200 duplicate_relay → 请求**没发出去**、
// 永远没人来决策 → 工具干等到被 MCP 调用超时砍掉 → 模型回报「30 秒内未获批准」。
// 从状态码到措辞全都看不出问题,归因还完全错了(像是人没理它)。
test('★ 同一会话同一工具的两次调用必须用不同的幂等键', async () => {
const keys = [];
for (let i = 0; i < 2; i++) {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
keys.push(st.requests[0].body.relay_key);
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意' }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 500 });
assert.equal(r.allowed, true);
}
assert.equal(keys.length, 2);
assert.notEqual(keys[0], keys[1], '两次调用的键相同 ⇒ 第二次会被网关当重复丢弃');
// 键里仍保留会话与工具,便于事后从邮件反查(但唯一性来自随机尾)
assert.match(keys[0], /sess-1/);
assert.match(keys[0], /run_command/);
});
test('★ 网关判为重复请求时当场拒绝(不能干等到被超时砍掉)', async () => {
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => ({ status: 'duplicate_relay', detail: '该权限询问已转发过,本次调用未产生新邮件' })
});
const t0 = Date.now();
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 60000 });
const dt = Date.now() - t0;
assert.equal(r.allowed, false);
assert.equal(r.via, 'duplicate-relay');
assert.match(r.reason, /重复/);
assert.match(r.reason, /没有人会看到这次询问/);
assert.ok(dt < 5000, `必须立刻返回,实际等了 ${dt}ms(说明它在干等一个永远不会来的决策)`);
});
test('★ 反向对照:正常的 200(非 duplicate_relay)仍要等决策', async () => {
// 否则上面那条可能只是因为「任何 200 都被当成重复」。
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => ({ status: 'pending' })
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 150 });
assert.equal(r.via, 'timeout', '非重复的正常请求应该等,然后超时');
});