Skip to content

工具审批架构

追踪审批策略、执行要求、请求关联、Hook/Guardian/User 路由、会话缓存与拒绝返回的真实源码链路。

基于rust-v0.150.0
CodexRustToolsApproval

工具审批架构 ​

Codex 的“需要批准”不是一个布尔字段。一次 shell、unified exec 或 apply patch 调用,会先经过执行策略和权限 profile, 得到 Skip、NeedsApproval 或 Forbidden;只有 NeedsApproval 才会进入审批请求。请求发出后,又有 Hook、Guardian、 用户三种决策来源,最后通过 ReviewDecision 回到等待中的 handler。ApprovedForSession 还会改变后续相同命令、目录、 sandbox 和文件集合的行为。

本文面向已经读过ToolOrchestrator执行流程、ToolLifecycle事件 和工具调度追踪模型的读者。前文解释调用如何进入 handler、终态怎样通知扩展、诊断 trace 怎样记录;本文只研究审批如何暂停或拒绝执行,不展开 sandbox 各平台的底层实现,也不讲 MCP 专属 elicitation。读完后, 读者应能从一个命令判断它为什么跳过、请求或禁止审批,并能沿着 call_id 找到等待、响应、缓存和重试位置。

1. 策略边界 ​

1.1 四种全局策略 ​

AskForApproval 是用户配置层的策略,不直接决定某个命令的最终结果。UnlessTrusted 总是把命令交给后续信任判断; OnRequest 由命令和 sandbox 状态决定是否请求;Granular 允许分别关闭 sandbox、规则、技能、权限请求和 MCP elicitation 类别;Never 不会向用户发起审批,但失败仍会返回给模型。

源码位置:codex-rs/protocol/src/protocol.rs :: AskForApproval

rust
pub enum AskForApproval {
    UnlessTrusted,
    OnRequest,
    Granular(GranularApprovalConfig),
    Never,
}

pub struct GranularApprovalConfig {
    pub sandbox_approval: bool,
    pub rules: bool,
    pub skill_approval: bool,
    pub request_permissions: bool,
    pub mcp_elicitations: bool,
}

Granular 的 false 不是“继续询问但默认拒绝”,而是对应类别自动拒绝。这个区别会在 Forbidden 分支中体现:请求不会 进入用户界面,也不会创建 pending approval。

1.2 三态要求 ​

执行层使用 ExecApprovalRequirement 把策略结果传给 Orchestrator。Skip 可携带首次绕过 sandbox 的意图;NeedsApproval 携带提示原因和可能的 execpolicy amendment;Forbidden 携带最终拒绝原因。

源码位置:codex-rs/core/src/tools/sandboxing.rs :: ExecApprovalRequirement

rust
pub(crate) enum ExecApprovalRequirement {
    Skip {
        bypass_sandbox: bool,
        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
    },
    NeedsApproval {
        reason: Option<String>,
        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
    },
    Forbidden { reason: String },
}

2. 要求生成 ​

2.1 默认判定 ​

没有 runtime 自定义要求时,Orchestrator 调用 default_exec_approval_requirement。OnRequest 和 Granular 只有在文件 系统 sandbox 是 Restricted 时才需要审批;外部 sandbox 已经承担了隔离,因而可以 Skip。UnlessTrusted 无条件需要审批, 而 Granular 如果关闭 sandbox approval,则把本应提示的情况变成 Forbidden。

源码位置:codex-rs/core/src/tools/sandboxing.rs :: default_exec_approval_requirement

rust
pub(crate) fn default_exec_approval_requirement(
    policy: AskForApproval,
    file_system_sandbox_policy: &FileSystemSandboxPolicy,
) -> ExecApprovalRequirement {
    let needs_approval = match policy {
        AskForApproval::Never => false,
        AskForApproval::OnRequest | AskForApproval::Granular(_) => {
            matches!(
                file_system_sandbox_policy.kind,
                FileSystemSandboxKind::Restricted
            )
        }
        AskForApproval::UnlessTrusted => true,
    };

    if needs_approval
        && matches!(
            policy,
            AskForApproval::Granular(granular_config)
                if !granular_config.allows_sandbox_approval()
        )
    {
        ExecApprovalRequirement::Forbidden {
            reason: "approval policy disallowed sandbox approval prompt".to_string(),
        }
    } else if needs_approval {
        ExecApprovalRequirement::NeedsApproval {
            reason: None,
            proposed_execpolicy_amendment: None,
        }
    } else {
        ExecApprovalRequirement::Skip {
            bypass_sandbox: false,
            proposed_execpolicy_amendment: None,
        }
    }
}

