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:
329
plugins/zcode-mail-bridge/test/action-tools.test.mjs
Normal file
329
plugins/zcode-mail-bridge/test/action-tools.test.mjs
Normal file
@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 执行工具(lib/action-tools.mjs)的测试。
|
||||
*
|
||||
* 这些工具是**唯一**能动机器的路径(平台自带的 Bash/Write/Edit/js 已被
|
||||
* `--disallowed-tools` 禁掉),所以每条测试都必须同时验两件事:
|
||||
*
|
||||
* 1. 结果对不对(执行了 / 返回了什么)
|
||||
* 2. **在没获批准时,副作用真的没有发生**
|
||||
*
|
||||
* 第 2 条不能只看「抛错了」—— 抛错之后照样写文件是最糟的实现方式,
|
||||
* 而只验抛错完全发现不了。所以拒绝场景一律配一个文件系统断言。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm, stat, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildActionTools } from '../lib/action-tools.mjs';
|
||||
import { createGrantStore } from '../lib/permission-grants.js';
|
||||
|
||||
/** 假 SSE:立刻发 connected,测试自己投喂决策。 */
|
||||
function makeSSE() {
|
||||
const s = { onEvent: null, stopped: false };
|
||||
return {
|
||||
state: s,
|
||||
factory: ({ onEvent }) => {
|
||||
s.onEvent = onEvent;
|
||||
queueMicrotask(() => onEvent('connected', {}));
|
||||
return { stop: () => { s.stopped = true; } };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeClient({ decision, fail } = {}) {
|
||||
const state = { requests: [] };
|
||||
return {
|
||||
state,
|
||||
client: {
|
||||
baseURL: 'http://gw.test',
|
||||
authHeaders: () => ({}),
|
||||
async post(path, body) {
|
||||
state.requests.push({ path, body });
|
||||
if (fail) throw fail;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 人都同意场景:请求受理后立刻投喂「同意」。 */
|
||||
function approving({ decision = '同意' } = {}) {
|
||||
const sse = makeSSE();
|
||||
const c = makeClient();
|
||||
const orig = c.client.post;
|
||||
c.client.post = async (p, b) => {
|
||||
await orig(p, b);
|
||||
queueMicrotask(() => sse.state.onEvent('permission_decision', { relay_key: b.relay_key, decision }));
|
||||
return {};
|
||||
};
|
||||
return { ...c, factory: sse.factory };
|
||||
}
|
||||
|
||||
async function withTools(env, fn, opts = {}) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'zc-act-'));
|
||||
const c = opts.client || makeClient();
|
||||
const tools = buildActionTools({
|
||||
client: c.client,
|
||||
env: { AGENTMAIL_SESSION_ID: 'sess-1', AGENTMAIL_WORKSPACE_ROOT: dir, ...env },
|
||||
grants: opts.grants || null,
|
||||
createSSE: opts.createSSE,
|
||||
log: () => {}
|
||||
});
|
||||
const byName = new Map(tools.map(t => [t.name, t]));
|
||||
try {
|
||||
return await fn({ byName, dir, client: c });
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 工具面本身 ─────────────────────────────────────────────────────────
|
||||
|
||||
test('★ 工具面只暴露两个执行工具,且都声明为 destructive', async () => {
|
||||
await withTools({}, async ({ byName }) => {
|
||||
assert.deepEqual([...byName.keys()].sort(), ['run_command', 'write_file']);
|
||||
for (const [name, t] of byName) {
|
||||
assert.equal(t.annotations.readOnlyHint, false, `${name} 不该声称只读`);
|
||||
// destructiveHint 必须为真:plan 档下平台的判定是
|
||||
// 「permissionName==="mcp" && !destructive → allow」,声明成非破坏性会让
|
||||
// 这两个工具在只读档被平台放行 —— 那时我们的门禁也会拒,但平台那层
|
||||
// 已经先把话说错了。
|
||||
assert.equal(t.annotations.destructiveHint, true, `${name} 必须声明为破坏性`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── run_command ────────────────────────────────────────────────────────
|
||||
|
||||
test('★ 获批准后真的执行,并返回退出码与输出', async () => {
|
||||
const c = approving();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
|
||||
const out = await byName.get('run_command').run({ command: 'echo hello; echo err >&2' });
|
||||
assert.match(out, /退出码:0/);
|
||||
assert.match(out, /hello/);
|
||||
assert.match(out, /err/);
|
||||
}, { client: c, createSSE: c.factory });
|
||||
});
|
||||
|
||||
test('★ 拒绝时抛错、且命令真的没执行', async () => {
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
|
||||
const marker = join(dir, 'should-not-exist.txt');
|
||||
await assert.rejects(
|
||||
() => byName.get('run_command').run({ command: `touch ${marker}` }),
|
||||
/未获批准/
|
||||
);
|
||||
assert.equal(existsSync(marker), false, '被拒的命令仍然产生了副作用');
|
||||
});
|
||||
});
|
||||
|
||||
test('★ 命令非零退出不是工具失败:原样把退出码与 stderr 交给模型', async () => {
|
||||
// 抛错会让模型以为工具坏了并重试;而 `grep` 没匹配到、测试失败、
|
||||
// 编译报错都是**正常的命令结果**,模型靠 stderr 判断下一步。
|
||||
const c = approving();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||||
const out = await byName.get('run_command').run({ command: 'echo boom >&2; exit 7' });
|
||||
assert.match(out, /退出码:7/);
|
||||
assert.match(out, /boom/);
|
||||
}, { client: c, createSSE: c.factory });
|
||||
});
|
||||
|
||||
test('★ 超时被当作命令结果报告(不能挂死整轮)', async () => {
|
||||
const c = approving();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||||
const out = await byName.get('run_command').run({ command: 'sleep 5', timeout_ms: 300 });
|
||||
assert.match(out, /退出码:(SIGTERM|null)/);
|
||||
assert.match(out, /超时被终止/);
|
||||
assert.match(out, /上限 300ms/);
|
||||
}, { client: c, createSSE: c.factory });
|
||||
});
|
||||
|
||||
test('★ 输出过长时截断并明确说明截断了多少', async () => {
|
||||
const c = approving();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||||
const out = await byName.get('run_command').run({ command: `seq 1 20000` });
|
||||
assert.match(out, /被截断,省略 \d+ 字符/);
|
||||
assert.ok(out.length < 20000, '截断没生效');
|
||||
}, { client: c, createSSE: c.factory });
|
||||
});
|
||||
|
||||
test('★ 工作目录默认是本会话工作区,可用 cwd 覆盖', async () => {
|
||||
const c = approving();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
|
||||
const out = await byName.get('run_command').run({ command: 'pwd' });
|
||||
assert.match(out, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}, { client: c, createSSE: c.factory });
|
||||
});
|
||||
|
||||
test('空命令被拒(不浪费一次人工审批)', async () => {
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||||
await assert.rejects(() => byName.get('run_command').run({ command: ' ' }), /command 不能为空/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── write_file ─────────────────────────────────────────────────────────
|
||||
|
||||
test('★ 获批准后真的写入文件(含自动建父目录)', async () => {
|
||||
const c = approving();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
|
||||
const target = join(dir, 'deep', 'nested', 'a.txt');
|
||||
const out = await byName.get('write_file').run({ path: target, content: '内容' });
|
||||
assert.match(out, /已写入/);
|
||||
assert.equal(await readFile(target, 'utf8'), '内容');
|
||||
}, { client: c, createSSE: c.factory });
|
||||
});
|
||||
|
||||
test('★ 拒绝时抛错、且不创建文件也不创建目录', async () => {
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
|
||||
const target = join(dir, 'deep', 'x.txt');
|
||||
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /未获批准/);
|
||||
assert.equal(existsSync(target), false, '被拒的写入仍然产生了文件');
|
||||
assert.equal(existsSync(join(dir, 'deep')), false, '被拒的写入仍然创建了目录');
|
||||
});
|
||||
});
|
||||
|
||||
test('★ 保护目录:即使有人批准也拒,而且**根本不发审批请求**', async () => {
|
||||
// 这不是不信任人,而是防自我强化:邮件驱动的 Agent 可能被来信诱导去改
|
||||
// 网关数据库/服务单元/自己的插件代码,改完下一轮就换了一套规则。
|
||||
// 所以这道判定必须在门禁**之前**,且不能消耗人的注意力。
|
||||
const c = approving();
|
||||
for (const target of [
|
||||
'/opt/agentmail/data/agentmail.db',
|
||||
'/opt/agentmail/plugins/zcode-mail-bridge/x.mjs',
|
||||
'/etc/systemd/system/homeagent.service',
|
||||
'/etc/agentmail/pi.env',
|
||||
'/root/.agentmail-zcode/secret'
|
||||
]) {
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||||
await assert.rejects(
|
||||
() => byName.get('write_file').run({ path: target, content: 'x' }),
|
||||
/平台保护目录/,
|
||||
`${target} 应该被保护`
|
||||
);
|
||||
}, { client: c, createSSE: c.factory });
|
||||
}
|
||||
// 反向对照:保护目录外真的写了(否则上面全绿可能只是因为全都写不进去)。
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
|
||||
const p = join(dir, 'ok.txt');
|
||||
await byName.get('write_file').run({ path: p, content: 'ok' });
|
||||
assert.equal(await readFile(p, 'utf8'), 'ok');
|
||||
}, { client: c, createSSE: c.factory });
|
||||
});
|
||||
|
||||
test('★ 保护判定不能被路径花招绕过(大小写/相对路径/..)', async () => {
|
||||
for (const target of [
|
||||
'/opt/agentmail/data/../data/agentmail.db',
|
||||
'/opt/agentmail/./data/x',
|
||||
'/etc/systemd/system/../system/x.service'
|
||||
]) {
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||||
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /平台保护目录/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 相对路径按工作区解析(不能靠相对路径逃出工作区之外)', async () => {
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
|
||||
const out = await byName.get('write_file').run({ path: 'sub/rel.txt', content: 'r' });
|
||||
assert.match(out, new RegExp('sub/rel.txt'));
|
||||
assert.equal(await readFile(join(dir, 'sub', 'rel.txt'), 'utf8'), 'r');
|
||||
const st = await stat(join(dir, 'sub', 'rel.txt'));
|
||||
assert.ok(st.isFile());
|
||||
});
|
||||
});
|
||||
|
||||
test('content 必须是字符串(否则会写出 "[object Object]")', async () => {
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||||
await assert.rejects(() => byName.get('write_file').run({ path: 'x.txt', content: { a: 1 } }), /必须是字符串/);
|
||||
await assert.rejects(() => byName.get('write_file').run({ content: 'x' }), /path 不能为空/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 门禁接线 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('★ 授权请求里带上了人真正需要看的信息(命令原文 / 用途 / 目标路径)', async () => {
|
||||
const c = approving();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, client }) => {
|
||||
await byName.get('run_command').run({ command: 'rm -rf /tmp/x', purpose: '清理临时文件' });
|
||||
const body = client.state.requests.at(-1).body;
|
||||
assert.equal(body.session_id, 'sess-1');
|
||||
assert.match(body.question, /rm -rf \/tmp\/x/, '批准人必须看到命令原文');
|
||||
assert.match(body.context, /清理临时文件/, '用途要带给批准人');
|
||||
assert.match(body.relay_key, /sess-1/);
|
||||
}, { client: c, createSSE: c.factory });
|
||||
});
|
||||
|
||||
test('★ 「一直同意」命中时不再打扰人(同一会话同一工具)', async () => {
|
||||
const grants = createGrantStore();
|
||||
grants.grant('sess-1', 'run_command', '一直同意');
|
||||
const c = makeClient();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
|
||||
const out = await byName.get('run_command').run({ command: 'echo granted' });
|
||||
assert.match(out, /granted/);
|
||||
}, { client: c, grants });
|
||||
assert.equal(c.state.requests.length, 0, '已有授权却仍然发了审批请求');
|
||||
});
|
||||
|
||||
test('★ full 档不打扰人(发件人已声明全权)', async () => {
|
||||
const c = makeClient();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||||
const out = await byName.get('run_command').run({ command: 'echo full' });
|
||||
assert.match(out, /full/);
|
||||
}, { client: c });
|
||||
assert.equal(c.state.requests.length, 0);
|
||||
});
|
||||
|
||||
test('★ 网关不可达时 fail closed(不执行、不写文件)', async () => {
|
||||
const fail = Object.assign(new Error('ECONNREFUSED'), { status: 502 });
|
||||
const c = makeClient({ fail });
|
||||
const sse = makeSSE();
|
||||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
|
||||
const marker = join(dir, 'nope.txt');
|
||||
await assert.rejects(() => byName.get('run_command').run({ command: `touch ${marker}` }), /未获批准/);
|
||||
assert.equal(existsSync(marker), false);
|
||||
}, { client: c, createSSE: sse.factory });
|
||||
});
|
||||
|
||||
// ─── 等待窗口必须容得下「人真的来点一下」────────────────────────────────
|
||||
// 这一组来自一个实测缺陷:工具在等授权,客户端(ZCode)默认 30 秒就把这次
|
||||
// MCP 调用掐了,模型于是回报「30 秒内未获批准」——看起来像人没理它,
|
||||
// 实际是门禁的等待窗口被截断,而且**表现得完全正常**。
|
||||
|
||||
test('★ 授权等待被夹到 MCP 调用超时之下(并留下可发现的痕迹)', async () => {
|
||||
const { resolveWaitMs, resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
|
||||
|
||||
// 清单里声明的时间(本插件自己的清单,实测生效:40 秒的命令没被砍)
|
||||
const declared = resolveMcpTimeoutMs();
|
||||
assert.ok(declared && declared >= 60000, `清单应声明一个够长的 timeoutMs,实际 ${declared}`);
|
||||
|
||||
// 配置想等 90 分钟,但 MCP 只给 10 分钟 → 应夹到 10 分钟减余量
|
||||
const capped = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '5400000' }, 600000);
|
||||
assert.ok(capped.waitMs < 600000, '必须小于 MCP 超时,否则调用会先被杀掉');
|
||||
assert.ok(capped.waitMs >= 600000 - 120000, '也不该夹得过小(人需要时间点同意)');
|
||||
assert.equal(capped.capped, true, '被夹小这件事必须能被发现(要写日志)');
|
||||
|
||||
// 边界:配置正好等于上限 → 不算被夹(它本来就 settle 得掉)
|
||||
const onEdge = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: String(600000 - 30000) }, 600000);
|
||||
assert.equal(onEdge.capped, false);
|
||||
assert.equal(onEdge.waitMs, 570000);
|
||||
|
||||
// 反向对照:配置本来就比 MCP 超时小 → 原样使用,不报「被夹」
|
||||
const fine = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '120000' }, 600000);
|
||||
assert.equal(fine.waitMs, 120000);
|
||||
assert.equal(fine.capped, false);
|
||||
|
||||
// 反向对照:读不到清单时不猜,沿用配置(并在日志里说没校到)
|
||||
const unknown = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '540000' }, null);
|
||||
assert.equal(unknown.waitMs, 540000);
|
||||
assert.equal(unknown.capped, false);
|
||||
});
|
||||
|
||||
test('★ 清单里的 timeoutMs 必须真的存在且够长(否则门禁没有可行窗口)', async () => {
|
||||
const { resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
|
||||
const t = resolveMcpTimeoutMs();
|
||||
assert.ok(t, '插件清单的 mcpServers.agentmail 必须有 timeoutMs');
|
||||
// 默认 30 秒的 MCP 超时下,人根本来不及看到请求 —— 所以必须显式声明一个大的。
|
||||
assert.ok(t > 300000, `timeoutMs=${t} 太短,人工审批窗口不够`);
|
||||
});
|
||||
354
plugins/zcode-mail-bridge/test/approval.test.mjs
Normal file
354
plugins/zcode-mail-bridge/test/approval.test.mjs
Normal 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', '非重复的正常请求应该等,然后超时');
|
||||
});
|
||||
@ -215,11 +215,12 @@ test('Agent 来信的提示词必须说清「插件不会替你回信」', async
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 档位随邮件传下去,并作为 --mode 与钩子环境变量注入', async () => {
|
||||
test('★ 档位随邮件传下去,并作为 --mode / 禁用清单 / 钩子环境变量注入', async () => {
|
||||
for (const [tier, mode] of [
|
||||
['plan', 'plan'],
|
||||
// workspace 默认映射到 plan:build 在 headless 下连 MCP 工具都要审批而无人可批
|
||||
['workspace', 'plan'],
|
||||
// workspace 与 full 都映射到 yolo:平台不做权限判定(它自带危险工具已被
|
||||
// --disallowed-tools 拿掉),执行类动作改由我们自己的门禁逐次请示。
|
||||
['workspace', 'yolo'],
|
||||
['full', 'yolo']
|
||||
]) {
|
||||
const h = await harness();
|
||||
@ -230,6 +231,12 @@ test('★ 档位随邮件传下去,并作为 --mode 与钩子环境变量注
|
||||
// 钩子靠这两个变量决定档位与「有没有本地界面」
|
||||
assert.equal(env.AGENTMAIL_PERMISSION_MODE, tier);
|
||||
assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-1');
|
||||
// 禁用清单必须真的传下去:它是「平台不问」时唯一的替代防线。
|
||||
const denied = h.calls[0].opts.disallowedTools;
|
||||
assert.ok(Array.isArray(denied) && denied.length > 20, '禁用清单未传给 ZCode');
|
||||
for (const must of ['Bash', 'Write', 'Edit', 'js', 'mcp__node_repl__js']) {
|
||||
assert.ok(denied.includes(must), `禁用清单缺少 ${must}`);
|
||||
}
|
||||
} finally {
|
||||
await h.cleanup();
|
||||
}
|
||||
@ -368,3 +375,34 @@ test('读回的记录形状可直接交给共用去重判据', async () => {
|
||||
assert.ok(rec.replyTos.has('m1'));
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── 档位强制力自报(必须如实,否则是在替不存在的能力背书)────────────
|
||||
|
||||
test('★ 自报 native 要有真凭据:门禁链就绪(yolo + 够长的禁用清单)', async () => {
|
||||
const { detectModeEnforcement } = await import('../src/index.mjs');
|
||||
const r = detectModeEnforcement({ env: {} });
|
||||
assert.equal(r.enforcement, 'native');
|
||||
// 理由里必须点出**谁**在把关。以前这里写的是「钩子已注册」,而 yolo 下
|
||||
// 钩子根本不会触发 —— 那种理由会让人以为平台在管,实际平台什么都没管。
|
||||
assert.match(r.reason, /门禁|请示/);
|
||||
assert.doesNotMatch(r.reason, /^钩子已注册/, '不能拿钩子当唯一凭据');
|
||||
});
|
||||
|
||||
test('★ 反向对照:门禁链断了就必须降级成 advisory', async () => {
|
||||
const { detectModeEnforcement } = await import('../src/index.mjs');
|
||||
// 禁用清单被清空 = 平台自带 Bash/Write/js 全都还回去了 —— 此时即使钩子
|
||||
// 清单正常,也不再是「该档位被强制」。
|
||||
const r = detectModeEnforcement({ env: { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: '' } });
|
||||
assert.equal(r.enforcement, 'advisory');
|
||||
});
|
||||
|
||||
test('★ 只拿得到钩子、拿不到门禁时不能硬报 native', async () => {
|
||||
const { detectModeEnforcement } = await import('../src/index.mjs');
|
||||
const r = detectModeEnforcement({
|
||||
hooksFile: '/nonexistent/hooks.json',
|
||||
env: {}
|
||||
});
|
||||
// 门禁就绪 → 仍然 native(我们拦得住),但理由里不能声称有钩子
|
||||
assert.equal(r.enforcement, 'native');
|
||||
assert.doesNotMatch(r.reason, /钩子已注册/);
|
||||
});
|
||||
|
||||
@ -109,3 +109,48 @@ test('失败回信在没有任何尝试记录时也不崩', () => {
|
||||
const body = renderTurnFailure(undefined, undefined);
|
||||
assert.match(body, /已尝试 0 次/);
|
||||
});
|
||||
|
||||
// ─── 能力说明(平台把自带危险工具禁掉了,模型必须知道)─────────────────
|
||||
|
||||
test('★ workspace 档:说清自带工具被禁、动手要用我们的工具、会被请示', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'workspace' }) });
|
||||
assert.match(p, /Bash \/ Write \/ Edit \/ js/, '必须点名哪些自带工具不可用');
|
||||
assert.match(p, /禁用/);
|
||||
assert.match(p, /run_command/);
|
||||
assert.match(p, /write_file/);
|
||||
assert.match(p, /申请授权/, '模型必须知道动手会先请示');
|
||||
// 被拒是业务结果而非故障,且**不能靠重试或绕道** —— 这三件事必须都说
|
||||
assert.match(p, /报错并给出原因/);
|
||||
assert.match(p, /不要重试/);
|
||||
assert.match(p, /绕道|其它执行手段/);
|
||||
// 只读工具要明确可用,否则模型会以为自己什么都干不了
|
||||
assert.match(p, /Read \/ Glob \/ Grep/);
|
||||
});
|
||||
|
||||
test('★ plan 档:明说不能动手,别浪费一轮去试', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'plan' }) });
|
||||
assert.match(p, /plan 档/);
|
||||
assert.match(p, /不能\*\*执行命令或写文件|不能\*\*执行/);
|
||||
assert.match(p, /一律拒绝/);
|
||||
assert.doesNotMatch(p, /申请授权/, 'plan 档不该说会去申请授权(它根本不会发请求)');
|
||||
});
|
||||
|
||||
test('★ full 档:明说免问(否则模型会以为每步都要等人,反而不敢动手)', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'full' }) });
|
||||
assert.match(p, /full 档/);
|
||||
assert.match(p, /直接生效/);
|
||||
assert.match(p, /不会打扰|无需/);
|
||||
assert.doesNotMatch(p, /第一次调用会先向发件人申请授权/);
|
||||
});
|
||||
|
||||
test('★ 反向对照:三个档位的说明互不相同(写死一档会让另两档撒谎)', () => {
|
||||
const texts = ['plan', 'workspace', 'full'].map(t =>
|
||||
buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: t }) })
|
||||
);
|
||||
assert.equal(new Set(texts).size, 3, '三个档位给出的能力说明必须各不相同');
|
||||
});
|
||||
|
||||
test('没有 permission_mode 时按 workspace(平台默认档)说明', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail() });
|
||||
assert.match(p, /workspace 档/);
|
||||
});
|
||||
|
||||
@ -1,56 +1,66 @@
|
||||
/**
|
||||
* 档位 → ZCode `--mode` 映射的测试。
|
||||
* 档位 → ZCode `--mode` + `--disallowed-tools` 的测试。
|
||||
*
|
||||
* 这个映射是**授权系统存不存在**的开关:ZCode 的判定里 yolo 一律 allow,
|
||||
* 而 `--prompt` 的默认 mode 就是 yolo。映射写错不会报错,只会让全部授权询问
|
||||
* 静默消失 —— 所以它是本项目里少数几个「错一个值等于功能整体失效」的地方。
|
||||
* 这一对参数是**授权系统长什么样**的开关。ZCode 的判定里 yolo 一律 allow,
|
||||
* 而 `--prompt` 的默认 mode 就是 yolo —— 也就是说 `--mode` 漏传或写错,
|
||||
* 平台自己那道防线会静默消失。我们现在的姿态是**故意让平台让开**,
|
||||
* 于是安全边界完全落在两处:这张审过的禁用清单,以及我们自己的门禁。
|
||||
* 所以本文件的两组断言是配对的:
|
||||
*
|
||||
* 1. mode 映射:哪些档位允许平台「不问」
|
||||
* 2. 禁用清单:平台不问的时候,它自带的一切「能动机器」的工具是否都被拿掉
|
||||
*
|
||||
* 单独看任何一组都推不出「安全」:yolo + 完整清单 = 门禁在我们手里;
|
||||
* yolo + 漏一项 = 有一条路可以不过门禁。所以第 2 组里有一条**穷举性**的断言。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { zcodeModeForTier, modeReachesPermissionHook, describeTier, ZCODE_MODES } from '../src/turn-mode.mjs';
|
||||
import {
|
||||
zcodeModeForTier,
|
||||
denylistForTier,
|
||||
ourGateIsActive,
|
||||
modeReachesPermissionHook,
|
||||
describeTier,
|
||||
ZCODE_MODES,
|
||||
REVIEWED_DENYLIST
|
||||
} from '../src/turn-mode.mjs';
|
||||
|
||||
test('★ workspace 映射到 plan(不是 build),full 映射到 yolo', () => {
|
||||
// build 在 headless 下等于「什么都不行」:MCP 工具的 needsApproval 硬编码为真,
|
||||
// 而 headless 没有审批客户端 → 连 read_inbox 都被拒(实测)。
|
||||
// plan 是真的 fail-closed:危险的自带工具被平台拒,我们的非破坏性工具放行。
|
||||
test('★ workspace 与 full 都是 yolo(门禁在我们手里),plan 仍是 plan', () => {
|
||||
// 为什么 workspace 也敢用 yolo:平台上没有第二道防线可用 ——
|
||||
// MCP 工具的 needsApproval 硬编码为真,headless 没有审批客户端 ⇒ build/edit 档下
|
||||
// 连 read_inbox 都被拒(全不可用);PermissionRequest 钩子在本版本不可靠(见 README)。
|
||||
// 于是选择是「平台问、但问不到人 → 全拒」还是「平台不问、我们自己问」。
|
||||
// 后者才是真的可用且仍然可审计。
|
||||
assert.equal(zcodeModeForTier('plan'), 'plan');
|
||||
assert.equal(zcodeModeForTier('workspace'), 'plan');
|
||||
assert.equal(zcodeModeForTier('workspace'), 'yolo');
|
||||
assert.equal(zcodeModeForTier('full'), 'yolo');
|
||||
});
|
||||
|
||||
test('★ 映射可被 AGENTMAIL_ZCODE_MODE_MAP 覆盖(平台修好后不必等发版)', () => {
|
||||
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:build' }), 'build');
|
||||
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:yolo,full:plan' }), 'yolo');
|
||||
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:plan,full:plan' }), 'plan');
|
||||
// 非法值被忽略,不改变默认
|
||||
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:nonsense' }), 'plan');
|
||||
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: '' }), 'plan');
|
||||
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:nonsense' }), 'yolo');
|
||||
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: '' }), 'yolo');
|
||||
});
|
||||
|
||||
test('★ 只有 full 档会得到 yolo', () => {
|
||||
// 反向对照:如果任何其它档位(含拼错的、空的、未知的、大小写不对的)
|
||||
// 也能得到 yolo,那就意味着一个打字错误会关掉整个授权系统。
|
||||
for (const tier of ['plan', 'workspace', '', undefined, 'worjspace', 'default', 'FULL', 'Full']) {
|
||||
test('★ 认不出来的档位不会变成全权:门禁仍然在管', () => {
|
||||
// 共用库的 normalizeMode 把一切认不出来的值归到 **workspace**(不是原样退回、
|
||||
// 也不是报错)。所以「mode 是不是 yolo」已经不是安全性质了 —— workspace 也是 yolo。
|
||||
// 真正的性质是:**只有 full 档能让门禁闭嘴**,而 full 只能由"完全匹配的小写 full"触发。
|
||||
for (const tier of ['nonsense', undefined, '', 'PLAN', 'Plan', 'FULL', 'Full', 'x', null]) {
|
||||
assert.notEqual(
|
||||
zcodeModeForTier(tier),
|
||||
'yolo',
|
||||
`档位 ${JSON.stringify(tier)} 不该得到 yolo(实际 ${zcodeModeForTier(tier)})`
|
||||
'full',
|
||||
`档位 ${JSON.stringify(tier)} 不该被当成 full`
|
||||
);
|
||||
assert.equal(ourGateIsActive(tier), true, `档位 ${JSON.stringify(tier)} 下门禁必须在管`);
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 大写 FULL 不认,落在安全侧', () => {
|
||||
// 共用库的 normalizeMode 是严格匹配的(只认小写),实测 FULL → workspace。
|
||||
// 这是**刻意保留**的好性质:认不出来时不会掉进「免授权」那一档,
|
||||
// 而是退回 default。这条断言把它钉住 —— 哪天有人「顺手」改成大小写不敏感,
|
||||
// 就会有一个打字错误变成全权授权的风险面。
|
||||
assert.equal(zcodeModeForTier('FULL'), 'plan');
|
||||
assert.equal(zcodeModeForTier('PLAN'), 'plan');
|
||||
});
|
||||
|
||||
test('未知档位退回 plan(安全侧),不是 yolo', () => {
|
||||
assert.equal(zcodeModeForTier('nonsense'), 'plan');
|
||||
assert.equal(zcodeModeForTier(undefined), 'plan');
|
||||
// 反向对照:只有真正的小写 full 才关掉门禁。
|
||||
assert.equal(ourGateIsActive('full'), false);
|
||||
assert.equal(ourGateIsActive('workspace'), true);
|
||||
assert.equal(ourGateIsActive('plan'), true);
|
||||
});
|
||||
|
||||
test('产出的 mode 必须是 ZCode 认识的值', () => {
|
||||
@ -59,31 +69,114 @@ test('产出的 mode 必须是 ZCode 认识的值', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 只有 full 档会得到 yolo(覆盖后仍成立)', () => {
|
||||
assert.equal(zcodeModeForTier('full', {}), 'yolo');
|
||||
assert.equal(zcodeModeForTier('workspace', {}), 'plan');
|
||||
assert.equal(zcodeModeForTier('plan', {}), 'plan');
|
||||
});
|
||||
|
||||
test('只有 build / edit 会让危险操作走到授权钩子', () => {
|
||||
test('只有 build / edit 会让危险操作走到平台授权钩子', () => {
|
||||
assert.equal(modeReachesPermissionHook('build'), true);
|
||||
assert.equal(modeReachesPermissionHook('edit'), true);
|
||||
// plan 由 ZCode 自己就拒了;yolo 直接放行 —— 两者都不产生询问
|
||||
// plan 由 ZCode 自己就拒了;yolo 直接放行 —— 两者都不产生询问。
|
||||
// 注意:yolo 下「没有询问」不再等于「没人把关」,所以日志必须另有说法(见下)。
|
||||
assert.equal(modeReachesPermissionHook('plan'), false);
|
||||
assert.equal(modeReachesPermissionHook('yolo'), false);
|
||||
});
|
||||
|
||||
test('★ 反向对照:plan 与 full 都不产生询问,但原因不同', () => {
|
||||
// 两条路都不产生 PermissionRequest,却在日志里必须能区分:
|
||||
// 一个是「只读,ZCode 拒了」,一个是「全权,刻意不问」。
|
||||
test('★ 反向对照:plan 与 workspace 都不产生平台询问,但原因不同', () => {
|
||||
const plan = describeTier('plan');
|
||||
const full = describeTier('full');
|
||||
assert.notEqual(plan, full);
|
||||
const workspace = describeTier('workspace');
|
||||
assert.notEqual(plan, workspace);
|
||||
assert.match(plan, /只读/);
|
||||
assert.match(full, /全权/);
|
||||
// workspace 在本平台退到 plan,日志里必须说清**为什么**退
|
||||
// (否则人只会看到「为什么它什么都不做」而无从判断)
|
||||
assert.match(describeTier('workspace'), /headless 做不到|只读/);
|
||||
// 显式覆盖回 build 时,说明恢复成「会走到授权钩子」
|
||||
// workspace 的说明必须点出「谁在把关」——否则人看到「--mode yolo」会以为
|
||||
// 授权系统被关掉了,而真实情况是平台不问、我们逐次请示。
|
||||
assert.match(workspace, /门禁|请示/);
|
||||
assert.match(workspace, /禁用/);
|
||||
// 显式覆盖回 build 时,说明恢复成「平台会问、钩子转达」
|
||||
assert.match(describeTier('workspace', 'build'), /授权钩子/);
|
||||
});
|
||||
|
||||
test('★ 三个档位都拿到同一张禁用清单(只有一条代码路径)', () => {
|
||||
// 如果某个档位「忘了」加禁用清单,那一档就会多出 Bash/Write/js —— 而它们
|
||||
// 恰好是绕过门禁的方式。所以这里逐个档位验,而不是只验默认档。
|
||||
const base = denylistForTier('workspace', {});
|
||||
for (const tier of ['plan', 'workspace', 'full', 'nonsense', undefined]) {
|
||||
assert.deepEqual(denylistForTier(tier, {}), base, `档位 ${tier} 的清单不一致`);
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 穷举性:一切「能动机器」的自带工具都在清单里', () => {
|
||||
// 这份名单来自 CLI 产物里模型可见工具名的权威注册表(aIn 那个 28 项数组),
|
||||
// 并与另一处更宽的候选集取并集。不采信模型自述 —— 实测基线里它用某个
|
||||
// 没点名的方式真的创建了文件。
|
||||
//
|
||||
// 断言方式刻意选「逐项列出 + 已审阅」而不是「与某个运行时清单对比」:
|
||||
// 后者需要一个可信来源,而唯一的来源就是这份清单本身(循环论证)。
|
||||
// 所以这条测试的作用是**把审阅结论钉住** —— 新增/删除一项都必须来改它。
|
||||
const mustBlock = [
|
||||
// 机器改动
|
||||
'Bash',
|
||||
'Write',
|
||||
'Edit',
|
||||
'ApplyPatch',
|
||||
'NotebookEdit',
|
||||
'LSP', // rename 会应用工作区编辑
|
||||
'EnterWorktree',
|
||||
'ExitWorktree',
|
||||
// 等价于 Bash 的 JS 执行通道(挂在 MCP 上,最容易漏)
|
||||
'js',
|
||||
'mcp__node_repl__js',
|
||||
'js_reset',
|
||||
'js_add_node_module_dir',
|
||||
'mcp__node_repl__js_reset',
|
||||
'mcp__node_repl__js_add_node_module_dir',
|
||||
// 延迟执行:把危险动作挪到没人看着的时候
|
||||
'CronCreate',
|
||||
'CronUpdate',
|
||||
'CronDelete',
|
||||
'CronList',
|
||||
'ScheduleWakeup',
|
||||
'Workflow',
|
||||
// 子代理 / 后台任务(工具集是否继承本清单未验证)
|
||||
'Agent',
|
||||
'Task',
|
||||
'TaskCreate',
|
||||
'TaskGet',
|
||||
'TaskList',
|
||||
'TaskOutput',
|
||||
'TaskStop',
|
||||
'TaskUpdate',
|
||||
// 绕过 AgentMail 的对外通道
|
||||
'SendMessage',
|
||||
'RespondToCoordinator',
|
||||
// 档位逃生门
|
||||
'EnterPlanMode',
|
||||
'ExitPlanMode'
|
||||
];
|
||||
for (const name of mustBlock) {
|
||||
assert.ok(REVIEWED_DENYLIST.includes(name), `禁用清单缺少 ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 保留的必须是只读或纯本地状态(不能顺手把执行能力留下来)', () => {
|
||||
// 反向对照:清单是黑名单,漏一项就是开一个洞。反过来「多禁」只会少个能力,
|
||||
// 所以这里的断言是**确保没有把危险的东西留在允许侧**。
|
||||
const dangerous = ['Bash', 'Write', 'Edit', 'ApplyPatch', 'js', 'mcp__node_repl__js', 'Agent'];
|
||||
for (const name of dangerous) {
|
||||
assert.ok(!['Read', 'Glob', 'Grep', 'TodoWrite'].includes(name), '测试自身写错了');
|
||||
assert.ok(REVIEWED_DENYLIST.includes(name), `${name} 必须被禁`);
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 禁用清单可以被配置整表替换(收紧与放宽都要能改)', () => {
|
||||
// 整表替换而不是追加:收紧(连 WebFetch 一起拿掉)与本地调试(临时还回 Bash)
|
||||
// 是同一个旋钮的两端。
|
||||
assert.deepEqual(denylistForTier('workspace', { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: 'Bash Write' }), [
|
||||
'Bash',
|
||||
'Write'
|
||||
]);
|
||||
assert.deepEqual(denylistForTier('workspace', { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: 'Bash,Write' }), [
|
||||
'Bash',
|
||||
'Write'
|
||||
]);
|
||||
// 空串 = 一张空清单,与「没设置」不同(没设置要用默认清单)。
|
||||
// 这个区分很重要:把空串当默认会让「我想全放开」变成「我在用默认」,
|
||||
// 而两者只差一个环境变量的有无。
|
||||
assert.deepEqual(denylistForTier('workspace', { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: '' }), []);
|
||||
assert.ok(denylistForTier('workspace', {}).length > 20);
|
||||
});
|
||||
|
||||
@ -60,18 +60,29 @@ test('resume 只在有时才带(首轮不该带空 --resume)', () => {
|
||||
assert.equal(next[next.indexOf('--resume') + 1], 'sess_1');
|
||||
});
|
||||
|
||||
test('maxTurns 与工具黑白名单按需传递', () => {
|
||||
test('maxTurns 与禁用清单按需传递', () => {
|
||||
const args = buildRunArgs({
|
||||
prompt: 'p',
|
||||
cwd: '/tmp',
|
||||
mode: 'plan',
|
||||
maxTurns: 6,
|
||||
allowedTools: ['Read', 'Grep'],
|
||||
disallowedTools: ['Bash']
|
||||
disallowedTools: ['Bash', 'Write']
|
||||
});
|
||||
assert.equal(args[args.indexOf('--max-turns') + 1], '6');
|
||||
assert.equal(args[args.indexOf('--allowed-tools') + 1], 'Read,Grep');
|
||||
assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'Bash');
|
||||
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: [] }));
|
||||
});
|
||||
|
||||
// ─── 解析 ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user