按用户裁定「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、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
409 lines
16 KiB
JavaScript
409 lines
16 KiB
JavaScript
/**
|
||
* 驱动的整条流水线测试(不需要模型、不需要 ZCode)。
|
||
*
|
||
* 判据集中在几件**错了就会静默出错**的事上:
|
||
*
|
||
* - 说错「会不会替你回信」→ 要么发件人等一封永远不来的信,要么收到两封重复邮件
|
||
* - 忘了带 `--mode` → 授权询问全部消失(见 turn-mode 的测试)
|
||
* - 一轮跑不起来却不回信 → 发件人只看到「信发出去了,然后再无音讯」
|
||
* - 去重失效 → 同一封信被处理两遍
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { createDriver } from '../src/index.mjs';
|
||
import { explicitSendsFile, noteExplicitSendFile } from '../lib/explicit-sends.mjs';
|
||
|
||
/** 假网关客户端:记下发出去的每一封信。 */
|
||
function fakeClient() {
|
||
const sent = [];
|
||
return {
|
||
sent,
|
||
baseURL: 'http://fake',
|
||
agentName: 'zcode',
|
||
authHeaders: () => ({ 'X-Agent-Name': 'zcode' }),
|
||
checkConfig: () => [],
|
||
async post(path, body) {
|
||
if (path === '/mail/send') {
|
||
sent.push(body);
|
||
return { mail_id: `out-${sent.length}` };
|
||
}
|
||
return {};
|
||
},
|
||
async get() {
|
||
return {};
|
||
}
|
||
};
|
||
}
|
||
|
||
/** 一封来自人的来信。 */
|
||
const humanMail = (over = {}) => ({
|
||
mail_id: 'in-1',
|
||
session_id: 'sess-1',
|
||
role: 'to',
|
||
from_human: true,
|
||
from_name: 'gui-lab',
|
||
subject: '帮我看看日志',
|
||
permission_mode: 'workspace',
|
||
to_workspace: '',
|
||
reply_address: 'gui-lab@/work.sess-1',
|
||
...over
|
||
});
|
||
|
||
async function harness({ turn = { sessionId: 'sess_z1', response: '结论:是磁盘满了', exitCode: 0 }, env } = {}) {
|
||
const dir = await mkdtemp(join(tmpdir(), 'zc-driver-'));
|
||
const client = fakeClient();
|
||
const logs = [];
|
||
const calls = [];
|
||
const driver = createDriver({
|
||
client,
|
||
logFn: (...a) => logs.push(a.join(' ')),
|
||
env: env || {},
|
||
config: { workspaceRoot: dir, cliPath: '/fake/zcode.cjs', turnTimeoutMs: 60_000 },
|
||
runTurnFn: async (opts, deps) => {
|
||
calls.push({ opts, deps });
|
||
// 第三个参数是调用序号:测试里常要「第一次失败、第二次成功」
|
||
return typeof turn === 'function' ? turn(opts, deps, calls.length) : turn;
|
||
}
|
||
});
|
||
return { driver, client, logs, calls, dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
||
}
|
||
|
||
test('★ 人来信 + 模型没自己发 → 自动回信', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 1, '必须回一封信');
|
||
const mail = h.client.sent[0];
|
||
assert.equal(mail.to, 'gui-lab');
|
||
assert.equal(mail.body, '结论:是磁盘满了');
|
||
assert.equal(mail.reply_to, 'in-1');
|
||
assert.equal(mail.subject, 'Re: 帮我看看日志');
|
||
// 走免配额通道:模型已经把话说完了,驱动只是搬运
|
||
assert.equal(mail.relay, 'summary');
|
||
assert.ok(mail.relay_key, '要有幂等键');
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ Agent 来信 → 不自动转发(Agent 间必须自己 send_mail)', async () => {
|
||
// 反向对照:同一封邮件只翻转 from_human,回信行为必须跟着翻转。
|
||
// 不这么做的话,两个 Agent 会互相把对方的「已收到」当成待办,无限客套下去。
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail({ from_human: false, from_name: 'pi' }));
|
||
assert.equal(h.client.sent.length, 0, 'Agent 来信不该被自动回信');
|
||
assert.ok(
|
||
h.logs.some(l => /不自动转发/.test(l)),
|
||
`日志里应说明原因:${h.logs.join(' | ')}`
|
||
);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 模型这一轮自己发过信 → 让位,不重复转发', async () => {
|
||
// 线上实测过后果:收件箱里两封说同一件事的邮件(311 与 342 字节)。
|
||
const env = { AGENTMAIL_ZCODE_SENDS_FILE: join(await mkdtemp(join(tmpdir(), 'zc-sends-')), 'sends.jsonl') };
|
||
noteExplicitSendFile(env.AGENTMAIL_ZCODE_SENDS_FILE, {
|
||
sessionId: 'sess-1',
|
||
to: 'gui-lab',
|
||
replyTo: 'in-1'
|
||
});
|
||
const h = await harness({ env });
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 0, '模型已亲手回过,驱动不该再发一封');
|
||
assert.ok(h.logs.some(l => /跳过自动转发/.test(l)));
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 反向对照:另一条会话的主动发信不该让本会话沉默', async () => {
|
||
// 去重不能按「有人发过信」一刀切,必须按会话配对。
|
||
const dir = await mkdtemp(join(tmpdir(), 'zc-sends-'));
|
||
const env = { AGENTMAIL_ZCODE_SENDS_FILE: join(dir, 'sends.jsonl') };
|
||
noteExplicitSendFile(env.AGENTMAIL_ZCODE_SENDS_FILE, {
|
||
sessionId: 'sess-OTHER',
|
||
to: 'gui-lab',
|
||
replyTo: 'in-1'
|
||
});
|
||
const h = await harness({ env });
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 1, '别的会话发过信不该影响这一封');
|
||
} finally {
|
||
await h.cleanup();
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('★★ 一轮跑不起来 → 必须回一封失败信', async () => {
|
||
// 邮件驱动的会话没有本地界面:什么都不发等于「信发出去了,然后再无音讯」。
|
||
const h = await harness({
|
||
turn: { sessionId: '', response: '', exitCode: 1, stderrTail: 'Model config is missing.' }
|
||
});
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 1, '失败也必须回信');
|
||
const mail = h.client.sent[0];
|
||
assert.match(mail.subject, /处理失败/);
|
||
assert.match(mail.body, /Model config is missing/);
|
||
// 失败信的正文要给出**这个平台**的成因,而不是别处的建议
|
||
assert.match(mail.body, /没有登录/);
|
||
assert.match(mail.body, /AGENTMAIL_ZCODE_CLI/);
|
||
assert.equal(h.driver.stats.failures, 1);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('超时也算失败,且原因写明超时', async () => {
|
||
const h = await harness({
|
||
turn: { sessionId: '', response: '', exitCode: -1, timedOut: true }
|
||
});
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.match(h.client.sent[0].body, /超时/);
|
||
assert.doesNotMatch(h.client.sent[0].body, /CLI 失败/);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('退出码 0 但没有最终文本 → 不冒充回信', async () => {
|
||
const h = await harness({ turn: { sessionId: 's', response: ' ', exitCode: 0 } });
|
||
try {
|
||
const r = await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 0);
|
||
assert.equal(r.relayed, false);
|
||
assert.ok(h.logs.some(l => /没有产出最终文本/.test(l)));
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
// ─── 提示词与档位怎么传下去 ─────────────────────────────────────────
|
||
test('提示词里带上回信地址与邮件 id(模型自己发信时要拼对地址)', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
const prompt = h.calls[0].opts.prompt;
|
||
assert.match(prompt, /gui-lab@\/work\.sess-1/);
|
||
assert.match(prompt, /in-1/);
|
||
// 人来信:告诉模型插件会替它回信
|
||
assert.match(prompt, /回信不用你自己发/);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('Agent 来信的提示词必须说清「插件不会替你回信」', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail({ from_human: false, from_name: 'pi' }));
|
||
const prompt = h.calls[0].opts.prompt;
|
||
assert.match(prompt, /不会替你回信/);
|
||
assert.doesNotMatch(prompt, /回信不用你自己发/);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 档位随邮件传下去,并作为 --mode / 禁用清单 / 钩子环境变量注入', async () => {
|
||
for (const [tier, mode] of [
|
||
['plan', 'plan'],
|
||
// workspace 与 full 都映射到 yolo:平台不做权限判定(它自带危险工具已被
|
||
// --disallowed-tools 拿掉),执行类动作改由我们自己的门禁逐次请示。
|
||
['workspace', 'yolo'],
|
||
['full', 'yolo']
|
||
]) {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail({ permission_mode: tier }));
|
||
assert.equal(h.calls[0].opts.mode, mode, `档位 ${tier} 应映射到 ${mode}`);
|
||
const env = h.calls[0].opts.env;
|
||
// 钩子靠这两个变量决定档位与「有没有本地界面」
|
||
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();
|
||
}
|
||
}
|
||
});
|
||
|
||
test('工作目录取 to_workspace;没有就用兜底目录', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.ok(h.calls[0].opts.cwd.startsWith(h.dir), `兜底目录应在 ${h.dir} 下,实际 ${h.calls[0].opts.cwd}`);
|
||
|
||
const real = await mkdtemp(join(tmpdir(), 'zc-ws-'));
|
||
await h.driver.processMail(humanMail({ mail_id: 'in-2', to_workspace: real }));
|
||
assert.equal(h.calls[1].opts.cwd, real);
|
||
await rm(real, { recursive: true, force: true });
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 同一会话的第二封信带上 --resume(否则模型每封信都从零开始)', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.calls[0].opts.resumeSessionId, undefined, '首轮不该带 resume');
|
||
await h.driver.processMail(humanMail({ mail_id: 'in-2' }));
|
||
assert.equal(h.calls[1].opts.resumeSessionId, 'sess_z1', '第二轮要续上同一个 ZCode 会话');
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
// ─── 事件入口 ───────────────────────────────────────────────────────
|
||
test('SSE 事件:重复的 mail_id 只处理一次', async () => {
|
||
const h = await harness();
|
||
try {
|
||
h.driver.handleEvent('new_mail', humanMail());
|
||
h.driver.handleEvent('new_mail', humanMail());
|
||
await new Promise(r => setTimeout(r, 20));
|
||
assert.equal(h.calls.length, 1, '同一封信被处理了两遍');
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('抄送给自己也处理(role=cc),其它角色忽略', async () => {
|
||
const h = await harness();
|
||
try {
|
||
h.driver.handleEvent('new_mail', humanMail({ mail_id: 'cc-1', role: 'cc' }));
|
||
h.driver.handleEvent('new_mail', humanMail({ mail_id: 'x-1', role: 'from' }));
|
||
await new Promise(r => setTimeout(r, 20));
|
||
assert.equal(h.calls.length, 1);
|
||
assert.equal(h.calls[0].opts.prompt.includes('cc-1'), true);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('非 new_mail 事件被忽略(permission_decision 由钩子自己处理)', async () => {
|
||
const h = await harness();
|
||
try {
|
||
h.driver.handleEvent('permission_decision', { relay_key: 'k' });
|
||
h.driver.handleEvent('session_archived', { session_id: 's' });
|
||
await new Promise(r => setTimeout(r, 20));
|
||
assert.equal(h.calls.length, 0);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('一封邮件处理崩了不会带走驱动(后面的信照常处理)', async () => {
|
||
const h = await harness({
|
||
turn: (opts, deps, n) => {
|
||
if (n === 1) throw new Error('boom');
|
||
return { sessionId: 's', response: '第二封处理好了', exitCode: 0 };
|
||
}
|
||
});
|
||
try {
|
||
h.driver.handleEvent('new_mail', humanMail());
|
||
h.driver.handleEvent('new_mail', humanMail({ mail_id: 'in-2' }));
|
||
await new Promise(r => setTimeout(r, 50));
|
||
assert.equal(h.client.sent.length, 1);
|
||
assert.match(h.client.sent[0].body, /第二封处理好了/);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 关停时终止在途回合(不留下跑工具的孤儿)', async () => {
|
||
// systemd 杀掉驱动后,那个 ZCode 进程还在跑工具,而既没有驱动看着它、
|
||
// 也没有本地界面看着它 —— 宁可丢掉这一轮的工作。
|
||
const signals = [];
|
||
let release;
|
||
const h = await harness({
|
||
turn: async (opts, deps) => {
|
||
// 真实现会把「怎么杀」通过 deps.onChild 交出来(见 zcode-run.mjs)
|
||
deps.onChild(sig => signals.push(sig));
|
||
await new Promise(r => (release = r));
|
||
return { sessionId: 's', response: 'x', exitCode: 0 };
|
||
}
|
||
});
|
||
try {
|
||
const p = h.driver.processMail(humanMail());
|
||
await new Promise(r => setTimeout(r, 20));
|
||
assert.equal(typeof release, 'function', '回合应已开始');
|
||
|
||
h.driver.abort();
|
||
assert.deepEqual(signals, ['SIGTERM'], 'abort 必须终止在途回合');
|
||
// 幂等:重复关停不该再杀一次
|
||
h.driver.abort();
|
||
assert.deepEqual(signals, ['SIGTERM']);
|
||
|
||
release();
|
||
await p;
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
// ─── 主动发信记录的落盘 ─────────────────────────────────────────────
|
||
test('★ explicit-sends 文件位置两端一致(驱动与 MCP 服务器必须解析出同一路径)', async () => {
|
||
const env = { AGENTMAIL_CONFIG_DIR: '/tmp/agentmail-cfg' };
|
||
assert.equal(explicitSendsFile(env), '/tmp/agentmail-cfg/explicit-sends.jsonl');
|
||
assert.equal(explicitSendsFile({ ZCODE_PLUGIN_DATA: '/d' }), '/d/explicit-sends.jsonl');
|
||
assert.match(explicitSendsFile({}), /explicit-sends\.jsonl$/);
|
||
});
|
||
|
||
test('读回的记录形状可直接交给共用去重判据', async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), 'zc-sends-'));
|
||
const f = join(dir, 'sends.jsonl');
|
||
noteExplicitSendFile(f, { sessionId: 's1', to: 'gui-lab@/p', replyTo: 'm1' });
|
||
const { readExplicitSends } = await import('../lib/explicit-sends.mjs');
|
||
const rec = readExplicitSends(f, { sessionId: 's1' });
|
||
assert.ok(rec.names.has('gui-lab'));
|
||
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, /钩子已注册/);
|
||
});
|