feat(zcode): yolo + 自有工具面 + 我们自己的执行门禁(headless 真正能干活了)

按用户裁定「yolo_own_tools」实现:平台让开(--mode yolo),它自带的一切
「能动机器」的工具被 --disallowed-tools 拿掉,执行类动作改由我们自己的
run_command / write_file 承担,而门禁就在这两个工具里 —— 逐次向发件人请示。

## 为什么必须走这条路(实测,不是推断)

MCP 工具的 needsApproval 在产物里**硬编码为 true**(与 annotations 无关),
而 build/edit 档的判定最后一条是「需要审批 → ask」;headless 没有审批客户端
可问 ⇒ **每个 MCP 工具都被拒**(连 read_inbox 都调不动)。
我们本想让平台把询问转给钩子,但 PermissionRequest 在本版本(3.10.2 / CLI 0.16.5)
**不可靠**:有时压根不注册,触发时也无条件在 ~5ms 内失败、命令从未被 spawn
(用「钩子写 marker 文件」的副作用验证)。

于是选择只剩两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
后者才既可用又可审计。代价(平台不再提供第二道防线)写进了 README 的残余风险。

## 新增

- `lib/approval.mjs`:授权往返的唯一实现(钩子与工具共用,否则必然漂移)。
  三条不可动摇的规矩:只有明确同意才放行(判据是共用库的前缀白名单,
  不是「不等于拒绝」);永久失败(409/4xx)当场拒绝并把服务端建议带给模型;
  暂时失败看有没有本地界面 —— 判据用**调用方传的 sessionId**(单一事实来源,
  不再另读环境变量)。自己开 SSE 等决定,先建连再发请求。
- `lib/action-tools.mjs`:`run_command` / `write_file`。输出上限、超时上限、
  默认 cwd=工作区;拒绝时**抛错**(MCP 层转 isError)而不是返回「已处理」——
  opencode 上「工具失败但报成功」导致模型连试 6 次后放弃整个任务的教训。
  平台保护目录(网关数据库/插件代码/服务单元/密钥目录)**无论谁批准都不写**,
  且判定在门禁之前(不消耗人的注意力)——防的是自我强化:邮件驱动的 Agent
  可能被来信诱导去改自己的插件代码,改完下一轮就换了一套规则。
- `REVIEWED_DENYLIST`(32 项):逐条按「不拿掉会怎样」分类。名单来自 CLI 产物里
  模型可见工具名的**权威注册表**(aIn 那个 28 项数组)+ 另一份更宽的候选集并集,
  **不采信模型自述**(基线里它用某个没点名的方式真的创建了文件)。
  最容易被漏掉的是 `js` / `mcp__node_repl__js`:它挂在 MCP 上、
  产物里自述「can run arbitrary JavaScript with full Node privileges, like Bash」。
- 提示词的能力说明(分档):告诉模型自带工具被禁、动手要用哪两个工具、
  会被请示;并明确「被拒是业务结果,不要重试、不要绕道」。

## 修掉三个真缺陷(都是实测撞出来的)

1. **幂等键按「会话+工具」取 → 同会话第二次调用被静默吞掉**。
   网关对重复 relay_key 返回 **HTTP 200** `{status:"duplicate_relay"}` 并提前返回:
   不建请求、不发邮件、**永远不会有人来决策**。于是工具干等 → 被 MCP 调用超时
   砍掉 → 模型回报「30 秒内未获批准」。从状态码到措辞全看不出问题,归因还完全
   错了(像是人没理它)。改为**按调用唯一**(保留会话/工具前缀便于反查),
   并把 duplicate_relay 当成可读的拒绝(fail fast,不再干等)。
2. **授权窗口被 MCP 调用超时截断**。ZCode 对 MCP 工具调用有超时(默认量级 30 秒),
   而门禁要等人。已在插件清单声明 `mcpServers.agentmail.timeoutMs=600000`
   (实测生效:40 秒的命令没被砍,墙钟 50 秒通过),并让门禁**自己**把等待夹到
   timeoutMs - 余量之下(`resolveWaitMs`)——被客户端杀掉时连理由都发不出去,
   所以必须由我们自己先 settle。
3. **`--allowed-tools` 在 help 里写着但解析器不认**(`Unknown option`)。
   留着会拼出一条永远跑不起来的命令行,现在 `buildRunArgs` 直接抛错并指出
   替代方案。我在这里误判过一次:先看到「文件没创建」就以为白名单生效,
   其实进程只是没退到 usage。判据缺了「进程真的执行了」这一环。

## 自报改成如实

detectModeEnforcement 以前拿「钩子已注册」当 native 的凭据 —— yolo 下钩子
根本不会触发,那等于替一个不存在的能力背书。现在先看**我们那条链**是否就绪
(yolo + 禁用清单里真的有 Bash/js),就绪才报 native,并在理由里点明谁在把关
(实测输出:「执行类动作只能经我们自己的门禁…平台自带危险工具已禁用 32 项」)。

## 验证

- 单测 376/376(新增 47 条)。重点在反向对照:一句「拒绝/deny/空串/平台自己的
  shutdown 哨兵都不放行」之外,还验了「别人的决策不能拿来用(relay_key 配对)」、
  「超时必须真的拒绝」、「同一会话两次调用必须用不同的幂等键」、
  「重复请求要当场拒绝而不是干等」;执行工具的每条拒绝场景都配一个**文件系统断言**
  (「抛错了」不等于「副作用没发生」),保护目录还验了 `..`/`./` 绕不过去。
- 真模型端到端(`/root/e2e-zcode-gate/run.py`,13/13):
  批 → 命令真执行(文件内容=标记);拒 → 命令真没执行(文件不存在)
  且回信把成因说成「人拒绝」而**不是**「超时」;同会话第三次调用仍能产生新请求
  并在获批后执行。判据本身也修了两处(授权请求邮件里带标记会被误当成回信;
  备注在通过项旁边显示会误导)。
- 部署:`deploy/redeploy-plugin.sh zcode` 快照切换 + 握手自检;
  驱动单元改为跑快照(生产不跑仓库工作区),env 与清单超时的关系写进注释。
- 顺手清掉一个遗留驱动进程(跑的是仓库路径的旧代码、连着网关 SSE、会抢邮件)。

## 判据纪律(本轮又踩到、已写进代码注释)

「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
This commit is contained in:
2026-09-12 19:05:54 +08:00
parent 10ff50e899
commit a00cbf36fc
16 changed files with 1963 additions and 164 deletions

View File