2.2 ExecPolicy覆盖 ​

shell handler 会先向 ExecPolicyManager 提交命令。该管理器解析复合命令、检查规则和 heuristic fallback,再把 Allow、 Prompt、Forbidden 映射为三态要求。规则命中优先于默认 sandbox 判断;如果是 Prompt,还可能携带一个未来可保存的 prefix amendment。

源码位置:codex-rs/core/src/exec_policy.rs :: ExecPolicyManager::create_exec_approval_requirement_for_command

rust
let evaluation = exec_policy.check_multiple_with_options(
    commands.iter(),
    &exec_policy_fallback,
    &match_options,
);

match evaluation.decision {
    Decision::Forbidden => ExecApprovalRequirement::Forbidden {
        reason: derive_forbidden_reason(
            command,
            &evaluation,
            dangerous_command_match_for_heuristics(
                &evaluation,
                Decision::Forbidden,
                command_origin,
            ),
        ),
    },
    Decision::Prompt => {
        let prompt_is_rule = evaluation.matched_rules.iter().any(|rule_match| {
            is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt
        });
        match prompt_is_rejected_by_policy(approval_policy, prompt_is_rule) {
            Some(reason) if prompt_is_rule => ExecApprovalRequirement::Forbidden {
                reason: reason.to_string(),
            },
            Some(reason) => ExecApprovalRequirement::Forbidden {
                reason: derive_rejected_prompt_reason(
                    reason,
                    dangerous_command_match_for_heuristics(
                        &evaluation,
                        Decision::Prompt,
                        command_origin,
                    ),
                ),
            },
            None => ExecApprovalRequirement::NeedsApproval {
                reason: derive_prompt_reason(command, &evaluation),
                proposed_execpolicy_amendment: requested_amendment.or_else(|| {
                    if auto_amendment_allowed {
                        try_derive_execpolicy_amendment_for_prompt_rules(
                            &evaluation.matched_rules,
                        )
                    } else {
                        None
                    }
                }),
            },
        }
    }
    Decision::Allow => ExecApprovalRequirement::Skip {
        // Bypass sandbox only when every parsed command segment is
        // explicitly allowed by execpolicy.
        bypass_sandbox: commands.iter().all(|command| {
            exec_policy
                .matches_for_command_with_options(
                    command,
                    /*heuristics_fallback*/ None,
                    &match_options,
                )
                .iter()
                .any(|rule_match| {
                    is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
                })
        }),
        proposed_execpolicy_amendment: if auto_amendment_allowed {
            try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
        } else {
            None
        },
    },
}

这里的 bypass_sandbox 不是“审批已通过”。它只表示显式 Allow 规则可以让首次尝试不经过 sandbox;仍然需要检查 deny-read 权限,避免为了执行策略而丢掉文件系统拒绝规则。

2.3 工具自定义 ​

Approvable::exec_approval_requirement 允许 runtime 覆盖默认策略。shell 和 unified exec 把请求中已经计算好的要求原样 返回;apply_patch 也强制使用上游的 patch safety 结果,而不是重新套用全局 exec policy。

源码位置:codex-rs/core/src/tools/sandboxing.rs :: Approvable

rust
pub(crate) trait Approvable<Req> {
    fn sandbox_permissions(&self, _req: &Req) -> SandboxPermissions {
        SandboxPermissions::UseDefault
    }

    fn exec_approval_requirement(&self, _req: &Req) -> Option<ExecApprovalRequirement> {
        None
    }

    fn approval_action(&self, req: &Req, call_id: &str) -> std::io::Result<ApprovalAction>;
}

3. 编排入口 ​

3.1 首次审批 ​

ToolOrchestrator::run 先拿到 environment、permission profile 和要求,再处理审批,最后才构造首次 SandboxAttempt。因此 审批发生在 handler 的实际进程启动之前。

