CHAPTER 3 / 5
Tool & Permission Gating 工具与权限门控
概念与为什么
前两章里,模型只是个”动脑子的”:产出文本和 tool_use blocks。从这一章开始,模型要动手了 —— 而 tool call 是它改变世界的唯一通道:写文件、跑命令、访问网络。
这里有一条本章最重要的概念分界,先立起来:
- Permission(权限层)= 该不该做 —— 一道咨询性的安检,由规则、用户、甚至另一个模型来回答。
- Sandbox(沙箱层)= 能不能做 —— 一道硬边界,由操作系统内核强制执行,与任何人的”意见”无关。
一个 tool call 从意图到行动,要过一整条安检通道:
flowchart TD
M["tool_use block<br/>= 模型的意图"] --> G1["1. schema 校验 + hooks 拦截"]
G1 --> G2{"2. 权限规则<br/>deny / allow / ask"}
G2 -- "deny" --> X["拒绝 → 错误作为 tool result 喂回模型"]
G2 -- "ask" --> U["用户 / 自动 reviewer"]
U -- "拒绝" --> X
G2 -- "allow" --> G3["3. 执行"]
U -- "批准" --> G3
G3 --> G4["4. (可选) OS sandbox<br/>= 内核级硬边界"]
这个子系统为什么重要:
- 安全底线 —— agent 会犯错、会被 prompt injection 带偏。gating 决定一次”犯傻”的代价是弹个窗,还是
rm -rf半个家目录。 - 信任梯度 —— 好的产品体验是”越走越顺”:第一次问,之后记住。这套 graduated autonomy 全靠权限层的规则与记忆设计。
- 可审计性 —— 企业场景里,“谁批准了什么”必须能回答。批准记在内存还是落盘,差别就在这里。
三家实现对比
工具的定义与暴露:先让模型”看不见”
Claude Code 与 OpenCode 都把”不给用”的一道防线做在工具暴露上 —— 被 deny 的工具不会进入模型看到的工具列表。本章没有为 Codex 建立同样机制的证据。
Claude Code 的工具统一走 buildTool() 构造;默认 metadata 把未声明的工具视为不并发安全、非只读,但默认 checkPermissions 返回 allow,不能把整组默认值概括成 fail-closed。注册表是硬编码的 getAllBaseTools(),再叠加按环境/feature flag 过滤和 MCP 合并:
src/Tool.ts:759—isConcurrencySafe默认为 false,:760的isReadOnly也默认为 false;:762-766的默认checkPermissions返回 allow。(路径相对reference/claude-code/;闭源快照,只标位置)
src/tools.ts:268—return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))—— 命中 deny 规则的工具,模型永远看不到。
OpenCode 同款设计,deny * 的工具在组装请求时被过滤掉;它的注册表还对用户自定义工具和 plugin 工具开放:
packages/opencode/src/session/llm/request.ts:213—Record.filter(input.tools, ... !disabled.has(k))—— 被 deny 的工具不进请求。
packages/opencode/src/tool/registry.ts:196— plugin 提供的工具作为 custom tools 进注册表:for (const [id, def] of Object.entries(p.tool ?? {})) {。
Codex 在 router 边界把模型响应项解析成 ToolCall;构建 tool router 时会同时得到 model-visible specs 与 registry:
codex-rs/core/src/tools/router.rs:112—pub fn build_tool_call(item: ResponseItem) -> Result<Option<ToolCall>, FunctionCallError>。
codex-rs/core/src/tools/spec_plan.rs:162—build_tool_specs_and_registry(...)同时返回两者。
派发路径:三道拦截点
Claude Code:runTools 按并发安全性分批后,每个 tool call 走 runToolUse 完整生命周期 —— schema 校验 → PreToolUse hooks → 权限解析 → 执行:
src/services/tools/toolExecution.ts:615— 第一件事是校验输入:const parsedInput = tool.inputSchema.safeParse(input)。
src/services/tools/toolExecution.ts:800— PreToolUse hooks 在权限检查之前运行:for await (const result of runPreToolUseHooks(。
src/services/tools/toolExecution.ts:1207— 全部通过后才const result = await tool.call(。
Codex:并发门控之后进 registry,pre-tool-use hooks 可以直接阻断。已核查的 shell 与 apply_patch runtime 都使用 ToolOrchestrator 处理批准和 sandbox;本节不把这一点外推为所有只读工具的完整分类:
codex-rs/core/src/tools/registry.rs:508— hook 返回Blocked时,调用中止、错误回给模型:PreToolUseHookResult::Blocked(message) => { let err = FunctionCallError::RespondToModel(message);。
codex-rs/core/src/tools/orchestrator.rs:4— orchestrator 的自我陈述:“Central place for approvals + sandbox selection + retry semantics”。
codex-rs/core/src/tools/runtimes/shell.rs:4/apply_patch.rs:1— 两个 runtime 的模块注释都明确写出 orchestrator。
OpenCode:每个注册项被包成 AI SDK tool(),plugin 的 before/after hooks 包住执行;这个 generic wrapper 里没有中央权限关卡。需要权限的 built-in 会在自己的实现里调用 ctx.ask,但不是每个 built-in 都这样做:
packages/opencode/src/session/tools.ts:99—tools[item.id] = tool({。
packages/opencode/src/session/tools.ts:106/tools.ts:121— 每次执行前后分别触发 plugin hooks。
权限决策:三种世界观
Claude Code:严格排序的 cascade。 决策按固定顺序短路:deny 规则 → ask 规则 → 工具自己的 checkPermissions → bypass 模式 → allow 规则 → 默认 ask。deny 永远第一,bypass 也翻不了 deny 的盘:
src/utils/permissions/permissions.ts:1171— 第一步永远是查 deny:const denyRule = getDenyRuleForTool(...)。
src/utils/permissions/permissions.ts:1269—bypassPermissions模式在 deny/ask/safety 检查之后才短路放行。
src/utils/permissions/permissions.ts:929-948— headless / avoid-prompt 场景先运行 PermissionRequest hooks;只有 hook 没有给出 decision 时,ask 才 fallback 为 deny:“Permission prompts are not available in this context”。
Bash 的规则匹配采用 AST 级 security parse:Claude Code 的 tree-sitter 结果会产出 SimpleCommand[] 或 too-complex,OpenCode 的 shell 在 ctx.ask 前先 parse 再 scan 命令。这比只用正则切 &&/;/| 更能保留 shell 结构。
src/tools/BashTool/bashPermissions.ts:1670— Claude Code 注释说明 AST-based security parse 与SimpleCommand[]。
packages/opencode/src/tool/shell.ts:622— OpenCode 在 collect 后于:627调用ctx.ask。
Codex:声明式 policy engine + 分层审批。 两条独立轨道:审批政策 AskForApproval(untrusted / on-request / granular / never)和 sandbox 政策 SandboxPolicy(read-only / workspace-write / danger-full-access / external-sandbox):
codex-rs/protocol/src/protocol.rs:913—pub enum AskForApproval {四档。
codex-rs/protocol/src/protocol.rs:1000—pub enum SandboxPolicy {四档。
shell 命令先过声明式的 execpolicy 引擎(前缀规则,三种决策:allow / prompt / forbidden),没匹配上的再退回”已知安全命令”启发式:
codex-rs/execpolicy/src/decision.rs:9—pub enum Decision {—— 恰好三种决策。
codex-rs/core/src/exec_policy.rs:702— 未匹配的 generic 命令退回is_known_safe_command(command),启发式。
审批请求走事件流:ExecApprovalRequest 事件发出去,oneshot channel 等回来;审批者可以是用户,也可以是一个叫 Guardian 的自动 reviewer 子代理 —— 无人值守时让第二个模型当门卫:
codex-rs/core/src/session/mod.rs:2188—let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {。
codex-rs/core/src/tools/approvals.rs:223—let resolution = match reviewer { ApprovalReviewer::Guardian => {。
OpenCode:last-rule-wins 的规则表。 当前工具上下文把 agent 与 session permission 合并为 ruleset;求值时 findLast —— 最后一条匹配的规则胜出,wildcard 匹配:
packages/opencode/src/session/tools.ts:87—ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),。
packages/opencode/src/permission/index.ts:31—.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern))。
packages/opencode/src/permission/index.ts:75— deny 直接抛DeniedError中止执行。
packages/opencode/src/permission/index.ts:100— ask 则发布permission.asked事件,挂起等 client 回复。
默认规则体现了产品性格:读 .env 文件默认 ask;连续 3 次一模一样的 tool call 会触发 doom_loop 权限询问 —— 死循环检测被做成了权限系统的一等公民:
packages/opencode/src/agent/agent.ts:132—"*.env": "ask",。
packages/opencode/src/session/processor.ts:29—const DOOM_LOOP_THRESHOLD = 3;:373 — 以permission: "doom_loop",发起询问。
注意 OpenCode 的 built-in 权限检查是去中心化的:需要门控的实现会在自己的 execute 路径里调 ctx.ask;例如 edit 报文件路径、shell 报命令扫描结果、webfetch 报 URL。并非所有 built-in 都调用它;custom plugin tool 也只是收到可选用的 ask callback,框架不会强制调用。MCP 工具则相反,在 wrapper 里中央统一 catch-all:
packages/opencode/src/tool/edit.ts:102/tool/shell.ts:283/tool/webfetch.ts:39— permission-gated built-in examples。
packages/opencode/src/tool/registry.ts:145— custom plugin context 暴露askcallback;:149直接调用 plugin 的execute,没有强制先 ask。
packages/opencode/src/session/tools.ts:408— MCP 工具:yield* ctx.ask({ permission: key, ..., patterns: ["*"], always: ["*"] })。
硬边界:sandbox,三家的根本分歧
Codex 提供 OS sandbox 执行路径:macOS 选择 Seatbelt,Linux helper 默认用 bubblewrap 构造 filesystem view,再施加 seccomp / no_new_privs;Landlock 是 legacy fallback。Windows 的 restricted token 则只有在配置启用时才被选择:
codex-rs/sandboxing/src/manager.rs:60— 平台选择:if cfg!(target_os = "macos") { Some(SandboxType::MacosSeatbelt) }。
codex-rs/linux-sandbox/src/linux_run_main.rs:77/linux_run_main.rs:140— Linux helper 的默认 bubblewrap 与后续 seccomp 流程。
它的哲学一句话说透:approval 是咨询,sandbox 才是边界。所以被 sandbox 拒掉的命令不是直接放行,而是走一条fail-closed 的升级路径(见下方代码走读)。
Claude Code 在已核查的 shell spawn 路径上包 sandbox:
src/utils/Shell.ts:260—commandString = await SandboxManager.wrapWithSandbox(—— 只发生在 shell 执行路径上。
OpenCode:本章对 packages/opencode/src 作了定向 source search,seatbelt|landlock|bubblewrap|seccomp|restricted token|wrapWithSandbox 均为零命中;泛搜 sandbox 则还会命中 worktree、code-mode 类型与 prompt 等非 OS-boundary 用途。因此这里仅能说:本章未在该目录找到这些 OS sandbox 实现关键词。其权限检查主要由 permission 层承载。
“别再问了”的记忆
批准的记住方式,三家一个比一个短:
src/utils/permissions/PermissionUpdate.ts:219—persistPermissionUpdate()将 permission update 写入相应 settings source;:211列出可持久的 local/user/project destinations。
codex-rs/core/src/tools/sandboxing.rs:91— 已批准的 shell key 从 session 内存 store 查询。execpolicy amendment 可以同时写入内存与磁盘:session/mod.rs:1980。MCP prompt 可提供 session/persistent remember 选项:mcp_tool_call.rs:1201;持久接受会调用 persistence:mcp_tool_call.rs:1964。
packages/opencode/src/permission/index.ts:47/permission/index.ts:146— OpenCode 的 “always” 追加到 Permission service state 中初始化的approved数组。
Hooks 的权力边界
三家的 hook 语义并不相同,不能统一概括成“只许收缩”:Claude Code 的 allow 可以在没有 deny/ask rule 时跳过 interactive prompt;Codex 明确定义 block 与 updated input;OpenCode 通过可变 output 和 Promise 异常控制执行流。
src/utils/hooks.ts:552— Claude Code 的 PreToolUse hook 可以用 JSON 返回 allow/deny/ask;src/services/tools/toolHooks.ts:372说明 allow 可跳过 interactive prompt,但:373仍重查规则,deny/ask rule 保持优先。
codex-rs/core/src/tools/registry.rs:508/registry.rs:519— codex 的 hook 能 block,或以updated_input建立更新后的 invocation。OpenCode 的 plugin hook signature 没有专门的 veto return value,但 trigger 会 await 每个 hook Promise;throw/reject 会中止当前执行路径。
packages/opencode/src/plugin/index.ts:280/packages/web/src/content/docs/plugins.mdx:250。
代码走读
这章看 codex 的 sandbox 升级路径(orchestrator.rs)—— 工具先在 sandbox 里试跑;只有被 sandbox 拒绝(而非普通失败)才进入升级判定。前几道拒绝闸门是 fail-closed,但 retry approval 是否重新请求还取决于 bypass_retry_approval 条件(节选 orchestrator.rs:294-420):
match first_result {
Ok(out) => { /* 成功,直接返回 */ }
// (1) 只有 SandboxErr::Denied 才进入升级路径;其他错误原样冒泡
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. }))) => {
// (2) 工具自己不申请升级 → 拒绝
if !tool.escalate_on_failure() {
return Err(...Denied...);
}
// (3) 审批政策不允许升级 → 拒绝,保留原始报错
if !tool.wants_no_sandbox_approval(approval_policy) {
...
return Err(...Denied...);
}
// (4) 非 bypass 时才重新向用户/Guardian 请求批准;
// retry sandbox 也由后续 policy/selection 决定
...
}
}
codex-rs/core/src/tools/orchestrator.rs:302— 升级路径的入口 match 分支。
codex-rs/core/src/tools/orchestrator.rs:344—if !tool.wants_no_sandbox_approval(approval_policy) {。
codex-rs/core/src/tools/orchestrator.rs:392— strict auto-review 必须 fresh Guardian review;其他条件满足时可 bypass retry approval。
前三层是明确的拒绝闸门;第四层是条件式 retry approval:
- 错误类型闸门 —— 只有
SandboxErr::Denied能进来。普通执行错误(命令真的跑输了)不会触发提权,防止”失败就重试裸奔”。 - 工具意愿闸门 ——
escalate_on_failure()由工具自己声明。工具说”我就该在沙箱里跑”,谁劝都没用。 - 政策闸门 ——
wants_no_sandbox_approval()不允许升级时返回原始 sandbox denial;网络 approval 有单独的例外处理。 - retry approval 闸门 —— strict auto-review 下必须重新经 Guardian 审核;非 strict 场景若已有批准且满足
bypass_retry_approval,可以复用批准。随后 retry 是否继续 sandbox 由后续 selection 决定。
这条路径的重点是把普通执行失败与 sandbox denial 分开,并把 retry approval 和 sandbox selection 变成显式条件;这避免了“失败就自动无 sandbox 重试”的简单回退。
设计权衡
| 决策点 | Claude Code | Codex CLI | OpenCode |
|---|---|---|---|
| deny→隐藏工具 | 有(tools.ts:268) | 本章未建立此机制的证据;router 构建 ToolCall(router.rs:112) | 有(llm/request.ts:213) |
| 决策模型 | 严格排序 cascade(permissions.ts:1171) | 声明式 policy engine + approval/sandbox 双轨政策(protocol.rs:913,1000) | last-rule-wins 规则表(permission/index.ts:31) |
| shell 命令分析 | tree-sitter AST security parse(bashPermissions.ts:1670) | parser + 安全名单启发式(exec_policy.rs:702) | tree-sitter 命令扫描(tool/shell.ts:622) |
| hooks 权力 | allow 可跳过 prompt,但 deny/ask rule 优先(toolHooks.ts:372-373) | block / 改写参数(registry.rs:508,519) | 可改 args/output;无专用 veto return,但 throw/reject 会中止(plugin/index.ts:280-292) |
| OS 硬边界 | 已核查的 shell spawn 路径会 wrap(Shell.ts:260) | macOS/Linux platform sandbox;Windows token 需启用(manager.rs:60) | 本章定向搜索未找到常见 OS sandbox 实现关键词 |
| 批准记忆 | settings JSON 持久(PermissionUpdate.ts:219) | session cache + 可落盘 execpolicy amendment(sandboxing.rs:91; session/mod.rs:1980) | Permission service 的 approved state 数组(permission/index.ts:47,146) |
| 审批者 | 用户 / auto 分类器 / hooks 竞速 | 用户 / Guardian 子代理(approvals.rs:223) | 用户(TUI 渲染 diff) |
| 拒绝的语义 | tool result 回模型 | RespondToModel 回模型(registry.rs:508) | permission rejection 记录为 failed tool call;是否 break 由 shouldBreak 决定(processor.ts:186) |
| MCP 门控 | 与内置同套规则 | MCP prompt 可提供 session/persistent remember;持久接受调用 persistence(mcp_tool_call.rs:1201,1964) | 中央 catch-all ask(tools.ts:408) |
几个值得玩味的对立:
- 中央 vs 去中心化:Claude Code 把权限收在一个 cascade 里,可推理、可审计;OpenCode 让需要门控的 built-in 自己调用
ctx.ask,模式串更精确(只有工具自己知道要改哪个文件),但自定义工具若不调用收到的 callback,就不会从这里获得同一层门控。MCP 工具的中央 catch-all 正是另一种选择。 - 持久化的风险偏好:同一句”always allow”,Claude 的 permission update 可持久写 settings;Codex 的 session cache 与可落盘 execpolicy amendment 分开;OpenCode 本段展示的是 service state 的
approved数组。存储范围越长,体验与可审计性越需要一并设计。 - sandbox 的有无决定权限层的压力:Codex 的 OS sandbox 路径可提供额外边界;对 OpenCode,本章的定向搜索没有发现常见 OS sandbox 实现关键词,因此其已展示的门控重点落在 permission rules、doom_loop 与
*.env等规则。
迁移到你自己的 agent
- 分别定义工具默认 metadata 与权限默认值:未声明时可保守地视为“不并发安全、非只读”(
Tool.ts:759-760);但这不等于需要批准,因为同一组默认值的checkPermissions返回 allow(Tool.ts:762-766)。 - deny 做两层:模型看不到(不暴露)+ 运行时兜底(再查一次)。防的是 MCP 工具名漂移、alias、模型幻觉出工具名。
- 分清两层,别拿 permission 当 boundary:规则和用户批准都是”意见”。有条件就采用 OS sandbox,让内核提供独立的强制边界;具体平台实现与默认策略应分别核查。
- 升级路径写清条件:被 sandbox 拒绝 ≠ 自动裸跑;先检查错误类型、工具意愿和政策,再明确 retry approval 的 bypass 条件与 retry sandbox selection(
orchestrator.rs:302-420)。 - 把 hook allow 与规则优先级写清楚:如果允许 hook 跳过 interactive prompt,仍应让 deny/ask rules 优先(
toolHooks.ts:372-373);同时明确异常是否会中止工具执行。 - Bash 应做 AST 级分析:正则拆
&&/;/|容易丢失结构。让 parse/scan 的结果进入 permission decision,能避免把 shell 文本当成扁平字符串。 - 把拒绝设计成 tool result:deny 不是崩溃,是喂回模型的一条 error 结果,让模型换路子;特殊场景(doom loop、读
.env)做成权限系统的一等规则,而不是硬编码的特判。 - 想清楚 “always allow” 存哪:写盘最顺滑、内存最安全、policy 文件最可审计。这个选择本质是产品对”信任”的定价。