@ -14,7 +14,8 @@
"cwd": "${ZCODE_PROJECT_DIR}",
"env": {
"ZCODE_PLUGIN_ID": "agentmail"
}
},
"timeoutMs": 600000
}
}
}

View File

@ -33,7 +33,7 @@ test/manual/permission-e2e.mjs 授权桥端到端(真去点同意/拒绝)
test/manual/driver-e2e.mjs 邮件驱动端到端(桩 CLI真网关真邮件
```
## 三条能力线
## 三条能力线(现行姿态:一条主路 + 一条桌面通路)
### 1MCP 工具面
@ -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` | 等人工决策的上限,默认 5400009 分钟,须小于钩子的 `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`)。
两边解析出不同路径就会出现「刚点过一直同意又问你一遍」与「同一件事发两封信」。

View 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);
}

View 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();
}
}

View File

@ -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 = {

View File

@ -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 服务器):

View File

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

View File

@ -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` 属 destructivehigh→ ask
* `Write`/`Edit` 有 workspace 副作用 → ask。
* - `checkEditMode``permissionName === "edit"` 的工作区文件编辑放行,其余退回 build
* - plan`mode.plan.nonReadOnly` → 非只读一律**拒**。
* - yolo一律放行。
*
* 于是映射为plan → planworkspace → buildfull → 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}`;
}

View File

@ -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

View File