源码位置:codex-rs/core/src/tools/orchestrator.rs :: ToolOrchestrator::run

rust
let environment = tool.turn_environment(req);
let workspace_roots = environment.workspace_roots();
let permission_profile = environment.permission_profile();
let permissions = environment.permission_profile_with_workspace_roots();
let file_system_sandbox_policy = permissions.file_system_sandbox_policy();
let requirement = tool
    .exec_approval_requirement(req)
    .unwrap_or_else(|| {
        default_exec_approval_requirement(
            approval_policy,
            &file_system_sandbox_policy,
        )
    });

match &requirement {
    ExecApprovalRequirement::Skip { .. } => { /* 可直接进入首次尝试 */ }
    ExecApprovalRequirement::Forbidden { reason } => {
        return Err(ToolError::Rejected(reason.clone()));
    }
    ExecApprovalRequirement::NeedsApproval { reason, .. } => {
        let action = tool.approval_action(req, &tool_ctx.call_id)?;
        let approval_ctx = ApprovalContext {
            turn: Arc::clone(&tool_ctx.turn),
            call_id: tool_ctx.call_id.clone(),
            tool_name: tool_ctx.tool_name.clone(),
            strict_auto_review,
            approval_reason: reason.clone(),
            retry_reason: None,
            network_approval_context: None,
        };
        tool_ctx
            .session
            .request_approval(action, approval_ctx)
            .await?;
    }
}

3.2 Skip分支 ​

通常 Skip 会记录配置来源的 Approved telemetry,然后直接继续。唯一例外是 strict auto review:即使策略已 Skip, Orchestrator 仍构造 ApprovalAction 交给 Guardian,让自动审查覆盖显式放行;这保证“本轮授予的严格权限”不会绕过自动审查。

源码位置:codex-rs/core/src/tools/orchestrator.rs :: ToolOrchestrator::run

rust
ExecApprovalRequirement::Skip { .. } => {
    if strict_auto_review {
        let action = tool
            .approval_action(req, &tool_ctx.call_id)
            .map_err(|err| {
                ToolError::Rejected(format!(
                    "could not prepare approval action: {err}"
                ))
            })?;
        let approval_ctx = ApprovalContext {
            turn: Arc::clone(&tool_ctx.turn),
            call_id: tool_ctx.call_id.clone(),
            tool_name: tool_ctx.tool_name.clone(),
            strict_auto_review,
            approval_reason: None,
            retry_reason: None,
            network_approval_context: None,
        };
        tool_ctx.session.request_approval(action, approval_ctx).await?;
        already_approved = true;
    } else {
        otel.tool_decision(
            &otel_tn,
            otel_ci,
            &ReviewDecision::Approved,
            ToolDecisionSource::Config,
        );
    }
}

3.3 Sandbox选择 ​

审批结果不直接等于 sandbox 类型。sandbox_override_for_first_attempt 还要检查 deny-read 是否存在;如果绕过 sandbox 会丢失拒绝读取规则,就保持 NoOverride。显式 RequireEscalated 或 Skip { bypass_sandbox: true } 只有在 unsandboxed_execution_allowed 时才能影响首次尝试。

源码位置:codex-rs/core/src/tools/sandboxing.rs :: sandbox_override_for_first_attempt

rust
if !unsandboxed_execution_allowed(file_system_sandbox_policy) {
    return SandboxOverride::NoOverride;
}

if matches!(
    exec_approval_requirement,
    ExecApprovalRequirement::Skip {
        bypass_sandbox: true,
        ..
    }
) {
    return SandboxOverride::BypassSandboxFirstAttempt;
}

if sandbox_permissions.requires_escalated_permissions() {
    SandboxOverride::BypassSandboxFirstAttempt
} else {
    SandboxOverride::NoOverride
}

4. 请求模型 ​

4.1 ApprovalAction ​

审批请求不是直接传一个 command 字符串。ApprovalAction 按 runtime 分成 Shell、ExecCommand 和 ApplyPatch,并携带 environment、cwd、sandbox permissions、additional permissions、justification 以及可选 amendment。这个对象同时服务 Hook payload、Guardian request 和 User event,避免三种 reviewer 看到不同的核心事实。

