按用户裁定「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、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
183 lines
8.4 KiB
JavaScript
183 lines
8.4 KiB
JavaScript
/**
|
||
* 档位 → ZCode `--mode` + `--disallowed-tools` 的测试。
|
||
*
|
||
* 这一对参数是**授权系统长什么样**的开关。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,
|
||
denylistForTier,
|
||
ourGateIsActive,
|
||
modeReachesPermissionHook,
|
||
describeTier,
|
||
ZCODE_MODES,
|
||
REVIEWED_DENYLIST
|
||
} from '../src/turn-mode.mjs';
|
||
|
||
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'), '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:plan,full:plan' }), 'plan');
|
||
// 非法值被忽略,不改变默认
|
||
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:nonsense' }), 'yolo');
|
||
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: '' }), 'yolo');
|
||
});
|
||
|
||
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),
|
||
'full',
|
||
`档位 ${JSON.stringify(tier)} 不该被当成 full`
|
||
);
|
||
assert.equal(ourGateIsActive(tier), true, `档位 ${JSON.stringify(tier)} 下门禁必须在管`);
|
||
}
|
||
// 反向对照:只有真正的小写 full 才关掉门禁。
|
||
assert.equal(ourGateIsActive('full'), false);
|
||
assert.equal(ourGateIsActive('workspace'), true);
|
||
assert.equal(ourGateIsActive('plan'), true);
|
||
});
|
||
|
||
test('产出的 mode 必须是 ZCode 认识的值', () => {
|
||
for (const tier of ['plan', 'workspace', 'full', 'x', undefined]) {
|
||
assert.ok(ZCODE_MODES.includes(zcodeModeForTier(tier)), tier);
|
||
}
|
||
});
|
||
|
||
test('只有 build / edit 会让危险操作走到平台授权钩子', () => {
|
||
assert.equal(modeReachesPermissionHook('build'), true);
|
||
assert.equal(modeReachesPermissionHook('edit'), true);
|
||
// plan 由 ZCode 自己就拒了;yolo 直接放行 —— 两者都不产生询问。
|
||
// 注意:yolo 下「没有询问」不再等于「没人把关」,所以日志必须另有说法(见下)。
|
||
assert.equal(modeReachesPermissionHook('plan'), false);
|
||
assert.equal(modeReachesPermissionHook('yolo'), false);
|
||
});
|
||
|
||
test('★ 反向对照:plan 与 workspace 都不产生平台询问,但原因不同', () => {
|
||
const plan = describeTier('plan');
|
||
const workspace = describeTier('workspace');
|
||
assert.notEqual(plan, workspace);
|
||
assert.match(plan, /只读/);
|
||
// 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);
|
||
});
|