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:
@ -14,7 +14,8 @@
|
||||
"cwd": "${ZCODE_PROJECT_DIR}",
|
||||
"env": {
|
||||
"ZCODE_PLUGIN_ID": "agentmail"
|
||||
}
|
||||
},
|
||||
"timeoutMs": 600000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ test/manual/permission-e2e.mjs 授权桥端到端(真去点同意/拒绝)
|
||||
test/manual/driver-e2e.mjs 邮件驱动端到端(桩 CLI,真网关真邮件)
|
||||
```
|
||||
|
||||
## 三条能力线
|
||||
## 三条能力线(现行姿态:一条主路 + 一条桌面通路)
|
||||
|
||||
### 1)MCP 工具面
|
||||
|
||||
@ -48,7 +48,59 @@ test/manual/driver-e2e.mjs 邮件驱动端到端(桩 CLI,真网关真
|
||||
`test/tools.test.mjs` 里有一条断言直接拿 pi 桥的工具名做对照:少一个就会让某个平台
|
||||
的行为与其它平台不同,而那种问题只在单一平台复现,排查代价最高。
|
||||
|
||||
### 2)授权桥(`PermissionRequest` 钩子)
|
||||
### 2)执行门禁(headless 的主力路径)—— `lib/action-tools.mjs` + `lib/approval.mjs`
|
||||
|
||||
**这是本平台现在真正的安全边界。** 平台自带的 `Bash`/`Write`/`Edit`/`js` 等
|
||||
32 项「能动机器」的工具全部被 `--disallowed-tools` 拿掉(清单见
|
||||
`src/turn-mode.mjs` 的 `REVIEWED_DENYLIST`,逐条的取舍理由写在那里),
|
||||
模型唯一能动手的路径是我们自己的两个工具:
|
||||
|
||||
| 工具 | 作用 | 批准后 |
|
||||
|---|---|---|
|
||||
| `run_command` | 执行一条 shell 命令(`bash -c`,默认 cwd = 会话工作区) | 真执行;退出码 / stdout / stderr 原样交回模型;输出超 16000 字符截断并标明截了多少 |
|
||||
| `write_file` | 写文件(覆盖写,父目录自动建) | 真写;平台保护目录之外才行 |
|
||||
|
||||
两者的语义与档位对齐:
|
||||
|
||||
| 档位 | `--mode` | `run_command` / `write_file` |
|
||||
|---|---|---|
|
||||
| `plan` | `plan` | **直接拒绝**,且**根本不发授权请求**(注定拒绝的事不该打扰人) |
|
||||
| `workspace`(默认) | `yolo` | 每次调用**先向发件人请示**,拿到同意才执行 |
|
||||
| `full` | `yolo` | 直接执行(该档语义就是发件人已给全权) |
|
||||
|
||||
为什么 workspace 敢用 `yolo`:平台那条路在本环境下**不可用**——
|
||||
MCP 工具的 `needsApproval` 在产物里硬编码为 `true`,headless 没有审批客户端
|
||||
可问 ⇒ `build`/`edit` 档下**每个** MCP 工具都被拒(连 `read_inbox` 都调不动)。
|
||||
于是选择只有两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
|
||||
后者才是真的可用且仍然可审计。
|
||||
|
||||
**三道不可动摇的规矩**(`lib/approval.mjs` 的文件头有完整推导):
|
||||
|
||||
1. **只有明确同意才放行** —— 判据是共用库的前缀白名单(`^同意|一直同意|allow|approve|always|yes`,
|
||||
四个桥共用同一份)。看不懂的文本、空串、`拒绝`、`deny`、平台自己的 `shutdown` 哨兵
|
||||
全部当拒绝。判据是「在放行白名单里」,不是「不等于拒绝」。
|
||||
2. **永久失败当场拒绝** —— 409(本线索内没有可决策的人)/ 其它 4xx 不会因重试而改变;
|
||||
服务端的 `error` 与 `suggestion` 原样带回给模型,让它能改道而不是盲试。
|
||||
3. **暂时失败看有没有本地界面** —— 判据是**调用方传的会话 id**(单一事实来源,
|
||||
不再另读 `AGENTMAIL_SESSION_ID`,两个来源不一致时谁也说不清):
|
||||
邮件驱动(有会话、无界面)必须 fail closed;交互模式退回平台自己的流程。
|
||||
|
||||
**保护目录**(`PROTECTED_PREFIXES`):`/opt/agentmail/data`(网关数据库)、
|
||||
`/opt/agentmail/plugins`(我们自己的代码)、`/etc/systemd`、`/etc/agentmail`(含密钥)、
|
||||
`/root/.agentmail-zcode`、`/root/.ssh` —— 这些**无论谁批准都不写**,而且判定在门禁
|
||||
**之前**(不消耗人的注意力)。理由不是不信任人,而是防自我强化:邮件驱动的 Agent
|
||||
可能被来信诱导去改网关数据库或自己的插件代码,改完下一轮就换了一套规则,
|
||||
而人看到的是一封看起来合理的申请。这类改动应当是人工部署动作。
|
||||
|
||||
拒绝时**抛错**(MCP 层转成 `isError: true`)而不是返回一句「已处理」:
|
||||
模型必须看见原因才有机会改道。opencode 上「工具失败但报成功」导致模型连试 6 次、
|
||||
最后放弃整个任务的教训。
|
||||
|
||||
### 2b)授权桥(`PermissionRequest` 钩子)—— 交互(桌面)模式用
|
||||
|
||||
**注意:headless 驱动这条路上这个钩子不参与**(`--mode yolo` 下平台不做任何权限判定,
|
||||
钩子根本不会触发)。它保留给「人自己开着 ZCode 干活」的场景,那时没有桥进程在跑,
|
||||
钩子就是唯一的授权通道。
|
||||
|
||||
ZCode 决定某个工具需要授权时触发钩子(事件 JSON 走 stdin),我们回一个结论走 stdout:
|
||||
|
||||
@ -101,9 +153,12 @@ node /opt/ZCode/resources/glm/zcode.cjs \
|
||||
```
|
||||
|
||||
**⚠ `--mode` 漏传的后果**:`--prompt` 的默认 mode 是 **`yolo`**,而 ZCode 的判定里
|
||||
`mode === "yolo"` 一律 allow(`Yolo mode bypasses permission prompts`)——
|
||||
授权钩子根本不会触发,整个授权系统**静默消失**(不报错,只是没有询问)。
|
||||
档位映射表见 `src/turn-mode.mjs`;`buildRunArgs` 收不到 mode 会直接抛错。
|
||||
`mode === "yolo"` 一律 allow(`Yolo mode bypasses permission prompts`)。
|
||||
现在 workspace 档**故意**用 `yolo`(见上文 2 节),所以「mode 是不是 yolo」
|
||||
已经不是安全性质 —— 真正的性质是「我们自己的门禁在不在管」,由
|
||||
`denylistForTier` 与 `ourGateIsActive` 两个函数表达,且都有测试钉住。
|
||||
`buildRunArgs` 收不到 mode 会直接抛错;`--allowed-tools` 会被它直接拒掉
|
||||
(本版本 CLI 的 help 里写着这个选项,但解析器报 `Unknown option`)。
|
||||
|
||||
回信策略(与另三桥同源,复用 `lib/relay-policy.js`):
|
||||
|
||||
@ -139,6 +194,8 @@ node /opt/ZCode/resources/glm/zcode.cjs \
|
||||
| `AGENTMAIL_SESSION_ID` | **仅邮件驱动时**由驱动进程注入:本会话的 AgentMail 会话 id,兼作「有无本地界面」的判据 |
|
||||
| `AGENTMAIL_PERMISSION_MODE` | 档位(`plan`/`workspace`/`full`),由驱动按邮件的 `permission_mode` 注入 |
|
||||
| `AGENTMAIL_PERMISSION_WAIT_MS` | 等人工决策的上限,默认 540000(9 分钟,须小于钩子的 `timeoutMs`) |
|
||||
| `AGENTMAIL_ZCODE_MODE_MAP` | 覆盖档位→mode 映射(`workspace:yolo,full:yolo`),平台修好钩子后只改配置即可恢复 |
|
||||
| `AGENTMAIL_ZCODE_DISALLOWED_TOOLS` | **整表替换**禁用清单(空格/逗号分隔)。传空串 = 一张空清单,与「没设置」不同 |
|
||||
|
||||
密钥怎么给:ZCode 的插件 `userConfig` **不支持** `sensitive` 值(官方文档明说
|
||||
「sensitive 值当前无法在界面输入或持久化」),所以密钥走 **ZCode 进程的环境变量**
|
||||
@ -186,8 +243,21 @@ node test/manual/permission-e2e.mjs
|
||||
|
||||
# 邮件驱动端到端:桩 CLI 替掉 ZCode,真网关真邮件
|
||||
node test/manual/driver-e2e.mjs
|
||||
|
||||
# 门禁端到端(真模型、真网关、真邮件):批了→真执行;拒了→真不执行
|
||||
python3 /root/e2e-zcode-gate/run.py
|
||||
```
|
||||
|
||||
新增的两组测试把安全性质钉住(共 34 条):
|
||||
|
||||
- `test/approval.test.mjs`:全部授权往返。重点在反向对照 ——
|
||||
一句「拒绝/deny/空串/平台自己的 shutdown 哨兵都不放行」之外,
|
||||
还验了「别人的决策不能拿来用(relay_key 配对)」(同一个 Agent 并行发起
|
||||
两个动作时,B 的同意不能放行 A)与「超时必须真的拒绝而不是静默放行」。
|
||||
- `test/action-tools.test.mjs`:每条拒绝场景都配一个**文件系统断言** ——
|
||||
「抛错了」不等于「副作用没发生」,只看抛错发现不了「抛完错照样写文件」。
|
||||
保护目录还验了路径花招(`..`、`./`)绕不过去。
|
||||
|
||||
`driver-e2e.mjs` 用桩 CLI 把「除了模型之外」的每一环都真跑一遍:参数拼装
|
||||
(尤其是 `--mode`)、stream-json 解析、回信策略、跨进程去重、失败必回信。
|
||||
它需要 `gui-lab` 这个人类账号(去点界面/收信)与 zcode 的凭据。
|
||||
@ -199,20 +269,42 @@ node test/manual/driver-e2e.mjs
|
||||
(实测 6 条,含其它 Agent 的),只取「第一条新的」会拿到一条无关请求,
|
||||
于是人点了同意而钩子在等自己那条,最后超时。
|
||||
|
||||
## 已知缺口
|
||||
## 已知缺口与残余风险
|
||||
|
||||
- **真实一轮还没跑过**:ZCode 的模型访问要 OAuth 登录,登录完成前 headless 会直接
|
||||
报「Model config is missing」。桩 CLI 已经把除「模型干活」之外的每一环验过了,
|
||||
但「模型能不能真的用这些工具把活干完」要等登录后实测。
|
||||
- **驱动服务已写好但**未启用**:`deploy/zcode-mail-bridge.service`。
|
||||
未登录就启用的话,每封来信都会收到一封「处理失败」,所以留给人决定。
|
||||
启用:`install -m 0644 deploy/zcode-mail-bridge.service /etc/systemd/system/ && cp deploy/zcode.env.example /etc/agentmail/zcode.env && systemctl enable --now zcode-mail-bridge`。
|
||||
- **`mode_enforcement` 靠自检得出**:驱动启动时会读自己的 `hooks/hooks.json`,
|
||||
确认 `PermissionRequest` 已注册才报 `native`,否则报 `advisory` 并在日志里说明原因
|
||||
(不替一个不存在的能力背书)。
|
||||
- **生产路径**:当前 `plugins.dirs` 指向仓库工作副本,按项目纪律应改为
|
||||
`/opt/agentmail/plugins/zcode-mail-bridge/current` 的快照 + 原子切换
|
||||
(等 ZCode 重启不影响在跑的登录流程时再做)。
|
||||
### 残余风险(新姿态必须说清楚的代价)
|
||||
|
||||
- **`yolo` 意味着平台不再有任何权限判定**。安全完全来自两处:那张
|
||||
`REVIEWED_DENYLIST`(黑名单,漏一项就是开一个洞),以及我们自己的门禁。
|
||||
黑名单的完整性靠**穷举工具名**保证——名单来自 CLI 产物里模型可见工具名的
|
||||
权威注册表(`aIn` 那个 28 项数组)+ 另一份更宽的候选集取并集,测试里有一条
|
||||
「一切能动机器的自带工具都在清单里」把审阅结论钉住。但**不能证明完备**:
|
||||
换版本、装新插件(浏览器类、其它 MCP 服务器)都可能新增执行能力,
|
||||
而黑名单不会自动跟上。升级 ZCode 或安装新插件后应重做一次枚举。
|
||||
- **网络出向未被门禁覆盖**:`WebFetch` / `WebSearch` 保留着(它们读不到本机文件),
|
||||
但能把上下文里的内容编码进 URL 发出去。要收紧就把它们加进
|
||||
`AGENTMAIL_ZCODE_DISALLOWED_TOOLS`。
|
||||
- **文件系统读取范围未被限制**:`Read`/`Grep`/`Glob` 能读本机任何可读文件
|
||||
(包括密钥文件)。要收紧只能收紧整机权限(systemd `ProtectHome` / 专用用户)。
|
||||
- **保护目录是前缀匹配**:它挡住的是「按字面路径写入」,不是绕过符号链接等
|
||||
间接路径;真正的隔离需要 mount namespace 或文件权限。
|
||||
|
||||
### 缺口
|
||||
|
||||
- **旧钩子在本版本(3.10.2 / CLI 0.16.5)不可靠**:有时钩子压根不注册
|
||||
(插件扫描与会话创建有竞态),触发时也无条件在 ~5ms 内失败且命令从未被 spawn
|
||||
(用「钩子写 marker 文件」的副作用验证)。这是转向「我们自己的门禁」的直接原因;
|
||||
桌面模式下钩子仍然可用(已验:5/5)。
|
||||
- **`mode_enforcement` 靠自检得出**:驱动启动时先看我们那条门禁链是否就绪
|
||||
(`--mode yolo` + 禁用清单里真的有 `Bash`/`js`),就绪就报 `native` 并在日志里
|
||||
点明「谁在把关」;否则降级成 `advisory`。**不再拿「钩子已注册」当凭据** ——
|
||||
yolo 下钩子不会触发,那样等于替一个不存在的能力背书。
|
||||
- **闭源**:ZCode 是闭源客户端(deb 里 `License: unknown`),本插件的协议层
|
||||
(MCP 分帧、钩子 schema、headless 输出格式、`--mode` 判定规则)全是从其产物里
|
||||
实测逆出来的,版本升级可能破坏。
|
||||
(MCP 分帧、钩子 schema、headless 输出格式、`--mode`/`--disallowed-tools` 的语义)
|
||||
全是从其产物里实测逆出来的,版本升级可能破坏。
|
||||
- **
|
||||
`--allowed-tools` 在 help 里写着但不可用**:解析器报 `Unknown option`,
|
||||
退回 usage。所以「只放行只读自带工具 + 我们的工具」这种白名单姿态做不到,
|
||||
只能用黑名单。`buildRunArgs` 现在会为此直接抛错,而不是拼出一条跑不起来的命令。
|
||||
- **驱动与 MCP 服务器是两个进程**:模型自己发过信的记录、以及「一直同意」表,
|
||||
都靠落盘对齐(`lib/explicit-sends.mjs` / `lib/grants-file.mjs`)。
|
||||
两边解析出不同路径就会出现「刚点过一直同意又问你一遍」与「同一件事发两封信」。
|
||||
|
||||
336
plugins/zcode-mail-bridge/lib/action-tools.mjs
Normal file
336
plugins/zcode-mail-bridge/lib/action-tools.mjs
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 我们自己的「会动机器」的工具 —— 每一次执行都要先过人的批准。
|
||||
*
|
||||
* # 为什么要有这些工具
|
||||
*
|
||||
* headless(邮件驱动)模式下,ZCode 平台的授权询问**没有客户端可以问**:
|
||||
* 每个 MCP 工具的 `needsApproval` 在产物里是硬编码的 `true`,而引擎找不到
|
||||
* 审批客户端时直接判 deny(`permission.resolved: deny, "No permission client
|
||||
* configured for X"`)。我们试过让平台自己问人(PermissionRequest 钩子),
|
||||
* 在本版本(3.10.2 / CLI 0.16.5)**根本不可靠**:有时钩子压根不注册,
|
||||
* 触发时也无条件在 ~5ms 内失败、命令从未被 spawn(用「钩子写 marker 文件」
|
||||
* 的副作用验证过)。
|
||||
*
|
||||
* 所以换一条路:**让 ZCode 走 `--mode yolo`**(平台不再拦我们的工具),
|
||||
* 同时用 `--disallowed-tools` 把它自带的危险工具(Bash/Write/Edit/js/…)
|
||||
* 全部禁掉,只留只读的 Read/Glob/Grep。需要动手时,模型改用**我们这几个工具**,
|
||||
* 而门禁就在我们自己的代码里 —— 这也是唯一能真正落地的地方:
|
||||
* 我们能控制它的判据、日志与失败语义。
|
||||
*
|
||||
* # 安全边界(必须诚实地说清楚)
|
||||
*
|
||||
* `yolo` 意味着**平台不再有任何权限判定**。安全完全来自两件事:
|
||||
*
|
||||
* 1. `--disallowed-tools` 清单是否完整(见 src/turn-mode.mjs 的 REVIEWED_DENYLIST)。
|
||||
* 它是一张黑名单,漏掉一个能动机器的工具就等于开一个洞。
|
||||
* 2. 本文件的门禁。默认拒绝;只有「明确同意」才放行。
|
||||
*
|
||||
* # 规矩
|
||||
*
|
||||
* - 只读的工具不需要批准(读信、查地址)。需要批准的是**会改变机器状态的**:
|
||||
* 执行命令、写文件。
|
||||
* - 拒绝时**抛错**(MCP 层会把 `isError: true` 交给模型),而不是返回一句
|
||||
* 「已处理」。opencode 上「工具失败但报成功」导致模型连试 6 次、最后放弃
|
||||
* 整个任务的教训:失败必须让模型看见原因,它才有机会改道。
|
||||
* - 批准之前**不产生任何副作用**(不建文件、不建目录)。
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { requestApproval, tierOf, DEFAULT_WAIT_MS } from './approval.mjs';
|
||||
|
||||
/** 命令输出上限。整段回灌会挤掉模型真正需要的上下文(实测 1MB 的构建日志
|
||||
* 能把一轮对话直接顶爆),所以按字节截断并明确告知被截断了多少。 */
|
||||
const OUTPUT_LIMIT = 16000;
|
||||
|
||||
/**
|
||||
* 把一段文本包进 markdown 代码块。
|
||||
*
|
||||
* 不能直接写在模板字符串里 —— 三个反引号会把模板字符串**提前结束**,
|
||||
* 报出来的是「Invalid or unexpected token」而不是「你写错了引号」,
|
||||
* 一眼看不出是这里。用拼接就没有这个陷阱。
|
||||
*/
|
||||
const fenced = text => '```\n' + text + '\n```';
|
||||
|
||||
/** 单条命令的默认/最长超时。超时上限必须存在:没有它,一条 `sleep 1e9`
|
||||
* 会让这一轮永远跑不完,而驱动的回合超时到了就杀进程 —— 人会看到
|
||||
* 「处理失败」,却不知道只是有个命令没停。 */
|
||||
const DEFAULT_CMD_TIMEOUT_MS = 120000;
|
||||
const MAX_CMD_TIMEOUT_MS = 900000;
|
||||
|
||||
/**
|
||||
* 无论谁批准都不许写的路径。
|
||||
*
|
||||
* 这不是不信任人,而是**防自我强化**:邮件驱动的 Agent 可能被来信诱导去改
|
||||
* 平台的网关数据库、systemd 单元或它自己的插件代码,改完下一轮就换了一套
|
||||
* 规则,而人类看到的是一封看起来合理的申请。这类改动应当是人工部署动作,
|
||||
* 不该经授权流程走私进来。
|
||||
*/
|
||||
const PROTECTED_PREFIXES = [
|
||||
'/opt/agentmail/data', // 网关数据库(邮件、授权、附件)
|
||||
'/opt/agentmail/plugins', // 我们自己的插件代码
|
||||
'/etc/systemd', // 服务单元
|
||||
'/etc/agentmail', // 各桥的环境文件(含密钥)
|
||||
'/root/.agentmail-zcode', // 本 Agent 的凭据与会话状态
|
||||
'/root/.ssh'
|
||||
];
|
||||
|
||||
const str = (v, fallback = '') => (typeof v === 'string' ? v : fallback);
|
||||
|
||||
function clamp(text, limit = OUTPUT_LIMIT) {
|
||||
const s = String(text ?? '');
|
||||
if (s.length <= limit) return { text: s, truncated: 0 };
|
||||
// 留头部:报错通常出现在开头,而进度条/日志尾部噪音最多。
|
||||
return { text: s.slice(0, limit), truncated: s.length - limit };
|
||||
}
|
||||
|
||||
function protectedHit(p) {
|
||||
const abs = resolve(p);
|
||||
return PROTECTED_PREFIXES.find(prefix => abs === prefix || abs.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 等人工决策的上限,必须**明显小于** MCP 调用的超时。
|
||||
*
|
||||
* 这一条是实测出来的,而且它曾经以最难发现的方式失败:工具在等授权,
|
||||
* 人在界面上还没来得及反应,**客户端**先把这次工具调用掐了(默认 30 秒)。
|
||||
* 模型拿到的是一句「调用超时」,于是它在回信里写「30 秒内未获批准」——
|
||||
* 看起来像人没理它,实际是**门禁的等待窗口被截断了**,而且**看起来完全正常**。
|
||||
*
|
||||
* 所以这里不信任环境变量:它可能被配成一个比 MCP 超时还大的值。
|
||||
* 真正的上限是插件清单里 `mcpServers.agentmail.timeoutMs`(我们的工具就是它
|
||||
* 在调),而等待必须留出余量让门禁**自己**先 settle —— 被客户端杀掉时,
|
||||
* 我们连一条「等超时了」的理由都发不出去。
|
||||
*/
|
||||
const APPROVAL_MARGIN_MS = 30000;
|
||||
|
||||
/**
|
||||
* 从插件清单里读 MCP 服务器声明的 `timeoutMs`(本插件自己的清单)。
|
||||
* 读不到就返回 null —— 此时沿用环境变量,并在日志里说清楚没校到。
|
||||
*/
|
||||
export function resolveMcpTimeoutMs(manifestPath) {
|
||||
const file =
|
||||
manifestPath || fileURLToPath(new URL('../.zcode-plugin/plugin.json', import.meta.url));
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const t = cfg?.mcpServers?.agentmail?.timeoutMs;
|
||||
return Number.isFinite(t) && t > 0 ? t : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 算出实际要等多久。
|
||||
*
|
||||
* @returns {{waitMs:number, capped:boolean, mcpTimeoutMs:number|null}}
|
||||
* `capped` 为真时调用方应当写一条日志 —— 被夹小意味着
|
||||
* 「人能用来批准的时间比配置里写的少」,这件事必须能被看见。
|
||||
*/
|
||||
export function resolveWaitMs(env = process.env, mcpTimeoutMs = resolveMcpTimeoutMs()) {
|
||||
const want = Number(env.AGENTMAIL_PERMISSION_WAIT_MS) || DEFAULT_WAIT_MS;
|
||||
if (!mcpTimeoutMs) return { waitMs: want, capped: false, mcpTimeoutMs: null };
|
||||
const limit = mcpTimeoutMs - APPROVAL_MARGIN_MS;
|
||||
if (limit <= 0 || want <= limit) return { waitMs: want, capped: false, mcpTimeoutMs };
|
||||
return { waitMs: limit, capped: true, mcpTimeoutMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建这些工具。
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {any} opts.client 网关客户端
|
||||
* @param {object} [opts.env] 环境(默认 process.env;测试可注入)
|
||||
* @param {object} [opts.grants] 「一直同意」表(钩子与工具共用同一份落盘文件)
|
||||
* @param {Function} [opts.log]
|
||||
* @param {Function} [opts.createSSE] 仅测试用:注入假 SSE 以精确控制授权时序
|
||||
*/
|
||||
export function buildActionTools({ client, env = process.env, grants = null, log = () => {}, createSSE }) {
|
||||
const tier = tierOf(env);
|
||||
const sessionId = str(env.AGENTMAIL_SESSION_ID).trim();
|
||||
const workspace = str(env.AGENTMAIL_WORKSPACE_ROOT) || str(env.ZCODE_PROJECT_DIR) || process.cwd();
|
||||
const wait = resolveWaitMs(env);
|
||||
const waitMs = wait.waitMs;
|
||||
if (wait.capped) {
|
||||
log(
|
||||
`授权等待被夹到 ${Math.round(waitMs / 1000)}s:` +
|
||||
`MCP 调用超时只有 ${wait.mcpTimeoutMs}ms(清单里的 timeoutMs),` +
|
||||
`而配置想等 ${Math.round((Number(env.AGENTMAIL_PERMISSION_WAIT_MS) || DEFAULT_WAIT_MS) / 1000)}s。` +
|
||||
`不夹的话调用会先被杀掉,人会以为「没人批准」而不是「来不及」。`
|
||||
);
|
||||
}
|
||||
|
||||
/** 统一的问人入口:把「谁在问、问什么、上下文」凑好,交给共用模块。 */
|
||||
async function gate({ toolName, question, context }) {
|
||||
const r = await requestApproval({
|
||||
client,
|
||||
toolName,
|
||||
question,
|
||||
context,
|
||||
sessionId,
|
||||
waitMs,
|
||||
grants,
|
||||
tier,
|
||||
// 幂等键带上会话与工具:网关按它去重,同一个动作重复问不会刷屏。
|
||||
relayKeySeed: `zcode-tool:${sessionId || 'local'}:${toolName}`,
|
||||
log,
|
||||
createSSE
|
||||
});
|
||||
if (!r.allowed) {
|
||||
// 抛错而不是返回字符串:让模型看见 isError 与原因。
|
||||
throw new Error(`未获批准,未执行 ${toolName}。\n${r.reason}`);
|
||||
}
|
||||
log(`${toolName} 获批(${r.via}${r.decidedBy ? `, ${r.decidedBy}` : ''})`);
|
||||
return r;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'run_command',
|
||||
// 会改变机器状态 → destructiveHint:false 但非只读。平台在 yolo 下不再判定,
|
||||
// 但注解仍要如实填写:它决定别的档位/宿主下这个工具的可见性。
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
||||
description:
|
||||
'在本机执行一条 shell 命令并返回输出。' +
|
||||
'**这会先向发件人申请授权**(plan 档一律不允许,full 档免问,workspace 档现场问人)。' +
|
||||
'未获批准时本工具报错且不会执行任何东西。' +
|
||||
'命令在 bash 里运行;工作目录默认是本会话的工作区。' +
|
||||
`输出超过 ${OUTPUT_LIMIT} 字符会被截断(会标明截断了多少)。`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: '要执行的命令(经 bash -c 执行)' },
|
||||
cwd: { type: 'string', description: '工作目录,默认本会话工作区' },
|
||||
timeout_ms: {
|
||||
type: 'number',
|
||||
description: `超时毫秒(默认 ${DEFAULT_CMD_TIMEOUT_MS},上限 ${MAX_CMD_TIMEOUT_MS})`
|
||||
},
|
||||
purpose: {
|
||||
type: 'string',
|
||||
description: '为什么要执行它,一句话 —— 会展示给批准人看,请写具体'
|
||||
}
|
||||
},
|
||||
required: ['command']
|
||||
},
|
||||
async run(args) {
|
||||
const a = args && typeof args === 'object' ? args : {};
|
||||
const command = str(a.command).trim();
|
||||
if (!command) throw new Error('command 不能为空');
|
||||
const cwd = str(a.cwd) || workspace;
|
||||
const timeout = Math.min(
|
||||
Number.isFinite(a.timeout_ms) && a.timeout_ms > 0 ? a.timeout_ms : DEFAULT_CMD_TIMEOUT_MS,
|
||||
MAX_CMD_TIMEOUT_MS
|
||||
);
|
||||
const purpose = str(a.purpose).trim();
|
||||
|
||||
await gate({
|
||||
toolName: 'run_command',
|
||||
question:
|
||||
'Agent 请求执行一条命令:\n\n' +
|
||||
fenced(clamp(command, 2000).text) +
|
||||
`\n\n目录:${cwd}`,
|
||||
context: [
|
||||
purpose ? `用途:${purpose}` : '',
|
||||
'批准后该命令将在本机执行。拒绝后 Agent 会收到拒绝原因,可以改道。'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
});
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const { stdout, stderr } = await new Promise((res, rej) => {
|
||||
execFile(
|
||||
'/bin/bash',
|
||||
['-c', command],
|
||||
{ cwd, timeout, maxBuffer: 4 * 1024 * 1024, killSignal: 'SIGTERM' },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) return rej(Object.assign(err, { stdout, stderr }));
|
||||
res({ stdout, stderr });
|
||||
}
|
||||
);
|
||||
});
|
||||
return renderResult({ code: 0, stdout, stderr, ms: Date.now() - started });
|
||||
} catch (e) {
|
||||
// 非零退出码与超时都不是「工具坏了」——它们是命令的真实结果,
|
||||
// 必须原样告诉模型(它靠 stderr 判断下一步),所以这里不抛错。
|
||||
const o = clamp(e?.stdout);
|
||||
const er = clamp(e?.stderr);
|
||||
const timedOut = e?.killed || e?.signal === 'SIGTERM';
|
||||
const exit = typeof e?.code === 'number' ? e.code : e?.signal || '未知';
|
||||
return (
|
||||
`退出码:${exit}${timedOut ? `(超时被终止,上限 ${timeout}ms)` : ''}` +
|
||||
`耗时:${Date.now() - started}ms\n` +
|
||||
truncNote('stdout', o) +
|
||||
truncNote('stderr', er)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'write_file',
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
||||
description:
|
||||
'把一个文件写到本机(覆盖写,父目录会自动创建)。' +
|
||||
'**这会先向发件人申请授权**;未获批准时报错且不会创建任何文件或目录。' +
|
||||
'平台自身的目录(部署、数据库、服务配置)无论谁批准都拒绝写入。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '绝对路径,或相对工作区的路径' },
|
||||
content: { type: 'string', description: '文件内容(UTF-8)' },
|
||||
purpose: { type: 'string', description: '为什么要写它,一句话,会展示给批准人看' }
|
||||
},
|
||||
required: ['path', 'content']
|
||||
},
|
||||
async run(args) {
|
||||
const a = args && typeof args === 'object' ? args : {};
|
||||
const raw = str(a.path).trim();
|
||||
if (!raw) throw new Error('path 不能为空');
|
||||
if (typeof a.content !== 'string') throw new Error('content 必须是字符串');
|
||||
const target = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
|
||||
|
||||
// 保护路径在我们的门禁**之前**判定:即使有人点了同意也不放行,
|
||||
// 因为这类改动不该走授权流程(见 PROTECTED_PREFIXES 的注释)。
|
||||
const hit = protectedHit(target);
|
||||
if (hit) {
|
||||
throw new Error(
|
||||
`拒绝写入 ${target}:它在平台保护目录 ${hit} 之下。` +
|
||||
`这类改动必须由人工部署完成。请把要写的内容放进回信,或改写到工作区内。`
|
||||
);
|
||||
}
|
||||
|
||||
await gate({
|
||||
toolName: 'write_file',
|
||||
question: `Agent 请求写入文件:\n\n${target}\n\n内容 ${a.content.length} 字符`,
|
||||
context: [
|
||||
str(a.purpose).trim() ? `用途:${str(a.purpose).trim()}` : '',
|
||||
'内容预览(前 800 字符):',
|
||||
clamp(a.content, 800).text
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
});
|
||||
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, a.content, 'utf8');
|
||||
return `已写入 ${target}(${Buffer.byteLength(a.content, 'utf8')} 字节)。`;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function truncNote(label, { text, truncated }) {
|
||||
const tail = truncated ? `\n[${label} 被截断,省略 ${truncated} 字符]` : '';
|
||||
const body = text === '' ? `(${label} 为空)` : text;
|
||||
return `${label}:\n${body}${tail}\n`;
|
||||
}
|
||||
|
||||
function renderResult({ code, stdout, stderr, ms }) {
|
||||
const o = clamp(stdout);
|
||||
const e = clamp(stderr);
|
||||
return `退出码:${code}\n耗时:${ms}ms\n` + truncNote('stdout', o) + truncNote('stderr', e);
|
||||
}
|
||||
300
plugins/zcode-mail-bridge/lib/approval.mjs
Normal file
300
plugins/zcode-mail-bridge/lib/approval.mjs
Normal file
@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 授权往返:把一次「要不要执行这个动作」的询问发给人类,等他的决定。
|
||||
*
|
||||
* # 为什么必须是独立模块
|
||||
*
|
||||
* 它现在有两个调用方,而且两者的失败后果完全不同:
|
||||
*
|
||||
* - `hooks/permission.mjs`:ZCode 桌面(交互)模式下的 PermissionRequest 钩子
|
||||
* - `lib/action-tools.mjs`:headless 模式下我们自己的执行工具(run_command 等)
|
||||
*
|
||||
* 两份实现迟早会漂移,而漂移的地方恰恰是最不该出错的判定:「什么算同意」
|
||||
* 「永久失败要不要 fail closed」「超时算不算拒绝」。所以判定复用共用库的
|
||||
* `isApproval` / `isAlwaysDecision` / `isPermanentFailure`,流程只有这一份。
|
||||
*
|
||||
* # 三条不可动摇的规矩
|
||||
*
|
||||
* 1. **只有明确同意才放行**(共用库的 `isApproval`)。注意它实际的判据是
|
||||
* **前缀匹配** `/^(同意|一直同意|allow|approve|always|yes)/i`(四个桥共用同一份,
|
||||
* 所以这里不能另立一套)。前缀里的东西(如「同意吧」)算同意,
|
||||
* 而看不懂的文本、空串、`拒绝`、`deny`、平台自己的 `shutdown` 哨兵一律当拒绝 ——
|
||||
* 判据是「在放行白名单里」,不是「不等于拒绝」。
|
||||
* 2. **永久失败当场拒绝**(409 无人可问、4xx 参数/权限错)。它们不会因为重试
|
||||
* 而改变,重试只会把「权限系统坏了」这件事藏起来。
|
||||
* 3. **暂时失败看有没有本地界面**:有(桌面模式)就退回平台自己的流程;
|
||||
* 没有(headless 邮件驱动)必须拒绝 —— 退回等于守卫消失。
|
||||
* 判据用的是调用方传进来的 `sessionId`(会话由邮件驱动 = 没有界面),
|
||||
* **不再另读 `AGENTMAIL_SESSION_ID`**:两个事实来源迟早会不一致,
|
||||
* 而它们不一致时到底算有界面还是没界面,谁都说不清。
|
||||
*
|
||||
* # 为什么自己开 SSE
|
||||
*
|
||||
* 网关的 SSE 是**扇出**的(`clients` 按唯一 id 存,`SendToAgent` 推给该 Agent
|
||||
* 的所有客户端),所以一个短命的钩子进程或一次工具调用都能自己订阅、拿到
|
||||
* 自己那条决定、然后退出。先建连再发请求 —— 反过来会有一个窗口:人恰好在
|
||||
* 窗口内点了同意,而事件推给了当时还不存在的客户端,表现为「明明点了同意
|
||||
* 却被拒」。
|
||||
*/
|
||||
|
||||
import { createSSEClient } from './sse-client.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { clampRelayKey, isPermanentFailure } from './relay-key.js';
|
||||
import { isApproval, isAlwaysDecision } from './permission-grants.js';
|
||||
import { normalizeMode, DEFAULT_MODE, MODE_FULL, MODE_PLAN } from './permission-mode.js';
|
||||
/** 等待人工决策的默认上限。调用方应保证它**明显小于**自己的杀进程上限,
|
||||
* 否则会在正要给出结论的瞬间被杀掉,而「不表态」与「来不及答」就分不开了。 */
|
||||
export const DEFAULT_WAIT_MS = 540000;
|
||||
|
||||
/** 当前档位(来自驱动注入的环境变量)。 */
|
||||
export function tierOf(env = process.env) {
|
||||
return normalizeMode(env.AGENTMAIL_PERMISSION_MODE) || DEFAULT_MODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「有没有本地界面可以让人就地决定」。
|
||||
*
|
||||
* 判据是驱动有没有注入会话 id:邮件驱动的会话由驱动起、没有界面;
|
||||
* 人自己开着 ZCode 时有界面。这个区分决定了暂时失败该 fail closed 还是让位。
|
||||
*/
|
||||
export function hasLocalUi(env = process.env) {
|
||||
return !String(env.AGENTMAIL_SESSION_ID || '').trim();
|
||||
}
|
||||
|
||||
/** 等 SSE 建连完成(服务端在 AddClient 时立刻下发一个 connected 事件)。 */
|
||||
function waitConnected(state, timeoutMs = 5000, log = () => {}) {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
log('SSE 建连等待超时,仍然继续(可能错过极早到达的决策)');
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
state.onConnected = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等键必须**每次调用都不同**。
|
||||
*
|
||||
* 这里踩过一个真坑,而且失败方式极隐蔽:键取成 `会话 + 工具` 之后,
|
||||
* 同一个会话里**第二次** `run_command` 就是个「重复请求」——网关按设计
|
||||
* 返回 HTTP 200 `{status:"duplicate_relay", detail:"该权限询问已转发过,本次调用未产生新邮件"}`
|
||||
* 并且**提前返回**:不建请求、不发邮件、永远不会有人来决策。
|
||||
*
|
||||
* 于是工具干等(实测被 MCP 的 30 秒调用超时砍掉),模型回报
|
||||
* 「30 秒内未获批准」—— 看上去像人没理它,实际是**请求根本没出去**。
|
||||
* 而 HTTP 还全是 200,从状态码上看不出任何异常。
|
||||
*
|
||||
* 所以键的语义是「**这一次调用**」(一次工具调用 = 一次询问),不是「这个会话的这个工具」。
|
||||
* 重复请求的去重需求由「一直同意」表承担(那张表是按 会话+工具 生效的,那是对的语义)。
|
||||
*/
|
||||
export function relayKeyForCall({ seed, sessionId, toolName, nonce }) {
|
||||
const head = seed || `${sessionId || 'zcode'}:${toolName}`;
|
||||
const tail = nonce || randomUUID().slice(0, 8);
|
||||
return clampRelayKey(`${head}:${tail}`);
|
||||
}
|
||||
|
||||
/** 网关在幂等命中时的回包形状(实测):建请求被跳过,不会有任何人来决策。 */
|
||||
export function isDuplicateRelay(res) {
|
||||
return Boolean(res && typeof res === 'object' && res.status === 'duplicate_relay');
|
||||
}
|
||||
|
||||
/**
|
||||
* 询问人类。
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {any} opts.client 网关客户端(要 authHeaders / post / baseURL)
|
||||
* @param {string} opts.toolName 工具名(同时用作「一直同意」的授权粒度)
|
||||
* @param {string} opts.question 给人看的问题
|
||||
* @param {string} opts.context 给人看的上下文(命令内容/文件路径等)
|
||||
* @param {string} [opts.sessionId] AgentMail 会话 id
|
||||
* @param {string} [opts.relayKeySeed] 幂等键前缀(默认 session:tool);每次调用会**追加一个随机尾**,
|
||||
* 见 relayKeyForCall 的注释
|
||||
* @param {string} [opts.nonce] 仅测试用:固定随机尾以便断言
|
||||
* @param {object} opts.grants createFileGrantStore 的实例(可省)
|
||||
* @param {string} [opts.tier] 档位(默认从环境读)
|
||||
* @param {number} [opts.waitMs]
|
||||
* @param {Function} [opts.log]
|
||||
* @param {Function} [opts.createSSE] 供测试注入
|
||||
* @returns {Promise<{allowed:boolean, reason:string, decidedBy:string, via:string}>}
|
||||
* via 说明结论来自哪一步:tier / grant / human / permanent-failure /
|
||||
* timeout / transport —— 日志与回信要能看出「当时凭什么放行」。
|
||||
*/
|
||||
export async function requestApproval(opts) {
|
||||
const {
|
||||
client,
|
||||
toolName,
|
||||
question,
|
||||
context = '',
|
||||
sessionId = '',
|
||||
relayKeySeed,
|
||||
nonce,
|
||||
grants = null,
|
||||
tier = tierOf(),
|
||||
waitMs = DEFAULT_WAIT_MS,
|
||||
log = () => {},
|
||||
createSSE = createSSEClient
|
||||
} = opts;
|
||||
|
||||
// ① 档位:plan 档只允许读与查,没什么可问人的(该档语义就是「不动手」)。
|
||||
if (tier === MODE_PLAN) {
|
||||
return {
|
||||
allowed: false,
|
||||
via: 'tier',
|
||||
decidedBy: '',
|
||||
reason:
|
||||
`plan 档下不允许执行 ${toolName}。本档只允许读与查,请把方案写在回信里。` +
|
||||
`如需动手请让发件人把档位改成 workspace。`
|
||||
};
|
||||
}
|
||||
|
||||
// ② full 档:发件人已声明全权。这一档的核心语义就是免掉询问。
|
||||
if (tier === MODE_FULL) {
|
||||
return { allowed: true, via: 'tier', decidedBy: '', reason: `${tier} 档:全权,无需询问` };
|
||||
}
|
||||
|
||||
const scope = sessionId || '';
|
||||
// ③ 「一直同意」:钩子是短命进程,所以这张表由文件承载(见 grants-file.mjs)。
|
||||
if (grants && grants.isGranted(scope, toolName)) {
|
||||
return { allowed: true, via: 'grant', decidedBy: '', reason: `本会话的 ${toolName} 已获「一直同意」` };
|
||||
}
|
||||
|
||||
const relayKey = relayKeyForCall({
|
||||
seed: relayKeySeed,
|
||||
sessionId: scope,
|
||||
toolName,
|
||||
nonce
|
||||
});
|
||||
|
||||
// 有没有本地界面:由**调用方给的会话 id** 判定(单一事实来源)。
|
||||
// 邮件驱动的会话一定带 sessionId;人自己开着 ZCode 时没有。
|
||||
const mailDriven = scope.trim() !== '';
|
||||
|
||||
// ④ 先订阅再发请求(顺序不能反,见文件头注释)。
|
||||
const state = { onConnected: null };
|
||||
let waiter = null;
|
||||
const early = [];
|
||||
const sse = createSSE({
|
||||
authHeaders: () => client.authHeaders(),
|
||||
baseURL: client.baseURL,
|
||||
path: '/api/v1/events/stream',
|
||||
log,
|
||||
onEvent: (evt, data) => {
|
||||
if (evt === 'connected' && state.onConnected) state.onConnected();
|
||||
if (evt !== 'permission_decision') return;
|
||||
// 只认自己那条:同一 Agent 可能同时有多个调用在等(模型并行发起两个动作),
|
||||
// 按 relay_key 配对才不会互相拿到对方的决定。
|
||||
if (data?.relay_key && data.relay_key !== relayKey) return;
|
||||
if (waiter) {
|
||||
const w = waiter;
|
||||
waiter = null;
|
||||
w(data);
|
||||
} else {
|
||||
early.push(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await waitConnected(state, 5000, log);
|
||||
|
||||
try {
|
||||
const accepted = await client.post('/permission/request', {
|
||||
question,
|
||||
options: ['同意', '一直同意', '拒绝'],
|
||||
context,
|
||||
session_id: scope,
|
||||
relay_key: relayKey
|
||||
});
|
||||
// 幂等命中 = 请求**没有**发出去,永远不会有决策事件。
|
||||
// 不把它当成失败的话,调用方会一直等到被客户端杀掉,而错误信息是
|
||||
// 「没有人批准」—— 归因完全错了。所以当场以可读的原因拒绝。
|
||||
if (isDuplicateRelay(accepted)) {
|
||||
log(`授权询问被网关判为重复(relay_key=${relayKey}),本次没有产生新请求`);
|
||||
return {
|
||||
allowed: false,
|
||||
via: 'duplicate-relay',
|
||||
decidedBy: '',
|
||||
reason:
|
||||
`授权请求被网关当作重复请求丢弃了(${accepted.detail || 'duplicate_relay'})。` +
|
||||
`这意味着**没有人会看到这次询问**,因此不放行。` +
|
||||
`请重新发起(键每次调用都不同),或改用不需要授权的方式。`
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// 永久失败(409 无人可问 / 4xx)不会因重试而改变 → 当场拒绝,
|
||||
// 让调用方从错误里看到原因并自己改道(挂死时连重试机会都没有)。
|
||||
if (isPermanentFailure(e)) {
|
||||
const b = e?.body && typeof e.body === 'object' ? e.body : {};
|
||||
const reason = [b.error || `权限询问无法送达(HTTP ${e?.status})`, b.detail || '', b.suggestion || '']
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
log(`权限询问永久失败,当场拒绝 ${relayKey}:${reason.split('\n')[0]}`);
|
||||
return { allowed: false, via: 'permanent-failure', decidedBy: '', reason };
|
||||
}
|
||||
const detail = e?.message || String(e);
|
||||
log(`权限询问暂时失败:${detail}`);
|
||||
if (mailDriven) {
|
||||
// 邮件驱动:没有本地界面兜底,退回本地决策等于守卫消失。
|
||||
return {
|
||||
allowed: false,
|
||||
via: 'transport',
|
||||
decidedBy: '',
|
||||
reason:
|
||||
`无法把 ${toolName} 的授权请求送达给人(${detail})。` +
|
||||
`这条会话由邮件驱动、没有本地界面,因此不放行。` +
|
||||
`请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。`
|
||||
};
|
||||
}
|
||||
return { allowed: false, via: 'transport', decidedBy: '', reason: `授权询问失败:${detail}` };
|
||||
}
|
||||
|
||||
const decision =
|
||||
early.shift() ??
|
||||
(await new Promise(resolve => {
|
||||
waiter = resolve;
|
||||
setTimeout(() => {
|
||||
if (waiter !== resolve) return;
|
||||
waiter = null;
|
||||
resolve(null);
|
||||
}, waitMs);
|
||||
}));
|
||||
|
||||
if (decision === null) {
|
||||
return {
|
||||
allowed: false,
|
||||
via: 'timeout',
|
||||
decidedBy: '',
|
||||
reason: `等待授权超时(${Math.round(waitMs / 1000)} 秒内没有人决策),未执行 ${toolName}。`
|
||||
};
|
||||
}
|
||||
|
||||
const text = decision.decision ?? '';
|
||||
if (isApproval(text)) {
|
||||
if (isAlwaysDecision(text) && grants?.grant(scope, toolName, text)) {
|
||||
log(`记下「一直同意」:会话 ${scope} 的 ${toolName} 后续免批`);
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
via: 'human',
|
||||
decidedBy: decision.decided_by || '',
|
||||
reason: `获批(${text})`
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
via: 'human',
|
||||
decidedBy: decision.decided_by || '',
|
||||
reason: [
|
||||
`用户拒绝了这次 ${toolName} 调用。`,
|
||||
decision.note ? `说明:${decision.note}` : '',
|
||||
decision.decided_by ? `(由 ${decision.decided_by} 决定)` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
};
|
||||
} finally {
|
||||
sse.stop();
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,8 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
import { GatewayClient } from '../lib/gateway.mjs';
|
||||
import { buildTools, indexTools } from '../lib/tools.mjs';
|
||||
import { buildActionTools } from '../lib/action-tools.mjs';
|
||||
import { createFileGrantStore, grantsFilePath } from '../lib/grants-file.mjs';
|
||||
import { handleLine, SERVER_NAME, SERVER_VERSION } from '../lib/mcp-rpc.mjs';
|
||||
import { isMainModule } from '../lib/is-main.mjs';
|
||||
|
||||
@ -36,6 +38,14 @@ export async function main() {
|
||||
const agentName = client.agentName;
|
||||
|
||||
const tools = buildTools({ client, agentName });
|
||||
|
||||
// 「会动机器」的工具(run_command / write_file)单独一组:它们的门禁在
|
||||
// lib/action-tools.mjs 里,且与钩子共用同一张落盘授权表(文件承载,
|
||||
// 因为 MCP 服务器与钩子是**两个进程**:桌面模式下人在 ZCode 里点「一直同意」,
|
||||
// 要能被我们的工具看见)。
|
||||
const grants = createFileGrantStore(grantsFilePath(process.env));
|
||||
tools.push(...buildActionTools({ client, grants, log }));
|
||||
|
||||
const byName = indexTools(tools);
|
||||
|
||||
const ctx = {
|
||||
|
||||
@ -43,7 +43,7 @@ import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
|
||||
import { normalizeMode } from '../lib/permission-mode.js';
|
||||
import { clampRelayKey } from '../lib/relay-key.js';
|
||||
import { explicitSendsFile, readExplicitSends } from '../lib/explicit-sends.mjs';
|
||||
import { zcodeModeForTier, modeReachesPermissionHook, describeTier } from './turn-mode.mjs';
|
||||
import { zcodeModeForTier, denylistForTier, modeReachesPermissionHook, describeTier } from './turn-mode.mjs';
|
||||
import { buildMailPrompt, replySubject, renderTurnFailure } from './prompt.mjs';
|
||||
import { runTurn, DEFAULT_CLI } from './zcode-run.mjs';
|
||||
import { isMainModule } from '../lib/is-main.mjs';
|
||||
@ -77,26 +77,57 @@ export function zcodeSessionFallback(sessionKey, rootOverride) {
|
||||
/**
|
||||
* 自报给网关的强制力。
|
||||
*
|
||||
* **只声明得出来的事**:ZCode 上的档位强制全部靠插件里的 PermissionRequest 钩子,
|
||||
* 钩子没被注册(插件没启用 / 被禁 / 清单被改坏)时我们什么也拦不住,
|
||||
* 那时还报 native 就是在替一个不存在的能力背书 —— 而这个声明的用途正是
|
||||
* 让人相信「这一档在这里是被强制的」。
|
||||
* **只声明得出来的事**。而且要分开两件事,它们以前被当成了同一件:
|
||||
*
|
||||
* 查的是插件自己的 `hooks/hooks.json`(驱动就住在这个插件里),
|
||||
* 不需要额外的配置项。
|
||||
* 1. **平台自己的权限引擎**(PermissionRequest 钩子)—— 只在 `--mode build/edit`
|
||||
* 下才会问。现在档位映射把 workspace 映到 `yolo`(平台恒 allow、钩子根本不会触发),
|
||||
* 所以**拿「钩子已注册」当强制力证据是错的**。桌面(有界面)时它仍然有用,
|
||||
* 但 headless 驱动这条路上不是它。
|
||||
* 2. **我们自己的门禁**(`lib/action-tools.mjs`)—— `yolo` + `--disallowed-tools`
|
||||
* 之后,能动机器的只剩我们两个工具,而它们每次都要过 `requestApproval`。
|
||||
* 这个才是这条路上真正拦得住东西的那一层。
|
||||
*
|
||||
* 于是判据改成:先看我们自己那条链能不能真的拦住(模块在不在 + 禁用清单够不够),
|
||||
* 再说平台那一层是否还参与。报 native 的含义是「该档位真的被强制,而非口头约定」。
|
||||
*
|
||||
* 查的是驱动自己的文件(它住在插件里),不需要额外配置项。
|
||||
*/
|
||||
export function detectModeEnforcement({ hooksFile } = {}) {
|
||||
export function detectModeEnforcement({ hooksFile, env = process.env } = {}) {
|
||||
const file = hooksFile || fileURLToPath(new URL('../hooks/hooks.json', import.meta.url));
|
||||
|
||||
// 我们自己那条链:必须是 yolo(平台不问)+ 一张够长的禁用清单。
|
||||
const mode = zcodeModeForTier('workspace', env);
|
||||
const denied = denylistForTier('workspace', env);
|
||||
const gateReady = mode === 'yolo' && denied.includes('Bash') && denied.includes('js');
|
||||
|
||||
// 平台那条链:钩子清单里真的注册了 PermissionRequest(桌面模式下才用得上)。
|
||||
let hookRegistered = false;
|
||||
let hookNote;
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(file, 'utf8'));
|
||||
const entries = cfg?.hooks?.PermissionRequest;
|
||||
if (Array.isArray(entries) && entries.some(e => Array.isArray(e?.hooks) && e.hooks.length > 0)) {
|
||||
return { enforcement: 'native', reason: `钩子已注册(${file})` };
|
||||
}
|
||||
return { enforcement: 'advisory', reason: `钩子清单里没有 PermissionRequest(${file})` };
|
||||
hookRegistered = Array.isArray(entries) && entries.some(e => Array.isArray(e?.hooks) && e.hooks.length > 0);
|
||||
hookNote = hookRegistered ? `钩子已注册(${file})` : `钩子清单里没有 PermissionRequest(${file})`;
|
||||
} catch (e) {
|
||||
return { enforcement: 'advisory', reason: `读不到钩子清单(${file}):${e?.message || e}` };
|
||||
hookNote = `读不到钩子清单(${file}):${e?.message || e}`;
|
||||
}
|
||||
|
||||
if (gateReady) {
|
||||
return {
|
||||
enforcement: 'native',
|
||||
reason:
|
||||
`执行类动作只能经我们自己的门禁(run_command / write_file 逐次请示),` +
|
||||
`平台自带危险工具已禁用 ${denied.length} 项(--mode ${mode})` +
|
||||
(hookRegistered ? ';桌面模式另有平台钩子' : '')
|
||||
};
|
||||
}
|
||||
if (hookRegistered && modeReachesPermissionHook(mode)) {
|
||||
return { enforcement: 'native', reason: hookNote };
|
||||
}
|
||||
return {
|
||||
enforcement: 'advisory',
|
||||
reason: hookRegistered ? `钩子已注册但不参与(--mode ${mode})` : `${hookNote},且门禁自检未通过`
|
||||
};
|
||||
}
|
||||
|
||||
/** 描述错误:把「网关可达但返回 4xx」与「连不上」分开 —— 两者的应对完全不同。 */
|
||||
@ -195,15 +226,18 @@ export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = p
|
||||
const sessionId = data?.session_id || '';
|
||||
const tier = normalizeMode(data?.permission_mode);
|
||||
const mode = zcodeModeForTier(tier);
|
||||
// 危险的自带工具一律拿掉(三个档位同一张审过的清单);需要动手时,
|
||||
// 模型改用我们自己的 run_command / write_file,门禁就在那里面。
|
||||
const disallowedTools = denylistForTier(tier);
|
||||
const cwd = resolveCwd(data);
|
||||
const prev = sessions.get(sessionId);
|
||||
const resume = prev?.zcodeSessionId || '';
|
||||
|
||||
|
||||
logFn(`处理 ${data.mail_id}|${describeTier(tier, mode)}|cwd=${cwd}${resume ? `|续会话 ${resume}` : ''}`);
|
||||
if (!modeReachesPermissionHook(mode) && tier !== 'plan') {
|
||||
// 只有 full 档会走到这里,且是刻意的。写日志是因为「没有权限询问」
|
||||
// 在 yolo 下是预期行为,在 build 下则是缺陷 —— 两者必须能区分。
|
||||
logFn(`注意:--mode ${mode} 不会产生权限询问(本档如此设计)`);
|
||||
logFn(`已禁用 ${disallowedTools.length} 个自带工具(Bash/Write/Edit/js/…),执行类动作走 AgentMail 门禁`);
|
||||
if (modeReachesPermissionHook(mode)) {
|
||||
// 平台会问、我们的钩子会转达 —— 说明档位映射被配置改回了 build/edit。
|
||||
logFn(`注意:--mode ${mode} 下平台自带工具会产生权限询问(映射被覆盖过?)`);
|
||||
}
|
||||
|
||||
const prompt = buildMailPrompt({ agentName: CFG.agentName, data });
|
||||
@ -215,6 +249,7 @@ export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = p
|
||||
mode,
|
||||
maxTurns: CFG.maxTurns,
|
||||
resumeSessionId: resume || undefined,
|
||||
disallowedTools,
|
||||
turnTimeoutMs: CFG.turnTimeoutMs,
|
||||
cliPath: CFG.cliPath,
|
||||
// 注入给 ZCode 进程(→ 继承给插件、钩子、MCP 服务器):
|
||||
|
||||
@ -16,6 +16,54 @@
|
||||
*/
|
||||
|
||||
import { inboundHeadline, replyInstruction } from '../lib/relay-policy.js';
|
||||
import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permission-mode.js';
|
||||
|
||||
/**
|
||||
* 告诉模型它的执行能力到底长什么样。
|
||||
*
|
||||
* 不说清楚会发生两件都很糟的事:模型去调 `Bash` 然后拿到一个看不懂的
|
||||
* 「工具不存在」(于是它反复换名字试,浪费整轮),或者它以为自己不能动手,
|
||||
* 把本来能做完的活写成「建议你自己执行」。
|
||||
*
|
||||
* 同时要把**授权这件事本身**讲明白:`run_command`/`write_file` 会先向发件人
|
||||
* 申请,被拒时工具会报错并给出原因。模型必须知道「被拒」是一个正常的、
|
||||
* 需要它改道的结果,而不是重试同一个动作的理由。
|
||||
*/
|
||||
export function capabilityNote(tier) {
|
||||
const t = normalizeMode(tier) || DEFAULT_MODE;
|
||||
const head =
|
||||
'## 你在这个平台上的执行能力\n\n' +
|
||||
'ZCode 自带的 Bash / Write / Edit / js 等工具**已被禁用**(不是故障,是本平台的策略:' +
|
||||
'邮件驱动的会话里没有本地界面可以给人审批,所以平台不做权限判定,改由我们自己的门禁负责)。';
|
||||
|
||||
if (t === MODE_PLAN) {
|
||||
return (
|
||||
head +
|
||||
'\n\n当前是 **plan 档**:只读。你**不能**执行命令或写文件(`run_command`/`write_file` 在' +
|
||||
'本档一律拒绝,不必尝试)。请把需要动手的步骤写进回信,说明「执行什么、为什么」。'
|
||||
);
|
||||
}
|
||||
|
||||
const gate =
|
||||
t === MODE_FULL
|
||||
? '当前是 **full 档**:发件人已声明全权,你的执行类调用**直接生效,不会打扰任何人**。'
|
||||
: '当前是 **workspace 档**:每类执行动作**第一次调用会先向发件人申请授权**' +
|
||||
'(他可以在界面上选「同意」「一直同意」或「拒绝」)。';
|
||||
|
||||
return (
|
||||
head +
|
||||
'\n\n需要动手时请改用这两个工具:\n' +
|
||||
'- `run_command`:执行一条 shell 命令(工作目录默认是本会话工作区)\n' +
|
||||
'- `write_file`:写一个文件(父目录自动创建;平台自身的部署/数据库/服务配置目录' +
|
||||
'无论谁批准都拒绝写入)\n' +
|
||||
'\n' +
|
||||
gate +
|
||||
'\n\n被拒绝时工具会**报错并给出原因**(含决策人)。这是正常的业务结果,不是故障:' +
|
||||
'不要重试同一个动作、也不要绕道去找其它执行手段,而应当在回信里说明' +
|
||||
'「哪一步被拒了、为什么需要它、希望对方怎么做」。\n' +
|
||||
'读写与搜索用自带的 Read / Glob / Grep(这些只读工具始终可用)。'
|
||||
);
|
||||
}
|
||||
|
||||
/** 去掉已有的 Re:/答复前缀,避免 `Re: Re: Re: …` 越滚越长。 */
|
||||
export function replySubject(subject) {
|
||||
@ -103,7 +151,9 @@ export function buildMailPrompt({ agentName, data, kind = 'mail', reused = false
|
||||
'',
|
||||
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
|
||||
'然后处理其中的请求。',
|
||||
...replyInstruction({ fromHuman, replyAddress: data?.reply_address })
|
||||
...replyInstruction({ fromHuman, replyAddress: data?.reply_address }),
|
||||
'',
|
||||
capabilityNote(data?.permission_mode)
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@ -1,31 +1,34 @@
|
||||
/**
|
||||
* AgentMail 的档位 → ZCode `--mode` 的映射。
|
||||
* AgentMail 的档位 → ZCode 的 `--mode` + `--disallowed-tools`。
|
||||
*
|
||||
* # 为什么这个映射必须存在,而且不能想当然
|
||||
* # 背景:平台的授权询问在 headless 下无法落地
|
||||
*
|
||||
* ZCode 的权限判定里有一条:
|
||||
* ZCode 的 MCP 工具 `needsApproval` 在产物里是**硬编码 `true`**(`Ari()` 里
|
||||
* `let d=!0`,与 `annotations` 无关)。而 `build`/`edit` 档的判定最后一条是
|
||||
* 「需要审批 → ask」,headless 没有审批客户端可问 ⇒ **每个 MCP 工具全被拒**
|
||||
* (`permission.resolved: deny, "No permission client configured for X"`),
|
||||
* 模型连 `read_inbox` 都调不动。
|
||||
*
|
||||
* t.mode === "yolo" ? this.allow(t, i, "mode.yolo", "Yolo mode bypasses permission prompts")
|
||||
* 我们本来打算让平台把询问转给我们(`PermissionRequest` 钩子),在本版本
|
||||
* (ZCode 3.10.2 / CLI 0.16.5)**做不到**:钩子有时根本不注册,触发时也无条件
|
||||
* 在 ~5ms 内失败、命令从未被 spawn(用「钩子写 marker 文件」的副作用验证过)。
|
||||
* 详见 README 的「已知缺口」。
|
||||
*
|
||||
* 也就是 **`yolo` 会绕过全部权限询问**,PermissionRequest 钩子根本不会触发 ——
|
||||
* 我们的授权桥会**静默消失**(不是报错,是没有询问,看起来一切正常)。
|
||||
* # 采用的姿态:平台让开,门禁由我们自己拿
|
||||
*
|
||||
* 而 `--prompt` 的**默认 mode 就是 `yolo`**(`--mode` 的 help 写着
|
||||
* "default: yolo for --prompt")。所以驱动若图省事不传 `--mode`,
|
||||
* 人就会以为「授权系统在管事」,实际每一条命令都已经自动放行了。
|
||||
* --mode yolo 平台不再做任何权限判定
|
||||
* --disallowed-tools <清单> 把它自带的一切「能动机器」的工具全部拿掉
|
||||
* (我们的 run_command / write_file 在 lib/action-tools.mjs 里自己问人)
|
||||
*
|
||||
* 反过来也不能一律传 `build`:档位的意义就是三种不同的行为。
|
||||
* 于是安全边界只在两处:**本文件那张审过的清单**,以及我们自己的门禁。
|
||||
* 平台不再提供第二道防线 —— 这是这个姿态必须在 README 里说清楚的代价。
|
||||
*
|
||||
* # 依据(从 CLI 产物里读出的规则表,不是猜)
|
||||
* # 为什么不能反过来用白名单
|
||||
*
|
||||
* - `checkBuildMode`:只读放行;critical/high 风险 → **ask**;
|
||||
* 有副作用 / 需要审批 → **ask**。`Bash` 属 destructive(high)→ ask;
|
||||
* `Write`/`Edit` 有 workspace 副作用 → ask。
|
||||
* - `checkEditMode`:`permissionName === "edit"` 的工作区文件编辑放行,其余退回 build。
|
||||
* - plan:`mode.plan.nonReadOnly` → 非只读一律**拒**。
|
||||
* - yolo:一律放行。
|
||||
*
|
||||
* 于是映射为:plan → plan,workspace → build,full → yolo。
|
||||
* CLI 的 `--allowed-tools` 在 help 里写着,但**解析器不认**
|
||||
* (`Unknown option '--allowed-tools'`)。实测过一次误判:先看到「文件没被创建」
|
||||
* 就以为白名单生效,其实进程只是没跑起来。所以这里只能用黑名单,
|
||||
* 而黑名单的完整性必须靠**穷举工具名**来保证(见 REVIEWED_DENYLIST)。
|
||||
*/
|
||||
|
||||
import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permission-mode.js';
|
||||
@ -33,53 +36,116 @@ import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permis
|
||||
/** ZCode 认识的 headless mode(`normalizePromptMode` 只接受这四个)。 */
|
||||
export const ZCODE_MODES = ['build', 'edit', 'plan', 'yolo'];
|
||||
|
||||
/**
|
||||
* 审过的禁用清单。
|
||||
*
|
||||
* # 依据
|
||||
*
|
||||
* 名单来自 CLI 产物里**模型可见工具名的权威注册表**(`aIn` 那个 28 项数组,
|
||||
* 含 `ApplyPatch`/`Bash`/`js`/`mcp__node_repl__js` 等),并与另一处更宽的候选集
|
||||
* 取并集。不采信模型的自述 —— 实测让模型「列出可用工具」时,它给的是它以为的
|
||||
* 清单,而基线里它**用某个没点名的方式真的创建了文件**。
|
||||
*
|
||||
* # 分类(每一类都必须能说出「不拿掉会怎样」)
|
||||
*
|
||||
* - **机器改动**:`Bash` 任意命令;`Write`/`Edit`/`ApplyPatch`/`NotebookEdit` 写文件;
|
||||
* `LSP`(rename 会应用工作区编辑);`EnterWorktree`/`ExitWorktree` 改仓库。
|
||||
* - **等价于 Bash 的执行通道**:`js` 及其 MCP 别名 —— 产物里它自己的描述就是
|
||||
* 「Node REPL can run arbitrary JavaScript with full Node privileges
|
||||
* (require/process), like Bash」,`riskLevel: "high"`。这是最容易被漏掉的一个:
|
||||
* 它挂在 **MCP** 上,看起来不像自带工具。同一族的 `js_reset` /
|
||||
* `js_add_node_module_dir` 单独看无害(只清状态/改模块解析路径),
|
||||
* 但拿掉更省心:它们对邮件驱动的一轮会话没有任何用处。
|
||||
* - **延迟执行**(绕过当期审批,把危险动作挪到没人看着的时候):
|
||||
* `CronCreate`/`CronUpdate`/`CronDelete`/`CronList`、`ScheduleWakeup`、`Workflow`。
|
||||
* - **子代理**:`Agent`/`Task` —— 子会话的工具集是否继承本清单**未经验证**,
|
||||
* 不拿掉就等于开一个洞。代价可接受:headless 是一封邮件一个进程,
|
||||
* 跑完就退,子代理本就没什么意义。
|
||||
* - **后台任务编排**:`TaskCreate`/`TaskGet`/`TaskList`/`TaskOutput`/`TaskStop`/
|
||||
* `TaskUpdate`(同上,进程活不过一轮)。
|
||||
* - **绕过 AgentMail 的对外通道**:`SendMessage`/`RespondToCoordinator` ——
|
||||
* 它们能直接给别的会话/协作者发消息,绕过「邮件是唯一交互范式」,
|
||||
* 于是绕过我们全部的预算、中继跳数与可见性治理。
|
||||
* - **档位逃生门**:`EnterPlanMode`/`ExitPlanMode` —— 中途切档会让平台的判定
|
||||
* 与我们自己的门禁临时错位(plan 档下平台会拒掉我们声明为 destructive 的工具),
|
||||
* 表现为「同一个动作刚才可以、现在被拒」而没人能解释。
|
||||
*
|
||||
* # 保留的(只读或纯本地状态)
|
||||
*
|
||||
* `Read` `Glob` `Grep` `WebFetch` `WebSearch` `web_search` `TodoRead` `TodoWrite`
|
||||
* `GoalRead` `ReadSessionContext` `AskUserQuestion` `Skill`。
|
||||
*
|
||||
* 其中 `WebFetch`/`WebSearch` 是**网络出向**:它们读不到本机文件,但能把
|
||||
* 上下文里的内容编码进 URL 发出去。这是已知且接受的残余风险(README 记一笔),
|
||||
* 要收紧就把它们加进 `AGENTMAIL_ZCODE_DISALLOWED_TOOLS`。
|
||||
*/
|
||||
export const REVIEWED_DENYLIST = [
|
||||
// 机器改动
|
||||
'Bash',
|
||||
'Write',
|
||||
'Edit',
|
||||
'ApplyPatch',
|
||||
'NotebookEdit',
|
||||
'LSP',
|
||||
'EnterWorktree',
|
||||
'ExitWorktree',
|
||||
// 等价于 Bash 的 JS 执行通道
|
||||
'js',
|
||||
'js_reset',
|
||||
'js_add_node_module_dir',
|
||||
'mcp__node_repl__js',
|
||||
'mcp__node_repl__js_reset',
|
||||
'mcp__node_repl__js_add_node_module_dir',
|
||||
// 延迟执行
|
||||
'Workflow',
|
||||
'CronCreate',
|
||||
'CronUpdate',
|
||||
'CronDelete',
|
||||
'CronList',
|
||||
'ScheduleWakeup',
|
||||
// 子代理
|
||||
'Agent',
|
||||
'Task',
|
||||
// 后台任务编排
|
||||
'TaskCreate',
|
||||
'TaskGet',
|
||||
'TaskList',
|
||||
'TaskOutput',
|
||||
'TaskStop',
|
||||
'TaskUpdate',
|
||||
// 绕过 AgentMail 的对外通道
|
||||
'SendMessage',
|
||||
'RespondToCoordinator',
|
||||
// 档位逃生门
|
||||
'EnterPlanMode',
|
||||
'ExitPlanMode'
|
||||
];
|
||||
|
||||
/**
|
||||
* 档位 → ZCode mode。
|
||||
*
|
||||
* # 三个模式在 headless 下的真实行为(逐条实测 + 逆代码,見 README)
|
||||
* | 档位 | mode | 谁在把关 |
|
||||
* |------|------|---------|
|
||||
* | plan | `plan` | 平台 + 我们(我们的门禁在 plan 档一律拒执行类工具) |
|
||||
* | workspace | `yolo` | **我们自己的门禁**(每次执行前问人) |
|
||||
* | full | `yolo` | 发件人已声明全权,门禁直接放行 |
|
||||
*
|
||||
* | mode | ZCode 自带工具 | 本插件的 MCP 工具 | 结果 |
|
||||
* |-------|---------------|------------------|------|
|
||||
* | build | Bash/Write/Edit → ask | **全部 → ask** | 没有交互式审批客户端 ⇒ **全部被拒** |
|
||||
* | edit | 文件编辑放行,其余同 build | 同 build | 同上 |
|
||||
* | plan | 非只读一律拒(fail-closed)| **非破坏性的直接放行** | 只读可用 |
|
||||
* | yolo | 全部放行 | 全部放行 | 全可用,但**没有任何审批** |
|
||||
*
|
||||
* 关键在于 MCP 工具的 `needsApproval` 是**硬编码为 true** 的(`Ari()` 里 `let d=!0`),
|
||||
* 与 `annotations` 无关;而 `build` 档的判定最后一条是
|
||||
* `needsApproval || destructive || sideEffectScope !== "none" → ask`。
|
||||
* 于是 **`build` 档下每一个 MCP 工具都要审批**,而 headless 模式没有审批客户端 →
|
||||
* 全被拒。实测:模型连 `read_inbox` 都调不动,只能靠提示词里带的信息猜。
|
||||
*
|
||||
* 而 `plan` 档有一条 `permissionName === "mcp" && !destructive → allow`,
|
||||
* 所以**声明了 `destructiveHint: false` 的 MCP 工具在 plan 档下直接放行**。
|
||||
* 实测:模型用 read_inbox 读出了只存在于邮件正文里的标记。
|
||||
*
|
||||
* # 为什么 workspace 档映射到 plan 而不是 build
|
||||
*
|
||||
* `build` 在本环境下等于「什么都不能做」(连读信都被拒)——那不是保守,
|
||||
* 是不可用。而 `plan` 是**真的 fail-closed**:危险的自带工具被平台直接拒,
|
||||
* 我们能用的只有自己声明的非破坏性工具。人在 workspace 档要的是「能干活」,
|
||||
* 拿不到;那就退到「至少能读能回」,并**在日志里说清楚为什么**,
|
||||
* 而不是静默地变成一个什么都干不了的代理。
|
||||
*
|
||||
* 要真让它动手,只有两条路(见 README 的「已知缺口」):
|
||||
* 1) 平台修好 PermissionRequest 钩子 → 审批能送达人;
|
||||
* 2) 显式改用 yolo(`AGENTMAIL_ZCODE_MODE_MAP`)+ `--disallowed-tools`
|
||||
* 把危险的自带工具拿掉,只留我们自己的工具面。
|
||||
* 三个档位都用同一张禁用清单:即使 full 档,危险的自带工具也不还回去。
|
||||
* 理由是**只有一条代码路径**才不会有「哪个档忘了加」的缺陷;而且我们的
|
||||
* `run_command` 已经覆盖了 Bash 的能力,还额外带来输出上限、保护目录与审计日志。
|
||||
*/
|
||||
const MODE_FOR_TIER = {
|
||||
[MODE_PLAN]: 'plan',
|
||||
workspace: 'plan',
|
||||
workspace: 'yolo',
|
||||
[MODE_FULL]: 'yolo'
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析档位映射。允许用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖,格式
|
||||
* `workspace:yolo,full:yolo`(逗号分隔)。
|
||||
* `workspace:build,full:yolo`(逗号分隔)。
|
||||
*
|
||||
* 存在的理由:将来平台修好钩子(或本机换版本后行为变了),
|
||||
* 应该**只改配置就能恢复**成 build,而不是等一次发版。
|
||||
* 存在的理由:将来平台修好钩子(或换版本后行为变了),应该**只改配置就能恢复**
|
||||
* 成 build/plan,而不是等一次发版。
|
||||
*/
|
||||
export function resolveModeMap(env = process.env) {
|
||||
const raw = String(env.AGENTMAIL_ZCODE_MODE_MAP || '').trim();
|
||||
@ -104,28 +170,54 @@ export function zcodeModeForTier(tier, env = process.env) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个 mode 是否会让危险操作走到我们的授权钩子。
|
||||
* 本次调用要禁用的自带工具。
|
||||
*
|
||||
* 用于启动自检与日志 —— 「驱动跑起来了但一次授权询问都没发生」有两种成因
|
||||
* (真没人碰危险工具 / mode 把询问绕过了),它们必须以不同的方式被看见。
|
||||
* `AGENTMAIL_ZCODE_DISALLOWED_TOOLS` 是**整表替换**(不是追加):
|
||||
* 收紧(把 WebFetch 也拿掉)与放宽(临时还回 Bash,比如本地调试)都靠它。
|
||||
* 传空串表示「一张空清单」—— 需要与「没设置」区分开,所以用 `=== undefined` 判。
|
||||
*/
|
||||
export function denylistForTier(tier, env = process.env) {
|
||||
const raw = env.AGENTMAIL_ZCODE_DISALLOWED_TOOLS;
|
||||
if (raw === undefined || raw === null) return [...REVIEWED_DENYLIST];
|
||||
return String(raw)
|
||||
.split(/[\s,]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个 mode 是否会让危险操作走到平台的授权询问。
|
||||
*
|
||||
* 只用于启动自检与日志。`yolo` 恒为 false —— 而那正是我们现在**故意**要的:
|
||||
* 平台不问,我们自己的门禁问。这件事必须以不同方式被看见,不能被理解成
|
||||
* 「授权系统消失了」。
|
||||
*/
|
||||
export function modeReachesPermissionHook(mode) {
|
||||
return mode === 'build' || mode === 'edit';
|
||||
}
|
||||
|
||||
/** 我们自己的门禁是否在这次调用里生效(只有非 yolo 时才没有)。 */
|
||||
export function ourGateIsActive(tier) {
|
||||
return normalizeMode(tier) !== MODE_FULL;
|
||||
}
|
||||
|
||||
/** 权限档位的人话解释,写进日志与回信里,方便复盘「当时是什么档」。 */
|
||||
export function describeTier(tier, mode = zcodeModeForTier(tier)) {
|
||||
const t = normalizeMode(tier) || DEFAULT_MODE;
|
||||
if (t === MODE_PLAN) return `${t} 档 → --mode ${mode}(只读,ZCode 自己就会拒非只读工具)`;
|
||||
if (t === MODE_FULL) return `${t} 档 → --mode ${mode}(全权,刻意绕过审批)`;
|
||||
if (mode === 'plan') {
|
||||
if (t === MODE_PLAN) {
|
||||
return `${t} 档 → --mode ${mode}(只读:平台会拒非只读工具,我们的门禁也会拒执行类工具)`;
|
||||
}
|
||||
if (t === MODE_FULL) {
|
||||
return `${t} 档 → --mode ${mode}(发件人声明全权:我们自己的门禁直接放行,平台不自带危险工具)`;
|
||||
}
|
||||
if (mode === 'yolo') {
|
||||
return (
|
||||
`${t} 档 → --mode ${mode}(本档本应「危险操作问人」,本平台 headless 做不到:` +
|
||||
`MCP 工具在 build 档下恒需审批而无人可批,只能退到只读)`
|
||||
`${t} 档 → --mode ${mode}(平台不做权限判定,危险的自带工具已被禁用;` +
|
||||
`执行类动作由 AgentMail 门禁**逐次向发件人请示**)`
|
||||
);
|
||||
}
|
||||
if (mode === 'build' || mode === 'edit') {
|
||||
return `${t} 档 → --mode ${mode}(危险操作会走到 AgentMail 授权钩子)`;
|
||||
return `${t} 档 → --mode ${mode}(平台自带工具会走到 AgentMail 授权钩子)`;
|
||||
}
|
||||
return `${t} 档 → --mode ${mode}`;
|
||||
}
|
||||
|
||||
@ -58,9 +58,18 @@ export function buildRunArgs({
|
||||
args.push('--mode', mode);
|
||||
if (maxTurns) args.push('--max-turns', String(maxTurns));
|
||||
if (resumeSessionId) args.push('--resume', resumeSessionId);
|
||||
// `--allowed-tools` 在 help 里写着,但 CLI 的解析器**不认**它
|
||||
// (`Unknown option '--allowed-tools'`),会直接退到 usage。
|
||||
// 留着这个参数会拼出一条永远跑不起来的命令行,所以在拼参数这一步就报错,
|
||||
// 而不是等到一两分钟后拿到一段 usage 文本才去查。
|
||||
if (Array.isArray(allowedTools) && allowedTools.length) {
|
||||
args.push('--allowed-tools', allowedTools.join(','));
|
||||
throw new Error(
|
||||
'本版本 ZCode CLI 不支持 --allowed-tools(解析器报 Unknown option);' +
|
||||
'只能用 --disallowed-tools 黑名单,见 src/turn-mode.mjs 的 REVIEWED_DENYLIST'
|
||||
);
|
||||
}
|
||||
// 逗号分隔:help 说「Comma or space-separated」,实测两种都行,
|
||||
// 但逗号不受调用方是否把参数拼成单个 argv 的影响。
|
||||
if (Array.isArray(disallowedTools) && disallowedTools.length) {
|
||||
args.push('--disallowed-tools', disallowedTools.join(','));
|
||||
}
|
||||
@ -102,6 +111,7 @@ export function parseStreamLine(line) {
|
||||
*
|
||||
* @param {{prompt:string, cwd:string, mode:string, maxTurns?:number,
|
||||
* resumeSessionId?:string, turnTimeoutMs?:number,
|
||||
* disallowedTools?:string[],
|
||||
* env?:Record<string,string>, cliPath?:string, nodePath?:string,
|
||||
* onChild?:(kill:(signal?:string)=>void)=>void,
|
||||
* onEvent?:(event:any)=>void}} opts
|
||||
|
||||
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