源码位置:codex-rs/core/src/tools/approvals.rs :: ApprovalAction

rust
pub(crate) enum ApprovalAction {
    Shell {
        id: String,
        environment_id: String,
        command: Vec<String>,
        hook_command: String,
        cwd: PathUri,
        sandbox_permissions: SandboxPermissions,
        additional_permissions: Option<AdditionalPermissionProfile>,
        justification: Option<String>,
        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
    },
    ExecCommand {
        id: String,
        environment_id: String,
        command: Vec<String>,
        hook_command: String,
        cwd: PathUri,
        sandbox_permissions: SandboxPermissions,
        additional_permissions: Option<AdditionalPermissionProfile>,
        justification: Option<String>,
        tty: bool,
        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
    },
    ApplyPatch {
        id: String,
        environment_id: String,
        cwd: PathUri,
        files: Vec<PathUri>,
        patch: String,
        changes: Arc<HashMap<PathBuf, FileChange>>,
        permissions_preapproved: bool,
    },
}

4.2 Hook输入 ​

Shell 与 unified exec 将 action 投影为 PermissionRequestPayload::bash,包含 command 和可选 description;apply_patch 则使用 HookToolName::apply_patch,把 patch 放入 command 字段。Hook 的 Allow/Deny 不需要创建用户 pending request。

源码位置:codex-rs/core/src/tools/approvals.rs :: ApprovalAction::permission_request_payload

rust
match self {
    Self::Shell { hook_command, justification, .. }
    | Self::ExecCommand { hook_command, justification, .. } => {
        PermissionRequestPayload::bash(
            hook_command.clone(),
            justification.clone(),
        )
    }
    Self::ApplyPatch { patch, .. } => PermissionRequestPayload {
        tool_name: HookToolName::apply_patch(),
        tool_input: serde_json::json!({ "command": patch }),
    },
}

4.3 用户事件 ​

用户 reviewer 通过 EventMsg::ExecApprovalRequest 或 ApplyPatchApprovalRequest 暴露请求。命令请求包含 parsed command、 reason、network context、additional permissions 和 available decisions;patch 请求包含文件变更、reason 和 grant root。

源码位置:codex-rs/core/src/session/mod.rs :: Session::request_command_approval

rust
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
    call_id,
    plugin_id,
    script_path,
    approval_id,
    turn_id: turn_context.sub_id.clone(),
    environment_id,
    started_at_ms: now_unix_timestamp_ms(),
    command,
    cwd,
    reason,
    network_approval_context,
    proposed_execpolicy_amendment,
    proposed_network_policy_amendments,
    additional_permissions,
    available_decisions: Some(available_decisions),
    parsed_cmd,
});
self.send_event(turn_context, event).await;
rx_approve.await.unwrap_or(ReviewDecision::Abort)

available_decisions 不是装饰字段。网络阻断时可以出现 allow/deny host 的 amendment;有 additional permissions 时只提供 Approved/Abort;有 execpolicy prefix 时可以出现 ApprovedExecpolicyAmendment。客户端若收到旧格式,也能由协议字段推导 默认列表。

5. 等待关联 ​

5.1 Pending map ​

Session 在发事件前,把 oneshot sender 写入当前 active turn 的 pending approval map。这样客户端响应到达时,审批仍然 对应原来的 turn;如果 approval id 已被替换,会记录 warning。命令的 subcommand callback 使用 approval_id,普通命令 则使用 call_id 作为 effective id。

源码位置:codex-rs/core/src/session/mod.rs :: Session::request_command_approval

rust
let effective_approval_id = approval_id
    .clone()
    .unwrap_or_else(|| call_id.clone());
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,
    }
};

5.2 响应回传 ​

外部 Op::ExecApproval 或 Op::PatchApproval 由 session handler 接收。普通决定调用 notify_approval,它从 active turn 移除 sender 并发送 ReviewDecision;Abort 则直接 interrupt 当前任务。这个分支意味着 Abort 不是普通的 Denied 文本, 它会改变 turn 的取消状态。

源码位置:codex-rs/core/src/session/handlers.rs :: exec_approval

