Files

217 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ZCode Auto 审批员(gatekeeper)
对标 **Claude Code auto mode** 的 ZCode 外置实现:由一个独立的小模型在 ZCode 每次工具调用执行前,对照"当前任务"审查是否越权——
- 任务内的操作**自动放行**,不打断你;
- 越权/危险的**拒绝并说明理由**,理由回传给主模型,它会自行调整方案;
- 审查器自身故障时 **fail-closed**(拒绝执行,与 Claude Code 的兜底语义一致)。
审查分类、决策清单、`shouldBlock` 判定 schema 与 fail-closed 语义均对照 Claude Code auto mode 的实际行为设计。
## 功能特性
- 任务感知:审查员能看到你最近输入的任务,据此判断"是否越权",而不是死板的关键词匹配
- 全工具覆盖:shell 命令、文件写入、MCP 工具都在审查范围内
- 分层流水线:只读操作零延迟直通,灰区才调用审查模型(实测 ~0.7–1.5s)
- 灾难命令黑名单:删盘/格式化/递归删除根目录等**不经任何模型、确定性拦截**
- 弹窗分级可配:模型"拿不准"时可以选择弹窗问你、硬拒或放行(`onUncertain`)
- 逃生舱:被拒的命令支持一次性批准(绑定目录/工具/会话、用后即焚、审计留痕)
- 防注入:模型伪造"用户已同意"等授权文字会被识别为注入并直接拒绝
- 常驻 daemon:双向 HMAC 身份校验、审查连接热复用、上下文绑定缓存、配置热重载
- 审计日志:每次决策(放行/拒绝/来源/耗时)全部落盘可查
## 环境要求
- ZCode 桌面版(hooks 在会话启动时加载,改配置后需重启会话)
- Node.js ≥ 18(自带 fetch,零第三方依赖)
- 一个 OpenAI 兼容的审查端点(任意提供 chat/completions 的服务)
## 快速开始
```bash
git clone <本仓库> && cd <仓库目录>
node gatekeeper/gatekeeper.mjs install # 一键安装
```
安装器做了三件事:把运行文件复制到 `~/.zcode/gatekeeper/`;在用户级配置 `~/.zcode/cli/config.json` 注册 4 个 hooks(只增删本工具自己的条目,不动你已有的其他 hooks,改动前自动备份);打印后续步骤。
然后:
1. 编辑 `~/.zcode/gatekeeper/config.json`,填入你的审查端点(字段说明见 [配置参考](#configjson-配置参考),模板见 `gatekeeper/config.example.json`);
2. **重启 ZCode 会话**(hooks 在会话启动时快照);
3. 跑一条命令试试:`node gatekeeper.mjs status` 查看状态,`tail -f gatekeeper/state/audit.jsonl` 观察每次决策。
## 工作原理
注册 4 个 ZCode hooks(用户级 `~/.zcode/cli/config.json`):
| 事件 | 模式 | 作用 |
|---|---|---|
| `UserPromptSubmit` | `capture-prompt` | 捕获用户输入的任务原文(最近 5 条),按会话存盘——这是"任务感知"的关键 |
| `PostToolUse` | `capture-tool` | 记录最近 10 次工具调用,供审查器评估"连续安全操作的组合风险" |
| `PreToolUse` | `review --layer=hard` | 硬安全网(每次工具调用都过):只读工具白名单直放 + 灾难命令黑名单确定性拦截(不经任何模型) |
| `PermissionRequest` | `review --layer=full` | 完整审查(ZCode 需要权限决定时):白名单/工作区内编辑快速放行 → 灰区交给审查模型 |
完整层的判定流水线:
1. **工具白名单**:Read/Glob/Grep/WebFetch 等只读工具零成本放行(对照 Claude Code 的 `SAFE_YOLO_ALLOWLISTED_TOOLS`)
2. **编辑快速路径**:Write/Edit 目标在工作区内直接放行(对照 acceptEdits fast path),工作区外交审查
3. **命令白名单**:只读 shell 命令(git status、ls、cat…)放行;复合命令(含 `&&`、`|`、`$()`)不享受白名单,避免管道尾夹带危险别名
4. **灾难黑名单**:删盘、格式化、递归删除根目录、fork 炸弹等——确定性拦截,不依赖审查模型存活
5. **审查模型**:把「任务上下文 + 近期动作 + 待审动作」发给审查模型,二值判定(与 Claude Code 同款):
- `shouldBlock: false` → 自动放行
- `shouldBlock: true` → 拒绝执行,理由回传主模型(含逃生舱指引)
每次决策写入 `gatekeeper/state/audit.jsonl`(自动轮转 5MB)。
## 两种使用模式(按 ZCode 权限模式选择)
**确认模式**(`fullAccessGuard: false`,默认):你自己是审批人。ZCode 的权限弹窗照常出现由你确认;审查员负责在弹窗前拦下灾难命令、并按需对灰区操作做任务感知复核。
**完全访问模式**(`fullAccessGuard: true`):没有弹窗环节,审查员直接上岗——每次灰区工具调用都经 LLM 任务感知审查:放行 → 静默执行;越权 → 拒绝并回传理由;拿不准 → 按 `onUncertain` 配置处理。灾难黑名单全程兜底。改完 config.json 立即生效(无需重启 ZCode),也可用环境变量 `GATEKEEPER_FULL_ACCESS_GUARD=1` 临时开启。
## 拿不准怎么办?(onUncertain 三选一)
审查模型对某个调用拿不准时,落地方式由你决定:
```jsonc
"onUncertain": "deny" // 默认:硬拒,理由回传主模型(无人值守安全,不会被弹窗卡死)
"onUncertain": "prompt" // 升级为 ZCode 原生弹窗问你,你点允许才执行
"onUncertain": "allow" // 拿不准也放行(最流畅,也最信任审查员)
```
> `prompt` 依赖 ZCode 对 PreToolUse `ask` 决策的支持(已在 ZCode 桌面版实测通过,2026-09)。旧版本若不支持会**静默放行**(fail-open),所以模板默认 `deny`;开启前建议用一条命中黑名单的 echo 命令实测:弹窗出现即可放心使用。
**注意:明确的 deny 与灾难黑名单永远硬拒,不受此开关影响**——弹窗只留给"拿不准",人不在场时 agent 不会被卡死。
## 被 deny 了但确实想执行?(逃生舱)
1. **灰区命令(审查模型判的)**:在对话里明确说"我批准执行此命令"——审查员从键盘捕获通道(模型无法伪造)看到你的明确授权后,重试时放行。
2. **灾难黑名单拦截的**:对话内口头批准**无效**(防伪造底线),在终端运行拒绝理由中给出的命令:
```bash
node ~/.zcode/gatekeeper/gatekeeper.mjs approve --last # 推荐:绑定最近拒绝的完整上下文
node ~/.zcode/gatekeeper/gatekeeper.mjs approve "原命令原样粘贴" # 在该命令预期执行目录中运行
```
一次性放行:`approve --last` 会绑定原请求的目录、工具、会话与完整参数;直接 `approve "命令"` 至少绑定当前终端目录。批准用后即焚、10 分钟过期、全程审计。批准后让模型重试即可。
> `approve` 设计给**用户亲自执行**:策略提示词明令模型不得自行运行 approve 给自己放行;非交互环境必须追加 `--yes`(留给你明确授权的场景)。
切换 ZCode 到确认模式**不能**绕过拦截——灾难黑名单在弹窗之前就已生效(设计意图:任何模式下都不放行灾难命令)。
## 配置参考(config.json)
```jsonc
{
"baseUrl": "…", // 任意 OpenAI 兼容端点
"apiKey": "…",
"model": "…", // 建议用与主力模型异构的快速模型(跨模型视角更客观)
"llmTimeoutMs": 12000, // 单次审查请求超时
"retries": 1, // 失败重试次数
"maxOutputTokens": 2000, // 推理模型需要余量,否则判定 JSON 被截断
"onReviewerError": "deny",// 审查器故障时:deny(默认,fail-closed) | prompt | allow
"onUncertain": "deny", // 审查模型拿不准时:deny(默认) | prompt 弹窗 | allow 放行
"fullAccessGuard": false, // 完全访问模式守卫(见"两种使用模式")
"allowOutput": "json", // PermissionRequest 放行时输出决策 JSON(跳过弹窗)
"enableAllowlist": true,
"enableDangerList": true,
"maxInputChars": 6000,
"extraBody": {}, // 深合并进 LLM 请求体的自定义字段(见下)
"daemon": true, // 守护进程模式(false = 每次内联运行)
"daemonPort": 47811,
"daemonIdleMs": 1800000, // daemon 空闲自动退出(30 分钟无调用释放内存,0 = 常驻)
"toolAllowlist": [], // 追加免审工具名
"autoMode": { // 自定义规则(非空段整体替换 policy.md 中对应默认段)
"allow": [], // 应自动放行的动作
"soft_deny": [], // 应拦截的动作
"environment": [] // 你的环境说明(帮助审查器决策)
}
}
```
环境变量:`GATEKEEPER_BASE_URL` / `GATEKEEPER_API_KEY` / `GATEKEEPER_MODEL` / `GATEKEEPER_FULL_ACCESS_GUARD` / `GATEKEEPER_ON_UNCERTAIN` / `GATEKEEPER_ON_REVIEWER_ERROR` / `GATEKEEPER_DAEMON_PORT` / `GATEKEEPER_DAEMON_IDLE_MS`(内联模式直接生效;daemon 模式下 `FULL_ACCESS_GUARD` 随请求透传,其余长期配置建议写入 config.json——会热重载)。
## 自定义"思考程度"(extraBody)
不同厂商控制推理深度的参数五花八门,所以做成**通用注入**:`extraBody` 里的任意 JSON 会深合并进每次审查请求体。例如部分 vLLM 类部署的模型,用下面的配置即可关闭思考(实测判定耗时降低约 60% 且质量不降):
```json
"extraBody": { "chat_template_kwargs": { "enable_thinking": false } }
```
也可以放 `reasoning_effort`、`top_p` 等任何端点支持的参数;为防止覆盖安全策略,`messages` 字段会被忽略。改完即时生效(daemon 热重载配置)。
## 延迟量级(实测参考)
| 路径 | 耗时 | 说明 |
|---|---|---|
| 白名单/快速路径 | ~150ms | 进程开销主导,无 LLM 调用 |
| 灰区判定(审查模型关思考) | ~0.7–1.2s | LLM 推理与网络主导 |
| 灰区判定(思考开启) | ~1.7–2.5s | 推理模型建议关思考 |
| 决策缓存命中 | ~150–180ms | 相同任务+命令 15 分钟内免审 |
## 守护进程(daemon)
客户端每次调用先探测 `127.0.0.1:47811`(无副作用 ping),并用安装目录内随机生成、仅用户可读的 secret 对请求和响应做双向 HMAC 校验;无法读取该 secret 的本地进程即使抢占端口也不能伪造放行结果。daemon 不在就自动拉起;**无调用 30 分钟后自动退出释放内存**(`daemonIdleMs` 可调,0 = 常驻),下次调用自动重新拉起。收益:到审查端点的 TLS 连接常驻、决策缓存驻内存、配置热重载;会话任务状态保持磁盘同步,确保内联降级产生的新输入不会丢失。缓存绑定会话、cwd、近期动作、完整工具参数和策略版本。
```bash
node gatekeeper/gatekeeper.mjs shutdown-daemon # 停止(下次调用自动重新拉起)
GATEKEEPER_NO_DAEMON=1 node gatekeeper.mjs … # 强制内联模式(调试用)
GATEKEEPER_DEBUG=1 node gatekeeper.mjs … # 打印代理通道调试信息
```
## 日常调优
- **误拦了正常命令** → 加进 `allowlist.txt`,或写进 `autoMode.allow`
- **放行了不该放的** → 写进 `autoMode.soft_deny`,或收紧 `policy.md`
- **想换审查模型** → 改 `config.json` 前三项即可
- **审查器不稳定想软着陆** → `onReviewerError: "prompt"`(故障时回落原生弹窗而不是拒绝)
- **查审计** → `tail -f gatekeeper/state/audit.jsonl`
## 威胁模型:恶意上游与提示注入
如果接入的上游(主模型提供方)是恶意的,它能伪造什么、不能伪造什么:
| 通道 | 上游模型能否伪造 | 说明 |
|---|---|---|
| 任务上下文(审查员看到的"用户输入") | **不能** | 由 `UserPromptSubmit` hook 直接从用户键盘输入捕获,模型无法写入 |
| 审查提示词的溯源标记 | **不能** | 策略标明【当前任务】段是键盘原文;`<action>` 内的"用户已同意"字样一律视为注入 |
| 待审动作的参数内容 | **能** | 模型可以在命令里塞"用户已确认"等伪造授权文字 → 防注入规则:视为注入,直接 deny |
| 主模型自己的解释/承诺 | **能,但无效** | 策略明确"模型自我陈述不构成授权",且 transcript 只收录 tool_use、不收录模型文本 |
| `approve` 放行 | **不能** | 终端执行、审计留痕;非交互环境必须 `--yes`,策略禁止模型自行调用 |
诚实的技术边界:**LLM 审查层是"判断层",不是"信任边界"**——它是概率性的,极端注入仍可能绕过。真正的安全边界是本地确定性层:灾难黑名单(不经任何模型)+ `approve` 一次性批准(人工终端操作)+ 确认模式的原生弹窗(人类点击)。另外,若审查端点与主模型同属一个恶意上游,任何 LLM 方案都无意义——建议审查员走与主模型不同的可信渠道,且 `onReviewerError: deny` 保证审查员失效时宁可拒绝。
## 测试
```bash
bash test.sh
```
覆盖 64 项决策路径:白名单/黑名单拦截及语法变体、双 shell 兼容、三种 onUncertain 落地、extraBody 注入、最新任务保留、上下文缓存、作用域绑定 approve、daemon 身份认证与热重载、配置/规则损坏 fail-closed 等。
## 卸载
```bash
node gatekeeper/gatekeeper.mjs uninstall --purge
```
自动反注册 hooks(只移除本工具的条目)、停止 daemon、删除安装目录;用户配置每次改动前都有自动备份(`~/.zcode/cli/config.json.bak-gk-*`)。之后重启 ZCode 会话即可。
## 已知边界
- hooks 在**新会话**生效(ZCode 在会话启动时快照 hooks 配置)。
- `PreToolUse` 硬层不做 LLM 调用(零延迟);LLM 只在需要权限决策或守卫模式时介入。
- 若 ZCode 未识别放行/ask 决策 JSON(严格 schema 变动),退化行为是"恢复 ZCode 原生弹窗",不会误放行——**例外**是 `onUncertain: "prompt"` 依赖的 ask 决策,不支持的旧版本会静默放行(见上,开启前请实测)。
- 灾难命令由黑名单确定性拦截,不依赖审查模型存活。
## License
[Apache License 2.0](LICENSE)
## 致谢
- [Linux.do](https://linux.do)