# 审批流程 · 什么时候停下来问你

> 一次命令或补丁要不要问人，要过三道关：执行策略先判定“跳过、需要批准、禁止”，ToolOrchestrator 据此审批、选沙箱，并在沙箱拒绝后视策略请求提权重试，审批请求再按“钩子、自动审查、用户”的顺序分派。发给用户的请求经 app-server 变成 item/commandExecution/requestApproval，回复沿 Op::ExecApproval 唤醒挂起的 oneshot。默认的 on-request 下，命令失败不会自动弹“去掉沙箱重试”，提权由模型主动申请。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/approvals-flow
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 审批流程 · 什么时候停下来问你

[上一篇](https://daiw.net/manual/codex-source/permissions-model)定义了两个值：配置档划出边界，审批策略决定越界前问不问。这一篇跟着一次工具调用，看这两个值在运行时怎样变成“弹窗还是不弹窗”。

## 用户看到的样子

TUI 的审批框只列出服务端允许的选项（请求里的 `available_decisions`）。普通命令默认两项：`Yes, proceed` 和 `No, and tell Codex what to do differently`，后者不只是拒绝，还会中断本轮，等你输入新的指示；有可建议的前缀规则时（模型给了 `prefix_rule`，或由启发式推出），中间再多一项 `Yes, and don't ask again for commands that start with ...`，选它会把规则写进 `default.rules`。补丁审批另有 `Yes, and don't ask again for these files`，网络审批的选项留到[网络代理](https://daiw.net/manual/codex-source/network-proxy)一篇。策略组合与 rules 的写法分别见手册的[沙箱、审批与安全](https://daiw.net/manual/codex/sandbox-approvals)和[命令规则](https://daiw.net/manual/codex/rules)。

## 第一关：先判定要不要问

工具在进编排器之前，先得到一个 `ExecApprovalRequirement`（`codex-rs/core/src/tools/sandboxing.rs:153`），三种结果：`Skip`（不问，附带“第一次就不套沙箱”的 `bypass_sandbox` 标志）、`NeedsApproval`（要问，附带理由和建议的规则前缀）、`Forbidden`（直接拒绝）。

| 来源 | 管什么 | 怎么判 |
| --- | --- | --- |
| 执行策略（[第 33 篇](https://daiw.net/manual/codex-source/execpolicy)） | 命令 | 规则命中取最严格的决定；没命中走启发式，见下 |
| `assess_patch_safety`（`codex-rs/core/src/safety.rs:67`） | 补丁 | 改动全落在可写路径、平台沙箱可用就自动批准，否则问；`never` 或关掉 `sandbox_approval` 时直接拒绝 |
| `default_exec_approval_requirement` | 兜底 | 受限文件系统下 `on-request` 与 `granular` 要问，`never` 不问 |

没命中规则的命令由 `render_decision_for_unmatched_command_for_platform`（`codex-rs/core/src/exec_policy.rs:770`）判定。在 `on-request`、受限文件系统下，它只看两件事：命令是否被识别为危险（危险就问，`never` 下改为禁止），以及模型有没有申请越出沙箱（`sandbox_permissions` 不是默认值就问）。其余命令直接放行，交给沙箱兜底。所以 `on-request` 的“由模型决定何时请求”是字面意思：模型要在工具参数里主动申请。权限说明片段里的模板这样教它：

```markdown
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
- Include a short question asking the user if they want to allow the action in `justification` parameter. e.g. "Do you want to download and install dependencies for this project?"
- Optionally suggest a `prefix_rule` - this will be shown to the user with an option to persist the rule approval for future sessions.
```

（`codex-rs/prompts/templates/permissions/approval_policy/on_request.md:28`）

在 `never`，或关掉了 `sandbox_approval` 的 `granular` 下，这类申请在 `exec_command` 处理器里就被驳回（`codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs:343`），根本到不了审批环节。

## 第二关：ToolOrchestrator 的三步

`ToolOrchestrator::run`（`codex-rs/core/src/tools/orchestrator.rs:122`）按“审批、选沙箱、尝试、必要时提权重试”驱动任何实现了 `ToolRuntime` 的执行后端，目前是 unified exec 与 apply_patch 两个：

```mermaid
flowchart TB
  R[ExecApprovalRequirement] -->|Forbidden| X[拒绝 返回模型]
  R -->|NeedsApproval| Q1[request_approval]
  R -->|Skip| F[第一次尝试]
  Q1 -->|批准| F
  F --> SEL{bypass_sandbox 或<br/>已批准的 require_escalated}
  SEL -->|是且无读拒绝| NS[不套沙箱]
  SEL -->|否| SB[select_initial 选平台沙箱]
  NS & SB --> RUN[run_attempt]
  RUN -->|成功或普通失败| OUT[结果交还模型]
  RUN -->|沙箱拒绝| G{策略允许<br/>无沙箱重试吗}
  G -->|否| OUT
  G -->|是| Q2[request_approval<br/>附 retry_reason]
  Q2 --> RUN2[第二次尝试]
```

1. **审批**：`Forbidden` 直接变成 `ToolError::Rejected`；`NeedsApproval` 调 `request_approval`；`Skip` 本来不问，但本轮开了“严格自动审查”时仍要过一遍审查（客户端回复本轮范围的 `request_permissions` 授权时带上 `strictAutoReview`，之后本轮每条命令都先审再跑）。
2. **第一次尝试**：规则明确放行了每一段命令（`Skip` 且 `bypass_sandbox`），或者模型申请了 `require_escalated` 并已获批，第一次就不套沙箱；否则由 `SandboxManager` 选本平台的沙箱。唯一的例外是读拒绝：策略里有 `deny_read` 时 `unsandboxed_execution_allowed` 为 false，无论怎样批准都在沙箱里跑。
3. **失败后**：只有 `SandboxErr::Denied` 才考虑重试。这个判断是启发式的：非零退出码，且输出里出现 “operation not permitted”“permission denied”“read-only file system”“sandbox”“landlock” 这类关键词，或者 Linux 上以 SIGSYS 退出（`codex-rs/sandboxing/src/denial.rs`）。能不能“去掉沙箱再来一次”，由工具的 `wants_no_sandbox_approval` 决定：

```rust
    /// Decide we can request an approval for no-sandbox execution.
    fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
        match policy {
            AskForApproval::UnlessTrusted => true,
            AskForApproval::Never => false,
            AskForApproval::OnRequest => false,
            AskForApproval::Granular(granular_config) => granular_config.sandbox_approval,
        }
    }
```

（`codex-rs/core/src/tools/sandboxing.rs:330`）

<Callout type="warn">
  默认的 `on-request` 下，命令被沙箱拦住后**不会**弹“去掉沙箱重试”，编排器把带原始输出的拒绝直接交还模型（`codex-rs/core/src/tools/orchestrator.rs:367` 的注释就是这么写的），由模型按模板带上 `require_escalated` 重新申请。例外是 apply_patch：它覆盖了这个方法，`on-request` 下补丁写到沙箱外会问你。`untrusted` 与打开 `sandbox_approval` 的 `granular` 才会对普通命令弹重试审批，理由固定是 “command failed; retry without sandbox?”。编排器里还留着一条“网络被拦后请求批准再重试”的分支，但 0.158.0 里产生 `SandboxErr::Denied` 的各处都不填 `network_policy_decision`，它实际走不到；联网审批改在代理拦截的当下内联发起（见[网络代理](https://daiw.net/manual/codex-source/network-proxy)）。
</Callout>

第一次已经批准过、且没开严格审查时，重试不再重复询问（`should_bypass_approval`）。

## 第三关：谁来批

`Session::request_approval`（`codex-rs/core/src/tools/approvals.rs:479`）把一次审批分派出去，顺序写在注释里：

```rust
        // Approval precedence is:
        // 1. Hooks
        // 2. If StrictAutoReview || Guardian enabled, then Guardian. Else, user.
        let resolution = match run_permission_request_hooks(
            self,
            &ctx.review_context,
            &permission_request_run_id,
            action.permission_request_payload(),
        )
        .await
        {
            // ...
            Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution {
                decision: ReviewDecision::denied(message),
                source: ApprovalResolutionSource::Hook,
            },
            None => self.request_reviewer_approval(action, &ctx).await,
        };
```

（`codex-rs/core/src/tools/approvals.rs:505`）

先问 `PermissionRequest` 钩子（见 [Hooks](https://daiw.net/manual/codex-source/hooks)），钩子没表态再交给审查者：`request_guardian_approval` 认领了就由另一个模型裁决（见[自动审查](https://daiw.net/manual/codex-source/guardian)），否则才弹给用户。结果统一折算成工具结果：`Denied` 变成带理由的 `ToolError::Rejected`，作为工具输出喂回模型，模型可以换个做法；`Abort` 变成 `TurnAborted`，本轮就此中断。用户选过 `ApprovedForSession` 的请求记在会话级的 `ApprovalStore` 里（`with_cached_approval`，`codex-rs/core/src/tools/sandboxing.rs:71`），键是序列化后的请求要素：命令会先规范化，再连同 cwd、tty、`sandbox_permissions`、额外权限和执行策略指纹一起算键；补丁按文件逐个记。下次同样的请求直接放行。

## 从内核到客户端再回来

```mermaid
sequenceDiagram
  participant O as ToolOrchestrator
  participant S as Session
  participant A as app-server
  participant C as 客户端
  O->>S: request_approval
  S->>S: TurnState 登记 oneshot
  S->>A: EventMsg ExecApprovalRequest
  A->>C: item/commandExecution/requestApproval
  C-->>A: decision
  A->>S: Op ExecApproval
  S->>S: notify_approval 取出 oneshot
  S-->>O: ReviewDecision
```

发给用户的那一支落在 `request_command_approval`：先在本轮的 `TurnState` 里登记一个 oneshot，再发事件，然后原地等：

```rust
        // Add the tx_approve callback to the map before sending the request.
        let (tx_approve, rx_approve) = oneshot::channel();
        let prev_entry = {
            let mut active = self.active_turn.lock().await;
            match active.as_mut() {
                Some(at) => {
                    let mut ts = at.turn_state.lock().await;
                    ts.insert_pending_approval(effective_approval_id.clone(), tx_approve)
                }
                None => None,
        // ...
        self.send_event(turn_context, event).await;
        rx_approve.await.unwrap_or(ReviewDecision::Abort)
```

（`codex-rs/core/src/session/mod.rs:2813`）

事件里带着命令、cwd、理由、建议的规则前缀和 `available_decisions`；缺省时由 `ExecApprovalRequestEvent::default_available_decisions`（`codex-rs/protocol/src/approvals.rs:365`）算出上面那几个选项。app-server 在 `bespoke_event_handling.rs` 里把它翻成服务端请求 `item/commandExecution/requestApproval`，客户端回复的 `decision` 有六种：`accept`、`acceptForSession`、`acceptWithExecpolicyAmendment`、`applyNetworkPolicyAmendment`、`decline`、`cancel`，由 `on_command_execution_request_approval_response`（`codex-rs/app-server/src/bespoke_event_handling.rs:1960`）映射回 `ReviewDecision`：`decline` 变成理由为 “rejected by user” 的 `Denied`，`cancel` 变成 `Abort`，回复解析失败或客户端报错一律按拒绝处理。随后提交 `Op::ExecApproval`。内核的 `exec_approval` 处理器（`codex-rs/core/src/session/handlers.rs:174`）先处理附带动作：`ApprovedExecpolicyAmendment` 会把前缀规则追加进 `default.rules`，失败只发一条警告；`Abort` 不走通知，而是直接 `interrupt_task` 中断本轮；反过来，待审的 oneshot 若在回复到达前被清掉，`request_command_approval` 也按 `Abort` 处理，被中断的轮次不会拿着一个假的拒绝接着跑。其余决定经 `notify_approval` 从 `TurnState` 取出 oneshot 发回。补丁审批走同样的套路，对应 `item/fileChange/requestApproval` 与 `Op::PatchApproval`；`request_permissions` 工具的请求是 `item/permissions/requestApproval`。旧的 v1 接口还保留着 `execCommandApproval` 与 `applyPatchApproval` 两个已弃用的请求。

## 往终端里打字也要审

后台终端可以接着 `write_stdin`。`write_stdin_approval` 特性（默认开启）在 `codex-rs/core/src/unified_exec/stdin_approval.rs` 里比较终端启动时的权限快照与当前策略：终端当初是提权启动的、带着额外授权，或者策略已经变了，往里写字就要重新审批，选项只有批准与中断。策略有读拒绝、而终端在沙箱外启动或沙箱与当前不一致时，写入直接被拒，提示新开终端：批准也没法给正在运行的进程补上读拒绝。

## 和《从 LLM 到 Coding Agent》对照

那本书的[权限系统](https://daiw.net/manual/llm-to-agent/permissions)强调“拒绝要喂回模型”，Codex 正是这样处理 `Denied`：理由作为工具结果交还，本轮继续；而 `Abort` 对应[中断](https://daiw.net/manual/llm-to-agent/interrupt)，直接停下本轮等人。挂起等待的做法与 OpenCode 的[权限系统](https://daiw.net/manual/opencode/permission)如出一辙：那边用 `Deferred.await` 把工具停在 `ctx.ask` 上，这边用存进 `TurnState` 的 oneshot。区别在分派层：Codex 在用户之前多了钩子和审查模型两级，同一个审批请求可能根本不会出现在你面前。

---

上一篇：[权限模型 · 沙箱策略与审批策略](https://daiw.net/manual/codex-source/permissions-model) · 下一篇：[macOS 沙箱 · Seatbelt 策略生成](https://daiw.net/manual/codex-source/seatbelt)