rust
pub async fn exec_approval(
    sess: &Arc<Session>,
    approval_id: String,
    turn_id: Option<String>,
    decision: ReviewDecision,
) {
    if let ReviewDecision::ApprovedExecpolicyAmendment {
        proposed_execpolicy_amendment,
    } = &decision
        && let Err(err) = sess
            .persist_execpolicy_amendment(proposed_execpolicy_amendment)
            .await
    {
        sess.send_event_raw(Event {
            id: turn_id.unwrap_or_else(|| approval_id.clone()),
            msg: EventMsg::Warning(WarningEvent {
                message: format!("Failed to apply execpolicy amendment: {err}"),
            }),
        })
        .await;
    }
    match decision {
        ReviewDecision::Abort => sess.interrupt_task().await,
        other => sess.notify_approval(&approval_id, other).await,
    }
}

源码位置:codex-rs/core/src/session/mod.rs :: Session::notify_approval

rust
pub async fn notify_approval(
    &self,
    approval_id: &str,
    decision: ReviewDecision,
) {
    let entry = {
        let mut active = self.active_turn.lock().await;
        match active.as_mut() {
            Some(at) => {
                let mut ts = at.turn_state.lock().await;
                ts.remove_pending_approval(approval_id)
            }
            None => None,
        }
    };
    match entry {
        Some(tx_approve) => { tx_approve.send(decision).ok(); }
        None => warn!("No pending approval found for call_id: {approval_id}"),
    }
}

5.3 取消与清理 ​

审批请求注册一个 elicitation holder,使会话知道当前有交互等待。turn 被取消时,pending sender 可能被清理,等待中的 receiver 会得到 channel closed;request_command_approval 将其转换成 ReviewDecision::Abort。因此“界面没有回响应”与 “用户明确拒绝”在内部来源不同,但最终都不会启动工具。

6. 决策路由 ​

6.1 Hook优先 ​

Session::request_approval 首先运行 permission request hooks。Hook Allow 直接生成 Approved,Hook Deny 生成带消息的 Denied;只有 Hook 返回 None 才继续 reviewer 路由。strict auto review 不能覆盖 Hook Allow,因为 Hook 位于整个优先级链最前面。

源码位置:codex-rs/core/src/tools/approvals.rs :: Session::request_approval

rust
let resolution = match run_permission_request_hooks(
    self,
    &ctx.turn,
    permission_request_run_id
        .as_deref()
        .unwrap_or(&ctx.call_id),
    action.permission_request_payload(),
)
.await
{
    Some(PermissionRequestDecision::Allow) => ApprovalResolution {
        decision: ReviewDecision::Approved,
        source: ApprovalResolutionSource::Hook,
    },
    Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution {
        decision: ReviewDecision::denied(message),
        source: ApprovalResolutionSource::Hook,
    },
    None => self.request_reviewer_approval(action, &ctx).await,
};
record_resolution(&ctx, &resolution);
resolution.into_tool_result()

6.2 Guardian与User ​

没有 Hook 决定时,strict_auto_review 强制 Guardian;否则根据 turn 的 approvals_reviewer 和 Guardian feature 决定走 Guardian 还是 User。Guardian 使用结构化 GuardianApprovalRequest;User 使用 Session event/oneshot。两者最后都归一成 ApprovalResolution,因此上层不需要知道具体 reviewer。

源码位置:codex-rs/core/src/tools/approvals.rs :: Session::request_reviewer_approval

rust
let reviewer = if ctx.strict_auto_review {
    ApprovalReviewer::Guardian
} else {
    ApprovalReviewer::for_turn(&ctx.turn)
};

let decision = match reviewer {
    ApprovalReviewer::Guardian => {
        self.request_guardian_approval(action, ctx).await
    }
    ApprovalReviewer::User => {
        self.request_user_approval(&action, ctx).await
    }
};
let source = match reviewer {
    ApprovalReviewer::Guardian => ApprovalResolutionSource::Guardian,
    ApprovalReviewer::User => ApprovalResolutionSource::User,
};
ApprovalResolution { decision, source }

6.3 结果归一化 ​

ApprovalResolution::into_tool_result 把拒绝、超时、Abort 和拒绝型 network amendment 统一转换为 ToolError::Rejected; Approved、ApprovedForSession、允许型 network amendment 和 execpolicy amendment 则继续返回给 Orchestrator。这里消除了 reviewer 差异,但保留 rejection 文本用于模型可见错误。