@ -0,0 +1,329 @@
/**
* 执行工具lib/action-tools.mjs的测试。
*
* 这些工具是**唯一**能动机器的路径(平台自带的 Bash/Write/Edit/js 已被
* `--disallowed-tools` 禁掉),所以每条测试都必须同时验两件事:
*
* 1. 结果对不对(执行了 / 返回了什么)
* 2. **在没获批准时,副作用真的没有发生**
*
* 第 2 条不能只看「抛错了」—— 抛错之后照样写文件是最糟的实现方式,
* 而只验抛错完全发现不了。所以拒绝场景一律配一个文件系统断言。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildActionTools } from '../lib/action-tools.mjs';
import { createGrantStore } from '../lib/permission-grants.js';
/** 假 SSE立刻发 connected测试自己投喂决策。 */
function makeSSE() {
const s = { onEvent: null, stopped: false };
return {
state: s,
factory: ({ onEvent }) => {
s.onEvent = onEvent;
queueMicrotask(() => onEvent('connected', {}));
return { stop: () => { s.stopped = true; } };
}
};
}
function makeClient({ decision, fail } = {}) {
const state = { requests: [] };
return {
state,
client: {
baseURL: 'http://gw.test',
authHeaders: () => ({}),
async post(path, body) {
state.requests.push({ path, body });
if (fail) throw fail;
return {};
}
}
};
}
/** 人都同意场景:请求受理后立刻投喂「同意」。 */
function approving({ decision = '同意' } = {}) {
const sse = makeSSE();
const c = makeClient();
const orig = c.client.post;
c.client.post = async (p, b) => {
await orig(p, b);
queueMicrotask(() => sse.state.onEvent('permission_decision', { relay_key: b.relay_key, decision }));
return {};
};
return { ...c, factory: sse.factory };
}
async function withTools(env, fn, opts = {}) {
const dir = await mkdtemp(join(tmpdir(), 'zc-act-'));
const c = opts.client || makeClient();
const tools = buildActionTools({
client: c.client,
env: { AGENTMAIL_SESSION_ID: 'sess-1', AGENTMAIL_WORKSPACE_ROOT: dir, ...env },
grants: opts.grants || null,
createSSE: opts.createSSE,
log: () => {}
});
const byName = new Map(tools.map(t => [t.name, t]));
try {
return await fn({ byName, dir, client: c });
} finally {
await rm(dir, { recursive: true, force: true });
}
}
// ─── 工具面本身 ─────────────────────────────────────────────────────────
test('★ 工具面只暴露两个执行工具,且都声明为 destructive', async () => {
await withTools({}, async ({ byName }) => {
assert.deepEqual([...byName.keys()].sort(), ['run_command', 'write_file']);
for (const [name, t] of byName) {
assert.equal(t.annotations.readOnlyHint, false, `${name} 不该声称只读`);
// destructiveHint 必须为真plan 档下平台的判定是
// 「permissionName==="mcp" && !destructive → allow」声明成非破坏性会让
// 这两个工具在只读档被平台放行 —— 那时我们的门禁也会拒,但平台那层
// 已经先把话说错了。
assert.equal(t.annotations.destructiveHint, true, `${name} 必须声明为破坏性`);
}
});
});
// ─── run_command ────────────────────────────────────────────────────────
test('★ 获批准后真的执行,并返回退出码与输出', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo hello; echo err >&2' });
assert.match(out, /退出码0/);
assert.match(out, /hello/);
assert.match(out, /err/);
}, { client: c, createSSE: c.factory });
});
test('★ 拒绝时抛错、且命令真的没执行', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
const marker = join(dir, 'should-not-exist.txt');
await assert.rejects(
() => byName.get('run_command').run({ command: `touch ${marker}` }),
/未获批准/
);
assert.equal(existsSync(marker), false, '被拒的命令仍然产生了副作用');
});
});
test('★ 命令非零退出不是工具失败:原样把退出码与 stderr 交给模型', async () => {
// 抛错会让模型以为工具坏了并重试;而 `grep` 没匹配到、测试失败、
// 编译报错都是**正常的命令结果**,模型靠 stderr 判断下一步。
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo boom >&2; exit 7' });
assert.match(out, /退出码7/);
assert.match(out, /boom/);
}, { client: c, createSSE: c.factory });
});
test('★ 超时被当作命令结果报告(不能挂死整轮)', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'sleep 5', timeout_ms: 300 });
assert.match(out, /退出码:(SIGTERM|null)/);
assert.match(out, /超时被终止/);
assert.match(out, /上限 300ms/);
}, { client: c, createSSE: c.factory });
});
test('★ 输出过长时截断并明确说明截断了多少', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: `seq 1 20000` });
assert.match(out, /被截断,省略 \d+ 字符/);
assert.ok(out.length < 20000, '截断没生效');
}, { client: c, createSSE: c.factory });
});
test('★ 工作目录默认是本会话工作区,可用 cwd 覆盖', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
const out = await byName.get('run_command').run({ command: 'pwd' });
assert.match(out, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}, { client: c, createSSE: c.factory });
});
test('空命令被拒(不浪费一次人工审批)', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(() => byName.get('run_command').run({ command: ' ' }), /command 不能为空/);
});
});
// ─── write_file ─────────────────────────────────────────────────────────
test('★ 获批准后真的写入文件(含自动建父目录)', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
const target = join(dir, 'deep', 'nested', 'a.txt');
const out = await byName.get('write_file').run({ path: target, content: '内容' });
assert.match(out, /已写入/);
assert.equal(await readFile(target, 'utf8'), '内容');
}, { client: c, createSSE: c.factory });
});
test('★ 拒绝时抛错、且不创建文件也不创建目录', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
const target = join(dir, 'deep', 'x.txt');
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /未获批准/);
assert.equal(existsSync(target), false, '被拒的写入仍然产生了文件');
assert.equal(existsSync(join(dir, 'deep')), false, '被拒的写入仍然创建了目录');
});
});
test('★ 保护目录:即使有人批准也拒,而且**根本不发审批请求**', async () => {
// 这不是不信任人,而是防自我强化:邮件驱动的 Agent 可能被来信诱导去改
// 网关数据库/服务单元/自己的插件代码,改完下一轮就换了一套规则。
// 所以这道判定必须在门禁**之前**,且不能消耗人的注意力。
const c = approving();
for (const target of [
'/opt/agentmail/data/agentmail.db',
'/opt/agentmail/plugins/zcode-mail-bridge/x.mjs',
'/etc/systemd/system/homeagent.service',
'/etc/agentmail/pi.env',
'/root/.agentmail-zcode/secret'
]) {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(
() => byName.get('write_file').run({ path: target, content: 'x' }),
/平台保护目录/,
`${target} 应该被保护`
);
}, { client: c, createSSE: c.factory });
}
// 反向对照:保护目录外真的写了(否则上面全绿可能只是因为全都写不进去)。
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
const p = join(dir, 'ok.txt');
await byName.get('write_file').run({ path: p, content: 'ok' });
assert.equal(await readFile(p, 'utf8'), 'ok');
}, { client: c, createSSE: c.factory });
});
test('★ 保护判定不能被路径花招绕过(大小写/相对路径/..', async () => {
for (const target of [
'/opt/agentmail/data/../data/agentmail.db',
'/opt/agentmail/./data/x',
'/etc/systemd/system/../system/x.service'
]) {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /平台保护目录/);
});
}
});
test('★ 相对路径按工作区解析(不能靠相对路径逃出工作区之外)', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
const out = await byName.get('write_file').run({ path: 'sub/rel.txt', content: 'r' });
assert.match(out, new RegExp('sub/rel.txt'));
assert.equal(await readFile(join(dir, 'sub', 'rel.txt'), 'utf8'), 'r');
const st = await stat(join(dir, 'sub', 'rel.txt'));
assert.ok(st.isFile());
});
});
test('content 必须是字符串(否则会写出 "[object Object]"', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(() => byName.get('write_file').run({ path: 'x.txt', content: { a: 1 } }), /必须是字符串/);
await assert.rejects(() => byName.get('write_file').run({ content: 'x' }), /path 不能为空/);
});
});
// ─── 门禁接线 ───────────────────────────────────────────────────────────
test('★ 授权请求里带上了人真正需要看的信息(命令原文 / 用途 / 目标路径)', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, client }) => {
await byName.get('run_command').run({ command: 'rm -rf /tmp/x', purpose: '清理临时文件' });
const body = client.state.requests.at(-1).body;
assert.equal(body.session_id, 'sess-1');
assert.match(body.question, /rm -rf \/tmp\/x/, '批准人必须看到命令原文');
assert.match(body.context, /清理临时文件/, '用途要带给批准人');
assert.match(body.relay_key, /sess-1/);
}, { client: c, createSSE: c.factory });
});
test('★ 「一直同意」命中时不再打扰人(同一会话同一工具)', async () => {
const grants = createGrantStore();
grants.grant('sess-1', 'run_command', '一直同意');
const c = makeClient();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo granted' });
assert.match(out, /granted/);
}, { client: c, grants });
assert.equal(c.state.requests.length, 0, '已有授权却仍然发了审批请求');
});
test('★ full 档不打扰人(发件人已声明全权)', async () => {
const c = makeClient();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo full' });
assert.match(out, /full/);
}, { client: c });
assert.equal(c.state.requests.length, 0);
});
test('★ 网关不可达时 fail closed不执行、不写文件', async () => {
const fail = Object.assign(new Error('ECONNREFUSED'), { status: 502 });
const c = makeClient({ fail });
const sse = makeSSE();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
const marker = join(dir, 'nope.txt');
await assert.rejects(() => byName.get('run_command').run({ command: `touch ${marker}` }), /未获批准/);
assert.equal(existsSync(marker), false);
}, { client: c, createSSE: sse.factory });
});
// ─── 等待窗口必须容得下「人真的来点一下」────────────────────────────────
// 这一组来自一个实测缺陷工具在等授权客户端ZCode默认 30 秒就把这次
// MCP 调用掐了模型于是回报「30 秒内未获批准」——看起来像人没理它,
// 实际是门禁的等待窗口被截断,而且**表现得完全正常**。
test('★ 授权等待被夹到 MCP 调用超时之下(并留下可发现的痕迹)', async () => {
const { resolveWaitMs, resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
// 清单里声明的时间本插件自己的清单实测生效40 秒的命令没被砍)
const declared = resolveMcpTimeoutMs();
assert.ok(declared && declared >= 60000, `清单应声明一个够长的 timeoutMs实际 ${declared}`);
// 配置想等 90 分钟,但 MCP 只给 10 分钟 → 应夹到 10 分钟减余量
const capped = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '5400000' }, 600000);
assert.ok(capped.waitMs < 600000, '必须小于 MCP 超时,否则调用会先被杀掉');
assert.ok(capped.waitMs >= 600000 - 120000, '也不该夹得过小(人需要时间点同意)');
assert.equal(capped.capped, true, '被夹小这件事必须能被发现(要写日志)');
// 边界:配置正好等于上限 → 不算被夹(它本来就 settle 得掉)
const onEdge = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: String(600000 - 30000) }, 600000);
assert.equal(onEdge.capped, false);
assert.equal(onEdge.waitMs, 570000);
// 反向对照:配置本来就比 MCP 超时小 → 原样使用,不报「被夹」
const fine = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '120000' }, 600000);
assert.equal(fine.waitMs, 120000);
assert.equal(fine.capped, false);
// 反向对照:读不到清单时不猜,沿用配置(并在日志里说没校到)
const unknown = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '540000' }, null);
assert.equal(unknown.waitMs, 540000);
assert.equal(unknown.capped, false);
});
test('★ 清单里的 timeoutMs 必须真的存在且够长(否则门禁没有可行窗口)', async () => {
const { resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
const t = resolveMcpTimeoutMs();
assert.ok(t, '插件清单的 mcpServers.agentmail 必须有 timeoutMs');
// 默认 30 秒的 MCP 超时下,人根本来不及看到请求 —— 所以必须显式声明一个大的。
assert.ok(t > 300000, `timeoutMs=${t} 太短,人工审批窗口不够`);
});

View File

@ -0,0 +1,354 @@
/**
* 授权往返lib/approval.mjs的测试。
*
* 这是全项目最该被测死的一块:它决定「什么算同意」。所以每一组都配了
* **反向对照** —— 不是只验「同意时放行了」,而要同时验「别的任何东西都不放行」。
*
* 时序靠注入的假 SSE 控制:真网关的 SSE 是扇出的,假实现只需要保留
* `onEvent` 回调并在合适的时候投喂事件,就能精确复现「先建连、再发请求、
* 决策在请求之后到达」以及几个边界(决策在请求之前就到了 / 一直没到 /
* 来的是别人的决策)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { requestApproval, tierOf, hasLocalUi } from '../lib/approval.mjs';
import { createGrantStore } from '../lib/permission-grants.js';
/** 可控的假 SSE把 onEvent 抓住,测试自己决定何时投喂什么。 */
function makeSSE() {
const s = { onEvent: null, connected: false, stopped: false };
const factory = ({ onEvent }) => {
s.onEvent = onEvent;
// 真实现在建连后立刻下发 connected这里用 microtask 复现「不等它也能跑」。
queueMicrotask(() => {
s.connected = true;
onEvent('connected', {});
});
return { stop: () => { s.stopped = true; } };
};
return { factory, s };
}
/** 记录请求体;可选在请求成功后投喂一条决定。
* `onRequest` 的**返回值会被当作 HTTP 响应体**返回给被测代码 ——
* 这一点至关重要:网关的幂等命中是一个 200 + `{status:"duplicate_relay"}`
* 判据就看它。之前这里硬编码 `return {}`,把响应体丢了,于是「重复请求」
* 那条测试变成干等到超时,而失败信息看起来像被测代码的 bug。
*/
function makeClient({ onRequest } = {}) {
const state = { requests: [] };
const client = {
baseURL: 'http://gw.test',
authHeaders: () => ({ 'X-Agent-Secret': 's' }),
async post(path, body) {
state.requests.push({ path, body });
if (onRequest) {
const res = await onRequest(state, body);
return res === undefined ? {} : res;
}
return {};
}
};
return { client, state };
}
const base = env => ({
toolName: 'run_command',
question: '要执行一条命令',
context: 'echo hi',
sessionId: 'sess-1',
log: () => {},
env,
...env
});
test('★ plan 档直接拒绝执行类工具,且根本不发请求', async () => {
const { factory } = makeSSE();
const { client, state } = makeClient();
const r = await requestApproval({
...base({ tier: 'plan', createSSE: factory })
});
assert.equal(r.allowed, false);
assert.equal(r.via, 'tier');
assert.match(r.reason, /plan 档/);
// 反向对照:不该在「注定拒绝」的档位上去打扰人。
assert.equal(state.requests.length, 0, 'plan 档不该发出授权请求');
});
test('★ full 档直接放行', async () => {
const { client } = makeClient();
const r = await requestApproval({ toolName: 'run_command', tier: 'full', sessionId: 's1' });
assert.equal(r.allowed, true);
assert.equal(r.via, 'tier');
});
test('★ 「一直同意」命中时不发请求(钩子与工具共用同一张表)', async () => {
const grants = createGrantStore();
grants.grant('sess-1', 'run_command', '一直同意');
const { client, state } = makeClient();
const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants });
assert.equal(r.allowed, true);
assert.equal(r.via, 'grant');
assert.equal(state.requests.length, 0);
});
test('★ 人同意 → 放行,且请求里带上了 relay_key 与选项', async () => {
const { factory, s } = makeSSE();
const { client, state } = makeClient({
onRequest: async st => {
// 真网关是「先受理、后有人决策」,所以决策必须晚于请求。
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意', decided_by: 'gui-lab' }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, true);
assert.equal(r.via, 'human');
assert.equal(r.decidedBy, 'gui-lab');
const sent = state.requests[0].body;
assert.equal(sent.session_id, 'sess-1');
assert.ok(sent.relay_key, '请求必须带 relay_key决定回执怎么配对');
assert.deepEqual(sent.options, ['同意', '一直同意', '拒绝']);
assert.equal(s.stopped, true, 'SSE 必须被关掉(否则短命进程不退出)');
});
test('★ 「一直同意」放行并落进授权表;「同意」不落', async () => {
for (const [decision, shouldPersist] of [
['一直同意', true],
['同意', false]
]) {
const { factory, s } = makeSSE();
const grants = createGrantStore();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants, createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, true, decision);
assert.equal(
grants.isGranted('sess-1', 'run_command'),
shouldPersist,
`${decision} 的落表行为不对`
);
}
});
test('★ 人拒绝 → 不放行,且原因里带上决策人', async () => {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() =>
s.onEvent('permission_decision', {
relay_key: st.requests[0].body.relay_key,
decision: '拒绝',
decided_by: 'gui-lab',
note: '这条命令会删数据'
})
);
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'human');
assert.match(r.reason, /拒绝/);
assert.match(r.reason, /gui-lab/);
assert.match(r.reason, /会删数据/);
});
test('★ 反向对照:一切「不是明确同意」的文本都不放行', async () => {
// 判据是「在放行白名单里」,不是「不等于拒绝」。所以拒绝、看不懂的东西、
// 平台自己的 shutdown 哨兵、空串都不能放行。
//
// 注意白名单本身是共用库的前缀匹配(`^同意|一直同意|allow|approve|always|yes`
// 四个桥共用同一份)。所以「不同意」不放行(前缀不是同意),而「同意吧」放行 ——
// 后者是刻意接受的:决策文本来自界面按钮,前缀匹配是为了容错,不是为了放宽。
// 这里把两类都钉住,避免哪天有人把前缀匹配改成 includes 而无人发现
// (那会让「我不同意」变成同意)。
for (const decision of ['', 'maybe', 'ok?', 'shutdown', 'deny', '拒绝', '不同意', '否', 'no', undefined, null]) {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 });
assert.equal(r.allowed, false, `decision=${JSON.stringify(decision)} 不该放行`);
}
// 反向对照的对照:确实在白名单里的必须放行,否则上面全绿可能只是因为门槛坏死了。
for (const decision of ['同意', '一直同意', 'allow', 'yes']) {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 });
assert.equal(r.allowed, true, `decision=${JSON.stringify(decision)} 应当放行`);
}
});
test('★ 超时 → 拒绝(不能靠「没消息就是好消息」)', async () => {
const { factory } = makeSSE();
const { client } = makeClient(); // 从不投喂决策
const t0 = Date.now();
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 120 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'timeout');
assert.match(r.reason, /超时/);
assert.ok(Date.now() - t0 >= 100, '必须真的等过,而不是立刻返回');
});
test('★ 别人的决策不能拿来用relay_key 配对)', async () => {
// 同一个 Agent 可能同时有多个调用在等(模型并行发起两个动作)。
// 若不按 relay_key 过滤B 的同意会放行 A。
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
const mine = st.requests[0].body.relay_key;
queueMicrotask(() => {
s.onEvent('permission_decision', { relay_key: `${mine}-other`, decision: '同意' });
setTimeout(() => s.onEvent('permission_decision', { relay_key: mine, decision: '拒绝' }), 30);
});
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false, '拿到别人的「同意」就是越权放行');
assert.match(r.reason, /拒绝/);
});
test('★ 永久失败409 无人可问)当场拒绝,并把服务端建议带给模型', async () => {
const { factory } = makeSSE();
const err = Object.assign(new Error('409'), {
status: 409,
body: { error: '本线索内找不到可决策的人类', suggestion: '请让发件人把档位改成 full' }
});
const { client } = makeClient({
onRequest: async () => {
throw err;
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'permanent-failure');
assert.match(r.reason, /找不到可决策的人类/);
assert.match(r.reason, /改成 full/, '服务端的建议必须原样带给模型,否则它只能盲试');
});
test('★ 暂时失败没有本地界面时必须拒绝fail closed', async () => {
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => {
throw new Error('ECONNREFUSED');
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.equal(r.via, 'transport');
assert.match(r.reason, /没有本地界面/);
});
test('★ 暂时失败:有本地界面时明确说明没有放行', async () => {
// 桌面模式下平台自己还有流程,所以这里不放行是安全的 —— 但**不能说**放行了。
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => {
throw new Error('ECONNREFUSED');
}
});
const r = await requestApproval({ toolName: 'Bash', tier: 'workspace', client, createSSE: factory, waitMs: 1000 });
assert.equal(r.allowed, false);
assert.match(r.reason, /授权询问失败/);
});
test('★ 「有没有本地界面」由调用方传的 sessionId 判定(单一事实来源)', async () => {
// 反向对照:同一个暂时失败,在「有会话」与「没会话」下必须给出不同的拒绝理由。
// 这里刻意把 process.env.AGENTMAIL_SESSION_ID 设成反的,验证模块**不看它** ——
// 两个事实来源不一致时,谁也说不清到底算有界面还是没界面。
const prev = process.env.AGENTMAIL_SESSION_ID;
process.env.AGENTMAIL_SESSION_ID = '来自进程环境的干扰值';
try {
const mk = () => {
const { factory } = makeSSE();
const { client } = makeClient({ onRequest: async () => { throw new Error('boom'); } });
return { factory, client };
};
const a = mk();
const withSession = await requestApproval({
...base({}), client: a.client, tier: 'workspace', createSSE: a.factory, waitMs: 500
});
assert.match(withSession.reason, /没有本地界面/, '带会话 = 邮件驱动,必须 fail closed');
const b = mk();
const noSession = await requestApproval({
toolName: 'Bash', tier: 'workspace', client: b.client, createSSE: b.factory, waitMs: 500
});
assert.doesNotMatch(noSession.reason, /没有本地界面/, '不带会话 = 有界面,不该说成没界面');
} finally {
if (prev === undefined) delete process.env.AGENTMAIL_SESSION_ID;
else process.env.AGENTMAIL_SESSION_ID = prev;
}
});
test('tierOf / hasLocalUi 的判据', () => {
assert.equal(tierOf({}), 'workspace');
assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'full' }), 'full');
// 认不出来的值 → workspace共用库的约定不是「免问」
assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'FULL' }), 'workspace');
// 有会话 id = 邮件驱动 = 没有本地界面
assert.equal(hasLocalUi({}), true);
assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: 'sess-1' }), false);
assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: ' ' }), true, '空白串不算会话');
});
// ─── 幂等键必须按「这一次调用」唯一 ─────────────────────────────────────
// 一个实测缺陷,失败方式极隐蔽:键取成「会话+工具」之后,同一会话里**第二次**
// run_command 被网关判成重复请求 → HTTP 200 duplicate_relay → 请求**没发出去**、
// 永远没人来决策 → 工具干等到被 MCP 调用超时砍掉 → 模型回报「30 秒内未获批准」。
// 从状态码到措辞全都看不出问题,归因还完全错了(像是人没理它)。
test('★ 同一会话同一工具的两次调用必须用不同的幂等键', async () => {
const keys = [];
for (let i = 0; i < 2; i++) {
const { factory, s } = makeSSE();
const { client } = makeClient({
onRequest: async st => {
keys.push(st.requests[0].body.relay_key);
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意' }));
}
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 500 });
assert.equal(r.allowed, true);
}
assert.equal(keys.length, 2);
assert.notEqual(keys[0], keys[1], '两次调用的键相同 ⇒ 第二次会被网关当重复丢弃');
// 键里仍保留会话与工具,便于事后从邮件反查(但唯一性来自随机尾)
assert.match(keys[0], /sess-1/);
assert.match(keys[0], /run_command/);
});
test('★ 网关判为重复请求时当场拒绝(不能干等到被超时砍掉)', async () => {
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => ({ status: 'duplicate_relay', detail: '该权限询问已转发过,本次调用未产生新邮件' })
});
const t0 = Date.now();
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 60000 });
const dt = Date.now() - t0;
assert.equal(r.allowed, false);
assert.equal(r.via, 'duplicate-relay');
assert.match(r.reason, /重复/);
assert.match(r.reason, /没有人会看到这次询问/);
assert.ok(dt < 5000, `必须立刻返回,实际等了 ${dt}ms说明它在干等一个永远不会来的决策`);
});
test('★ 反向对照:正常的 200非 duplicate_relay仍要等决策', async () => {
// 否则上面那条可能只是因为「任何 200 都被当成重复」。
const { factory } = makeSSE();
const { client } = makeClient({
onRequest: async () => ({ status: 'pending' })
});
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 150 });
assert.equal(r.via, 'timeout', '非重复的正常请求应该等,然后超时');
});

