From a00cbf36fc6fedea8a419d0024554a7e90e194e8 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 19:05:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(zcode):=20yolo=20+=20=E8=87=AA=E6=9C=89?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=9D=A2=20+=20=E6=88=91=E4=BB=AC=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=9A=84=E6=89=A7=E8=A1=8C=E9=97=A8=E7=A6=81=EF=BC=88?= =?UTF-8?q?headless=20=E7=9C=9F=E6=AD=A3=E8=83=BD=E5=B9=B2=E6=B4=BB?= =?UTF-8?q?=E4=BA=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户裁定「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、会抢邮件)。 ## 判据纪律(本轮又踩到、已写进代码注释) 「文件没被创建」不能区分「被拦住了」与「进程根本没跑」; 「未获批准」不能区分「人拒绝」与「窗口被截断」; 「工具报错」不能区分「命令失败」与「工具坏了」。 每一处都改成了验到**具体成因**。 --- deploy/zcode-mail-bridge.service | 7 +- .../.zcode-plugin/plugin.json | 3 +- plugins/zcode-mail-bridge/README.md | 132 ++++++- .../zcode-mail-bridge/lib/action-tools.mjs | 336 +++++++++++++++++ plugins/zcode-mail-bridge/lib/approval.mjs | 300 +++++++++++++++ plugins/zcode-mail-bridge/mcp/server.mjs | 10 + plugins/zcode-mail-bridge/src/index.mjs | 71 +++- plugins/zcode-mail-bridge/src/prompt.mjs | 52 ++- plugins/zcode-mail-bridge/src/turn-mode.mjs | 216 ++++++++--- plugins/zcode-mail-bridge/src/zcode-run.mjs | 12 +- .../test/action-tools.test.mjs | 329 ++++++++++++++++ .../zcode-mail-bridge/test/approval.test.mjs | 354 ++++++++++++++++++ .../zcode-mail-bridge/test/driver.test.mjs | 44 ++- .../zcode-mail-bridge/test/prompt.test.mjs | 45 +++ .../zcode-mail-bridge/test/turn-mode.test.mjs | 195 +++++++--- .../zcode-mail-bridge/test/zcode-run.test.mjs | 21 +- 16 files changed, 1963 insertions(+), 164 deletions(-) create mode 100644 plugins/zcode-mail-bridge/lib/action-tools.mjs create mode 100644 plugins/zcode-mail-bridge/lib/approval.mjs create mode 100644 plugins/zcode-mail-bridge/test/action-tools.test.mjs create mode 100644 plugins/zcode-mail-bridge/test/approval.test.mjs diff --git a/deploy/zcode-mail-bridge.service b/deploy/zcode-mail-bridge.service index 04a1fb4..0f60928 100644 --- a/deploy/zcode-mail-bridge.service +++ b/deploy/zcode-mail-bridge.service @@ -10,8 +10,11 @@ Type=simple # 驱动自己不需要工作目录(每一轮 cwd 来自邮件寻址的 path 位), # 但 systemd 要求一个存在的目录,且相对路径(`../hooks/hooks.json` 的自检) # 以进程启动位置为准。 -WorkingDirectory=/home/program/agentmail/plugins/zcode-mail-bridge -ExecStart=/usr/bin/node /home/program/agentmail/plugins/zcode-mail-bridge/src/index.mjs +# 生产跑**快照**,不跑仓库工作区:仓库会被我们随手编辑,而邮件驱动的服务 +# 重启后会直接跑起当前 HEAD —— 一次未完成的修改就变成线上行为。 +# 与 pi / opencode / dsh 同一条约定,切换靠 deploy/redeploy-plugin.sh 的原子软链。 +WorkingDirectory=/opt/agentmail/plugins/zcode-mail-bridge/current +ExecStart=/usr/bin/node /opt/agentmail/plugins/zcode-mail-bridge/current/src/index.mjs # ZCode 靠 HOME 定位 ~/.zcode(OAuth 凭据、cli/config.json、会话库、日志)。 # systemd 不会自动注入 HOME,不显式给就: diff --git a/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json b/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json index db5b821..638ffbe 100644 --- a/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json +++ b/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json @@ -14,7 +14,8 @@ "cwd": "${ZCODE_PROJECT_DIR}", "env": { "ZCODE_PLUGIN_ID": "agentmail" - } + }, + "timeoutMs": 600000 } } } diff --git a/plugins/zcode-mail-bridge/README.md b/plugins/zcode-mail-bridge/README.md index 292d72b..4771ba2 100644 --- a/plugins/zcode-mail-bridge/README.md +++ b/plugins/zcode-mail-bridge/README.md @@ -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`)。 + 两边解析出不同路径就会出现「刚点过一直同意又问你一遍」与「同一件事发两封信」。 diff --git a/plugins/zcode-mail-bridge/lib/action-tools.mjs b/plugins/zcode-mail-bridge/lib/action-tools.mjs new file mode 100644 index 0000000..6941c94 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/action-tools.mjs @@ -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); +} diff --git a/plugins/zcode-mail-bridge/lib/approval.mjs b/plugins/zcode-mail-bridge/lib/approval.mjs new file mode 100644 index 0000000..1a9f551 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/approval.mjs @@ -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(); + } +} diff --git a/plugins/zcode-mail-bridge/mcp/server.mjs b/plugins/zcode-mail-bridge/mcp/server.mjs index 337bb8d..a8bc3c0 100644 --- a/plugins/zcode-mail-bridge/mcp/server.mjs +++ b/plugins/zcode-mail-bridge/mcp/server.mjs @@ -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 = { diff --git a/plugins/zcode-mail-bridge/src/index.mjs b/plugins/zcode-mail-bridge/src/index.mjs index 38c9bf8..7964158 100644 --- a/plugins/zcode-mail-bridge/src/index.mjs +++ b/plugins/zcode-mail-bridge/src/index.mjs @@ -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 服务器): diff --git a/plugins/zcode-mail-bridge/src/prompt.mjs b/plugins/zcode-mail-bridge/src/prompt.mjs index 7add4a1..8e30f73 100644 --- a/plugins/zcode-mail-bridge/src/prompt.mjs +++ b/plugins/zcode-mail-bridge/src/prompt.mjs @@ -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'); } diff --git a/plugins/zcode-mail-bridge/src/turn-mode.mjs b/plugins/zcode-mail-bridge/src/turn-mode.mjs index b1180da..730d2ea 100644 --- a/plugins/zcode-mail-bridge/src/turn-mode.mjs +++ b/plugins/zcode-mail-bridge/src/turn-mode.mjs @@ -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}`; } diff --git a/plugins/zcode-mail-bridge/src/zcode-run.mjs b/plugins/zcode-mail-bridge/src/zcode-run.mjs index 4a5df8c..bdc2bec 100644 --- a/plugins/zcode-mail-bridge/src/zcode-run.mjs +++ b/plugins/zcode-mail-bridge/src/zcode-run.mjs @@ -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, cliPath?:string, nodePath?:string, * onChild?:(kill:(signal?:string)=>void)=>void, * onEvent?:(event:any)=>void}} opts diff --git a/plugins/zcode-mail-bridge/test/action-tools.test.mjs b/plugins/zcode-mail-bridge/test/action-tools.test.mjs new file mode 100644 index 0000000..e476516 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/action-tools.test.mjs @@ -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} 太短,人工审批窗口不够`); +}); diff --git a/plugins/zcode-mail-bridge/test/approval.test.mjs b/plugins/zcode-mail-bridge/test/approval.test.mjs new file mode 100644 index 0000000..2da2086 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/approval.test.mjs @@ -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', '非重复的正常请求应该等,然后超时'); +}); diff --git a/plugins/zcode-mail-bridge/test/driver.test.mjs b/plugins/zcode-mail-bridge/test/driver.test.mjs index 8a5c25a..e51de94 100644 --- a/plugins/zcode-mail-bridge/test/driver.test.mjs +++ b/plugins/zcode-mail-bridge/test/driver.test.mjs @@ -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, /钩子已注册/); +}); diff --git a/plugins/zcode-mail-bridge/test/prompt.test.mjs b/plugins/zcode-mail-bridge/test/prompt.test.mjs index a3a6dda..d8f6e29 100644 --- a/plugins/zcode-mail-bridge/test/prompt.test.mjs +++ b/plugins/zcode-mail-bridge/test/prompt.test.mjs @@ -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 档/); +}); diff --git a/plugins/zcode-mail-bridge/test/turn-mode.test.mjs b/plugins/zcode-mail-bridge/test/turn-mode.test.mjs index 7da926e..f4b6436 100644 --- a/plugins/zcode-mail-bridge/test/turn-mode.test.mjs +++ b/plugins/zcode-mail-bridge/test/turn-mode.test.mjs @@ -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); +}); diff --git a/plugins/zcode-mail-bridge/test/zcode-run.test.mjs b/plugins/zcode-mail-bridge/test/zcode-run.test.mjs index b861bf8..259280d 100644 --- a/plugins/zcode-mail-bridge/test/zcode-run.test.mjs +++ b/plugins/zcode-mail-bridge/test/zcode-run.test.mjs @@ -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: [] })); }); // ─── 解析 ─────────────────────────────────────────────────────────