Files
MailUI4Agents/plugins/zcode-mail-bridge/test/approval.test.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

355 lines
16 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.

/**
* 授权往返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', '非重复的正常请求应该等,然后超时');
});