View File

@ -215,11 +215,12 @@ test('Agent 来信的提示词必须说清「插件不会替你回信」', async
}
});
test('★ 档位随邮件传下去,并作为 --mode 钩子环境变量注入', async () => {
test('★ 档位随邮件传下去,并作为 --mode / 禁用清单 / 钩子环境变量注入', async () => {
for (const [tier, mode] of [
['plan', 'plan'],
// workspace 默认映射到 planbuild 在 headless 下连 MCP 工具都要审批而无人可批
['workspace', 'plan'],
// workspace 与 full 都映射到 yolo平台不做权限判定它自带危险工具已被
// --disallowed-tools 拿掉),执行类动作改由我们自己的门禁逐次请示。
['workspace', 'yolo'],
['full', 'yolo']
]) {
const h = await harness();
@ -230,6 +231,12 @@ test('★ 档位随邮件传下去,并作为 --mode 与钩子环境变量注
// 钩子靠这两个变量决定档位与「有没有本地界面」
assert.equal(env.AGENTMAIL_PERMISSION_MODE, tier);
assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-1');
// 禁用清单必须真的传下去:它是「平台不问」时唯一的替代防线。
const denied = h.calls[0].opts.disallowedTools;
assert.ok(Array.isArray(denied) && denied.length > 20, '禁用清单未传给 ZCode');
for (const must of ['Bash', 'Write', 'Edit', 'js', 'mcp__node_repl__js']) {
assert.ok(denied.includes(must), `禁用清单缺少 ${must}`);
}
} finally {
await h.cleanup();
}
@ -368,3 +375,34 @@ test('读回的记录形状可直接交给共用去重判据', async () => {
assert.ok(rec.replyTos.has('m1'));
await rm(dir, { recursive: true, force: true });
});
// ─── 档位强制力自报(必须如实,否则是在替不存在的能力背书)────────────
test('★ 自报 native 要有真凭据门禁链就绪yolo + 够长的禁用清单)', async () => {
const { detectModeEnforcement } = await import('../src/index.mjs');
const r = detectModeEnforcement({ env: {} });
assert.equal(r.enforcement, 'native');
// 理由里必须点出**谁**在把关。以前这里写的是「钩子已注册」,而 yolo 下
// 钩子根本不会触发 —— 那种理由会让人以为平台在管,实际平台什么都没管。
assert.match(r.reason, /门禁|请示/);
assert.doesNotMatch(r.reason, /^钩子已注册/, '不能拿钩子当唯一凭据');
});
test('★ 反向对照:门禁链断了就必须降级成 advisory', async () => {
const { detectModeEnforcement } = await import('../src/index.mjs');
// 禁用清单被清空 = 平台自带 Bash/Write/js 全都还回去了 —— 此时即使钩子
// 清单正常,也不再是「该档位被强制」。
const r = detectModeEnforcement({ env: { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: '' } });
assert.equal(r.enforcement, 'advisory');
});
test('★ 只拿得到钩子、拿不到门禁时不能硬报 native', async () => {
const { detectModeEnforcement } = await import('../src/index.mjs');
const r = detectModeEnforcement({
hooksFile: '/nonexistent/hooks.json',
env: {}
});
// 门禁就绪 → 仍然 native我们拦得住但理由里不能声称有钩子
assert.equal(r.enforcement, 'native');
assert.doesNotMatch(r.reason, /钩子已注册/);
});

View File

@ -109,3 +109,48 @@ test('失败回信在没有任何尝试记录时也不崩', () => {
const body = renderTurnFailure(undefined, undefined);
assert.match(body, /已尝试 0 次/);
});
// ─── 能力说明(平台把自带危险工具禁掉了,模型必须知道)─────────────────
test('★ workspace 档:说清自带工具被禁、动手要用我们的工具、会被请示', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'workspace' }) });
assert.match(p, /Bash \/ Write \/ Edit \/ js/, '必须点名哪些自带工具不可用');
assert.match(p, /禁用/);
assert.match(p, /run_command/);
assert.match(p, /write_file/);
assert.match(p, /申请授权/, '模型必须知道动手会先请示');
// 被拒是业务结果而非故障,且**不能靠重试或绕道** —— 这三件事必须都说
assert.match(p, /报错并给出原因/);
assert.match(p, /不要重试/);
assert.match(p, /绕道|其它执行手段/);
// 只读工具要明确可用,否则模型会以为自己什么都干不了
assert.match(p, /Read \/ Glob \/ Grep/);
});
test('★ plan 档:明说不能动手,别浪费一轮去试', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'plan' }) });
assert.match(p, /plan 档/);
assert.match(p, /不能\*\*执行命令或写文件|不能\*\*执行/);
assert.match(p, /一律拒绝/);
assert.doesNotMatch(p, /申请授权/, 'plan 档不该说会去申请授权(它根本不会发请求)');
});
test('★ full 档:明说免问(否则模型会以为每步都要等人,反而不敢动手)', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'full' }) });
assert.match(p, /full 档/);
assert.match(p, /直接生效/);
assert.match(p, /不会打扰|无需/);
assert.doesNotMatch(p, /第一次调用会先向发件人申请授权/);
});
test('★ 反向对照:三个档位的说明互不相同(写死一档会让另两档撒谎)', () => {
const texts = ['plan', 'workspace', 'full'].map(t =>
buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: t }) })
);
assert.equal(new Set(texts).size, 3, '三个档位给出的能力说明必须各不相同');
});
test('没有 permission_mode 时按 workspace平台默认档说明', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail() });
assert.match(p, /workspace 档/);
});

