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,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} 太短,人工审批窗口不够`);
});

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

View File

@ -215,11 +215,12 @@ test('Agent 来信的提示词必须说清「插件不会替你回信」', async
}
});
test('★ 档位随邮件传下去,并作为 --mode 钩子环境变量注入', async () => {
test('★ 档位随邮件传下去,并作为 --mode / 禁用清单 / 钩子环境变量注入', async () => {
for (const [tier, mode] of [
['plan', 'plan'],
// workspace 默认映射到 planbuild 在 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, /钩子已注册/);
});

View File

@ -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 档/);
});

View File

@ -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不是 buildfull 映射到 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);
});

View File

@ -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: [] }));
});
// ─── 解析 ─────────────────────────────────────────────────────────