源码位置:codex-rs/core/src/tools/approvals.rs :: ApprovalResolution::into_tool_result

rust
match self.decision {
    ReviewDecision::NetworkPolicyAmendment { network_policy_amendment }
        if network_policy_amendment.action == NetworkPolicyRuleAction::Deny =>
    {
        Err(ToolError::Rejected(match source {
            ApprovalResolutionSource::Hook => "rejected by configuration",
            ApprovalResolutionSource::Guardian => "automatic approval review denied the action",
            ApprovalResolutionSource::User => "rejected by user",
        }.to_string()))
    }
    ReviewDecision::Denied { rejection } => Err(ToolError::Rejected(rejection)),
    ReviewDecision::TimedOut => Err(ToolError::Rejected(guardian_timeout_message())),
    ReviewDecision::Abort => Err(ToolError::Rejected(
        "approval request aborted".to_string(),
    )),
    decision => Ok(decision),
}

7. 会话缓存 ​

7.1 Key组成 ​

审批缓存不是按 tool name 粗略记忆。Shell key 包含 environment、规范化 command、cwd、sandbox permissions 和 additional permissions;unified exec 还包含 tty;apply_patch 则按每个文件路径建立 key。相同 session 中只要所有 key 都已有 ApprovedForSession,就跳过新的界面请求。

源码位置:codex-rs/core/src/tools/approvals.rs :: ApprovalAction::cache_keys

rust
Self::ExecCommand {
    environment_id,
    command,
    cwd,
    tty,
    sandbox_permissions,
    additional_permissions,
    ..
} => vec![ApprovalCacheKey::ExecCommand(UnifiedExecApprovalKey {
    environment_id: environment_id.clone(),
    command: canonicalize_command_for_approval(command),
    cwd: cwd.clone(),
    tty: *tty,
    sandbox_permissions: *sandbox_permissions,
    additional_permissions: additional_permissions.clone(),
})],

7.2 多文件Patch ​

apply_patch 可以一次修改多个文件,所以 cache_keys 返回多个 key。缓存函数要求所有 key 都已批准;新获得 ApprovedForSession 时,则逐个写入,后续只修改其中任意子集也能命中对应 key。这是“按文件集合拆分”而不是“把整次 patch 记成一个不可分割字符串”。

源码位置:codex-rs/core/src/tools/sandboxing.rs :: with_cached_approval

rust
let already_approved = {
    let store = services.tool_approvals.lock().await;
    keys.iter().all(|key| {
        matches!(
            store.get(key),
            Some(ReviewDecision::ApprovedForSession)
        )
    })
};

if already_approved {
    return ReviewDecision::ApprovedForSession;
}

let decision = fetch().await;
if matches!(decision, ReviewDecision::ApprovedForSession) {
    let mut store = services.tool_approvals.lock().await;
    for key in keys {
        store.put(key, ReviewDecision::ApprovedForSession);
    }
}
decision

7.3 缓存边界 ​

缓存存放在 SessionServices.tool_approvals 的内存 mutex 中,不是持久 config,也不是跨 session 的授权。execpolicy amendment 走另一条持久化路径;network amendment 也有自己的网络规则保存逻辑。不要把 ApprovedForSession 解释为修改了 全局安全策略。

8. 重试升级 ​

8.1 首次失败 ​

Orchestrator 首次在选定 sandbox 下运行。若 runtime 返回 sandbox denial,且工具允许 escalation,就构造 retry reason,重新 决定是否请求审批,然后建立第二个 SandboxAttempt。retry reason 会进入 ApprovalContext,因此用户看到的是“为什么要 在当前策略下再次请求”,不是一个无上下文的重复弹窗。

源码位置:codex-rs/core/src/tools/orchestrator.rs :: ToolOrchestrator::run

rust
let retry_reason = if let Some(network_context) =
    network_approval_context.as_ref()
{
    format!(
        "Network access to \"{}\" is blocked by policy.",
        network_context.host
    )
} else {
    build_denial_reason_from_output(output.as_ref())
};