View File

@ -1,56 +1,66 @@
/**
* 档位 → ZCode `--mode` 映射的测试。
* 档位 → ZCode `--mode` + `--disallowed-tools` 的测试。
*
* 这个映射是**授权系统存不存在**的开关ZCode 的判定里 yolo 一律 allow
* 而 `--prompt` 的默认 mode 就是 yolo。映射写错不会报错,只会让全部授权询问
* 静默消失 —— 所以它是本项目里少数几个「错一个值等于功能整体失效」的地方。
* 这一对参数是**授权系统长什么样**的开关ZCode 的判定里 yolo 一律 allow
* 而 `--prompt` 的默认 mode 就是 yolo —— 也就是说 `--mode` 漏传或写错,
* 平台自己那道防线会静默消失。我们现在的姿态是**故意让平台让开**
* 于是安全边界完全落在两处:这张审过的禁用清单,以及我们自己的门禁。
* 所以本文件的两组断言是配对的:
*
* 1. mode 映射:哪些档位允许平台「不问」
* 2. 禁用清单:平台不问的时候,它自带的一切「能动机器」的工具是否都被拿掉
*
* 单独看任何一组都推不出「安全」yolo + 完整清单 = 门禁在我们手里;
* yolo + 漏一项 = 有一条路可以不过门禁。所以第 2 组里有一条**穷举性**的断言。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { zcodeModeForTier, modeReachesPermissionHook, describeTier, ZCODE_MODES } from '../src/turn-mode.mjs';
import {
zcodeModeForTier,
denylistForTier,
ourGateIsActive,
modeReachesPermissionHook,
describeTier,
ZCODE_MODES,
REVIEWED_DENYLIST
} from '../src/turn-mode.mjs';
test('★ workspace 映射到 plan不是 buildfull 映射到 yolo', () => {
// build 在 headless 下等于「什么都不行」MCP 工具的 needsApproval 硬编码为真,
// 而 headless 没有审批客户端 → 连 read_inbox 都被拒(实测)。
// plan 是真的 fail-closed危险的自带工具被平台拒我们的非破坏性工具放行
test('★ workspace full 都是 yolo门禁在我们手里plan 仍是 plan', () => {
// 为什么 workspace 也敢用 yolo平台上没有第二道防线可用 ——
// MCP 工具的 needsApproval 硬编码为真headless 没有审批客户端 ⇒ build/edit 档下
// 连 read_inbox 都被拒全不可用PermissionRequest 钩子在本版本不可靠(见 README
// 于是选择是「平台问、但问不到人 → 全拒」还是「平台不问、我们自己问」。
// 后者才是真的可用且仍然可审计。
assert.equal(zcodeModeForTier('plan'), 'plan');
assert.equal(zcodeModeForTier('workspace'), 'plan');
assert.equal(zcodeModeForTier('workspace'), 'yolo');
assert.equal(zcodeModeForTier('full'), 'yolo');
});
test('★ 映射可被 AGENTMAIL_ZCODE_MODE_MAP 覆盖(平台修好后不必等发版)', () => {
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:build' }), 'build');
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:yolo,full:plan' }), 'yolo');
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:plan,full:plan' }), 'plan');
// 非法值被忽略,不改变默认
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:nonsense' }), 'plan');
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: '' }), 'plan');
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:nonsense' }), 'yolo');
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: '' }), 'yolo');
});
test('★ 只有 full 档会得到 yolo', () => {
// 反向对照:如果任何其它档位(含拼错的、空的、未知的、大小写不对的)
// 也能得到 yolo那就意味着一个打字错误会关掉整个授权系统
for (const tier of ['plan', 'workspace', '', undefined, 'worjspace', 'default', 'FULL', 'Full']) {
test('★ 认不出来的档位不会变成全权:门禁仍然在管', () => {
// 共用库的 normalizeMode 把一切认不出来的值归到 **workspace**(不是原样退回、
// 也不是报错。所以「mode 是不是 yolo」已经不是安全性质了 —— workspace 也是 yolo
// 真正的性质是:**只有 full 档能让门禁闭嘴**,而 full 只能由"完全匹配的小写 full"触发。
for (const tier of ['nonsense', undefined, '', 'PLAN', 'Plan', 'FULL', 'Full', 'x', null]) {
assert.notEqual(
zcodeModeForTier(tier),
'yolo',
`档位 ${JSON.stringify(tier)} 不该得到 yolo实际 ${zcodeModeForTier(tier)}`
'full',
`档位 ${JSON.stringify(tier)} 不该被当成 full`
);
assert.equal(ourGateIsActive(tier), true, `档位 ${JSON.stringify(tier)} 下门禁必须在管`);
}
});
test('★ 大写 FULL 不认,落在安全侧', () => {
// 共用库的 normalizeMode 是严格匹配的(只认小写),实测 FULL → workspace。
// 这是**刻意保留**的好性质:认不出来时不会掉进「免授权」那一档,
// 而是退回 default。这条断言把它钉住 —— 哪天有人「顺手」改成大小写不敏感,
// 就会有一个打字错误变成全权授权的风险面。
assert.equal(zcodeModeForTier('FULL'), 'plan');
assert.equal(zcodeModeForTier('PLAN'), 'plan');
});
test('未知档位退回 plan安全侧不是 yolo', () => {
assert.equal(zcodeModeForTier('nonsense'), 'plan');
assert.equal(zcodeModeForTier(undefined), 'plan');
// 反向对照:只有真正的小写 full 才关掉门禁。
assert.equal(ourGateIsActive('full'), false);
assert.equal(ourGateIsActive('workspace'), true);
assert.equal(ourGateIsActive('plan'), true);
});
test('产出的 mode 必须是 ZCode 认识的值', () => {
@ -59,31 +69,114 @@ test('产出的 mode 必须是 ZCode 认识的值', () => {
}
});
test('只有 full 档会得到 yolo覆盖后仍成立', () => {
assert.equal(zcodeModeForTier('full', {}), 'yolo');
assert.equal(zcodeModeForTier('workspace', {}), 'plan');
assert.equal(zcodeModeForTier('plan', {}), 'plan');
});
test('只有 build / edit 会让危险操作走到授权钩子', () => {
test('只有 build / edit 会让危险操作走到平台授权钩子', () => {
assert.equal(modeReachesPermissionHook('build'), true);
assert.equal(modeReachesPermissionHook('edit'), true);
// plan 由 ZCode 自己就拒了yolo 直接放行 —— 两者都不产生询问
// plan 由 ZCode 自己就拒了yolo 直接放行 —— 两者都不产生询问
// 注意yolo 下「没有询问」不再等于「没人把关」,所以日志必须另有说法(见下)。
assert.equal(modeReachesPermissionHook('plan'), false);
assert.equal(modeReachesPermissionHook('yolo'), false);
});
test('★ 反向对照plan 与 full 都不产生询问,但原因不同', () => {
// 两条路都不产生 PermissionRequest却在日志里必须能区分
// 一个是「只读ZCode 拒了」,一个是「全权,刻意不问」。
test('★ 反向对照plan 与 workspace 都不产生平台询问,但原因不同', () => {
const plan = describeTier('plan');
const full = describeTier('full');
assert.notEqual(plan, full);
const workspace = describeTier('workspace');
assert.notEqual(plan, workspace);
assert.match(plan, /只读/);
assert.match(full, /全权/);
// workspace 在本平台退到 plan日志里必须说清**为什么**退
// (否则人只会看到「为什么它什么都不做」而无从判断)
assert.match(describeTier('workspace'), /headless 做不到|只读/);
// 显式覆盖回 build 时,说明恢复成「会走到授权钩子
// workspace 的说明必须点出「谁在把关」——否则人看到「--mode yolo」会以为
// 授权系统被关掉了,而真实情况是平台不问、我们逐次请示。
assert.match(workspace, /门禁|请示/);
assert.match(workspace, /禁用/);
// 显式覆盖回 build 时,说明恢复成「平台会问、钩子转达
assert.match(describeTier('workspace', 'build'), /授权钩子/);
});
test('★ 三个档位都拿到同一张禁用清单(只有一条代码路径)', () => {
// 如果某个档位「忘了」加禁用清单,那一档就会多出 Bash/Write/js —— 而它们
// 恰好是绕过门禁的方式。所以这里逐个档位验,而不是只验默认档。
const base = denylistForTier('workspace', {});
for (const tier of ['plan', 'workspace', 'full', 'nonsense', undefined]) {
assert.deepEqual(denylistForTier(tier, {}), base, `档位 ${tier} 的清单不一致`);
}
});
test('★ 穷举性:一切「能动机器」的自带工具都在清单里', () => {
// 这份名单来自 CLI 产物里模型可见工具名的权威注册表aIn 那个 28 项数组),
// 并与另一处更宽的候选集取并集。不采信模型自述 —— 实测基线里它用某个
// 没点名的方式真的创建了文件。
//
// 断言方式刻意选「逐项列出 + 已审阅」而不是「与某个运行时清单对比」:
// 后者需要一个可信来源,而唯一的来源就是这份清单本身(循环论证)。
// 所以这条测试的作用是**把审阅结论钉住** —— 新增/删除一项都必须来改它。
const mustBlock = [
// 机器改动
'Bash',
'Write',
'Edit',
'ApplyPatch',
'NotebookEdit',
'LSP', // rename 会应用工作区编辑
'EnterWorktree',
'ExitWorktree',
// 等价于 Bash 的 JS 执行通道(挂在 MCP 上,最容易漏)
'js',
'mcp__node_repl__js',
'js_reset',
'js_add_node_module_dir',
'mcp__node_repl__js_reset',
'mcp__node_repl__js_add_node_module_dir',
// 延迟执行:把危险动作挪到没人看着的时候
'CronCreate',
'CronUpdate',
'CronDelete',
'CronList',
'ScheduleWakeup',
'Workflow',
// 子代理 / 后台任务(工具集是否继承本清单未验证)
'Agent',
'Task',
'TaskCreate',
'TaskGet',
'TaskList',
'TaskOutput',
'TaskStop',
'TaskUpdate',
// 绕过 AgentMail 的对外通道
'SendMessage',
'RespondToCoordinator',
// 档位逃生门
'EnterPlanMode',
'ExitPlanMode'
];
for (const name of mustBlock) {
assert.ok(REVIEWED_DENYLIST.includes(name), `禁用清单缺少 ${name}`);
}
});
test('★ 保留的必须是只读或纯本地状态(不能顺手把执行能力留下来)', () => {
// 反向对照:清单是黑名单,漏一项就是开一个洞。反过来「多禁」只会少个能力,
// 所以这里的断言是**确保没有把危险的东西留在允许侧**。
const dangerous = ['Bash', 'Write', 'Edit', 'ApplyPatch', 'js', 'mcp__node_repl__js', 'Agent'];
for (const name of dangerous) {
assert.ok(!['Read', 'Glob', 'Grep', 'TodoWrite'].includes(name), '测试自身写错了');
assert.ok(REVIEWED_DENYLIST.includes(name), `${name} 必须被禁`);
}
});
test('★ 禁用清单可以被配置整表替换(收紧与放宽都要能改)', () => {
// 整表替换而不是追加:收紧(连 WebFetch 一起拿掉)与本地调试(临时还回 Bash
// 是同一个旋钮的两端。
assert.deepEqual(denylistForTier('workspace', { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: 'Bash Write' }), [
'Bash',
'Write'
]);
assert.deepEqual(denylistForTier('workspace', { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: 'Bash,Write' }), [
'Bash',
'Write'
]);
// 空串 = 一张空清单,与「没设置」不同(没设置要用默认清单)。
// 这个区分很重要:把空串当默认会让「我想全放开」变成「我在用默认」,
// 而两者只差一个环境变量的有无。
assert.deepEqual(denylistForTier('workspace', { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: '' }), []);
assert.ok(denylistForTier('workspace', {}).length > 20);
});

View File

@ -60,18 +60,29 @@ test('resume 只在有时才带(首轮不该带空 --resume', () => {
assert.equal(next[next.indexOf('--resume') + 1], 'sess_1');
});
test('maxTurns 与工具黑白名单按需传递', () => {
test('maxTurns 与禁用清单按需传递', () => {
const args = buildRunArgs({
prompt: 'p',
cwd: '/tmp',
mode: 'plan',
maxTurns: 6,
allowedTools: ['Read', 'Grep'],
disallowedTools: ['Bash']
disallowedTools: ['Bash', 'Write']
});
assert.equal(args[args.indexOf('--max-turns') + 1], '6');
assert.equal(args[args.indexOf('--allowed-tools') + 1], 'Read,Grep');
assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'Bash');
assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'Bash,Write');
});
test('★ --allowed-tools 被拒于拼参数阶段(本版本 CLI 不认这个选项)', () => {
// 实测CLI 的 help 里写着 --allowed-tools但解析器报 `Unknown option`
// 然后打印 usage 并退出。如果这里静默拼进去,调用方要等一两分钟后
// 拿到一段 usage 文本才能开始查 —— 而且很容易被当成"模型没照做"。
// 所以错误必须在这里就报,且说清替代方案。
assert.throws(
() => buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'plan', allowedTools: ['Read'] }),
/不支持 --allowed-tools/
);
// 反向对照:空的 allowedTools 不该报错(调用方可能无条件传一个数组)。
assert.doesNotThrow(() => buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'plan', allowedTools: [] }));
});
// ─── 解析 ─────────────────────────────────────────────────────────