let approval_ctx = ApprovalContext {
    turn: Arc::clone(&tool_ctx.turn),
    call_id: tool_ctx.call_id.clone(),
    tool_name: tool_ctx.tool_name.clone(),
    strict_auto_review,
    approval_reason,
    retry_reason: Some(retry_reason),
    network_approval_context: network_approval_context.clone(),
};
tool_ctx.session.request_approval(action, approval_ctx).await?;

8.2 严格审查 ​

strict auto review 只覆盖 sandboxed attempt;如果要 retry 到 unsandboxed attempt,Guardian 仍需重新审查。普通 User reviewer 在某些已经 approved 的场景可以由 should_bypass_approval 跳过重复请求,但 strict review 不走这个捷径。

源码位置:codex-rs/core/src/tools/orchestrator.rs :: ToolOrchestrator::run

rust
let bypass_retry_approval = !strict_auto_review
    && tool.should_bypass_approval(
        approval_policy,
        already_approved,
    )
    && network_approval_context.is_none();

if !bypass_retry_approval {
    let approval_reason = match &requirement {
        ExecApprovalRequirement::NeedsApproval { reason, .. } => reason.clone(),
        ExecApprovalRequirement::Skip { .. }
        | ExecApprovalRequirement::Forbidden { .. } => None,
    };
    // 构造带 retry_reason 的 ApprovalContext
    tool_ctx.session.request_approval(action, approval_ctx).await?;
}

8.3 Deny-read边界 ​

即使用户批准了 escalation,deny-read 文件系统规则仍然有效。sandbox_override_for_first_attempt 和 sandbox_permissions_preserving_denied_reads 会阻止一个“看似已批准”的 unsandboxed 路径丢失拒绝读取约束。

9. 决策结果 ​

9.1 Approved ​

Approved 只放行当前请求。ApprovedForSession 额外写入 session cache;ApprovedExecpolicyAmendment 先尝试持久化规则, 即使持久化失败也会发送 warning,再把决定交给 pending approval。这个顺序让用户知道规则保存失败,而不会静默声称未来命令 已经被允许。

9.2 Denied ​

Denied { rejection } 通过 ToolError::Rejected 返回给 Orchestrator,再由 handler 转成模型可见错误;它不 interrupt 整个 turn。模型可以根据拒绝文本选择其他工具或继续回答。

9.3 Abort ​

Abort 由 handler 直接调用 interrupt_task,而不是发送给单个 pending receiver。等待中的审批因此成为 turn 取消的一部分, 不是普通“换一条命令再试”的拒绝。

9.4 Amendment ​

execpolicy amendment 和 network policy amendment 不是同一种权限:前者影响未来命令规则,后者影响目标 host 的网络策略。 协议层把它们都放在 ReviewDecision,但处理函数和持久化消费者不同。

图中 ApprovedForSession 仍然回到执行,但多了一条 session cache 写入;RuleChanged 和 NetworkChanged 则先进入不同的 持久化消费者。Rejected 只生成工具错误,Interrupted 直接结束当前 turn,两者不能合并为同一种拒绝。

10. 测试路径 ​

10.1 策略矩阵 ​

sandboxing_tests.rs 直接测试策略与 sandbox 的组合:外部 sandbox + OnRequest 得到 Skip;受限 sandbox + OnRequest 得到 NeedsApproval;Granular 关闭 sandbox approval 得到 Forbidden;Granular 开启则仍保留 NeedsApproval。这组测试证明审批需求 不是由 AskForApproval 单独决定。

源码位置:codex-rs/core/src/tools/sandboxing_tests.rs :: default_exec_approval_requirement_*

rust
assert_eq!(
    default_exec_approval_requirement(
        AskForApproval::OnRequest,
        &FileSystemSandboxPolicy::external_sandbox(),
    ),
    ExecApprovalRequirement::Skip {
        bypass_sandbox: false,
        proposed_execpolicy_amendment: None,
    }
);

assert_eq!(
    default_exec_approval_requirement(
        AskForApproval::OnRequest,
        &FileSystemSandboxPolicy::default(),
    ),
    ExecApprovalRequirement::NeedsApproval {
        reason: None,
        proposed_execpolicy_amendment: None,
    }
);

10.2 等待保持 ​

elicitation_holders_tests.rs 启动 command approval 和 patch approval 的异步请求,先等待 approval event 和 pause state, 再调用 notify_approval。断言请求 task 只在响应后结束,说明 pending sender、oneshot receiver 和 elicitation holder 的 生命周期是相互关联的。

源码位置:codex-rs/core/src/session/elicitation_holders_tests.rs :: command_approval_holds_an_elicitation_until_response

rust
let event = events.recv().await.expect("approval event");
assert!(matches!(
    event.msg,
    codex_protocol::protocol::EventMsg::ExecApprovalRequest(_)
));
wait_until_held(&mut pause_state).await;
session
    .notify_approval("call-1", ReviewDecision::Approved)
    .await;
request.await.expect("approval task");
wait_until_released(&mut pause_state).await;

这证明审批等待不会在 event 发出后立即释放;不证明前端一定显示了请求,也不覆盖用户主动断开连接后的所有清理时序。

10.3 会话批准 ​

审批集成测试先让 apply_patch 获得 ApprovedForSession,再让同一文件的后续 patch 运行。第二次只等待 TurnComplete, 如果再次收到 ApplyPatchApprovalRequest 就失败。这个输入同时覆盖多文件 key 拆分和 session cache 命中。

源码位置:codex-rs/core/tests/suite/approvals.rs :: approving_apply_patch_for_session_skips_future_prompts_for_same_file

rust
test.codex
    .submit(Op::PatchApproval {
        id: approval.call_id,
        decision: ReviewDecision::ApprovedForSession,
    })
    .await?;
wait_for_completion(&test).await;

let event = wait_for_event(&test.codex, |event| {
    matches!(
        event,
        EventMsg::ApplyPatchApprovalRequest(_)
            | EventMsg::TurnComplete(_)
    )
})
.await;
assert!(matches!(event, EventMsg::TurnComplete(_)));

10.4 Amendment保存 ​

execpolicy amendment 测试检查审批请求带有预期 prefix,再提交 ApprovedExecpolicyAmendment,最后确认后续相同命令不再 提示,并检查 developer context 中记录了保存的命令前缀。它证明 amendment 的持久化消费者不同于 session cache。

源码位置:codex-rs/core/tests/suite/approvals.rs :: approving_execpolicy_amendment_persists_policy_and_skips_future_prompts

rust
assert_eq!(
    approval.proposed_execpolicy_amendment,
    Some(expected_execpolicy_amendment.clone())
);
test.codex
    .submit(Op::ExecApproval {
        id: approval.effective_approval_id(),
        turn_id: None,
        decision: ReviewDecision::ApprovedExecpolicyAmendment {
            proposed_execpolicy_amendment: expected_execpolicy_amendment,
        },
    })
    .await?;
wait_for_completion(&test).await;

11. 阅读实践 ​

在源码仓库根目录运行下面的定向测试:

bash
RUST_MIN_STACK=16777216 cargo test -p codex-core sandboxing_tests
RUST_MIN_STACK=16777216 cargo test -p codex-core command_approval_holds_an_elicitation_until_response
RUST_MIN_STACK=16777216 cargo test -p codex-core approving_apply_patch_for_session_skips_future_prompts_for_same_file
RUST_MIN_STACK=16777216 cargo test -p codex-core approving_execpolicy_amendment_persists_policy_and_skips_future_prompts

然后用三个场景复述:

  1. 受限 sandbox 下的 OnRequest shell 命令,哪一个函数先产生 NeedsApproval,哪一个函数把它变成用户事件?
  2. 用户选择 ApprovedForSession 后,哪些字段组成 cache key?改变 cwd、tty、sandbox permissions 或文件路径会怎样?
  3. 首次 sandbox 执行失败后 retry,为什么 strict auto review 不能复用首次批准?deny-read 规则又在哪个函数阻止绕过?

能够从 ToolOrchestrator::run 复述到 default_exec_approval_requirement、Session::request_approval、 request_command_approval 和 notify_approval,再说明 ReviewDecision 如何回到 runtime,就掌握了审批架构的真实控制流。 网络审批还会在这条通用审批链上增加 host、规则保存和 deferred retry;本文先把共享审批骨架限定清楚。