Skip to content

ApprovalPolicy完整参考

解释 AskForApproval 四种模式、Granular 分类、Hook与Reviewer路由、决定解释、会话缓存及重试审批边界。

基于rust-v0.150.0
CodexRustSecurityApproval

ApprovalPolicy完整参考 ​

Codex 的 approval policy 不直接回答“命令是否安全”,而是回答一个更窄的问题:当前动作需要升级授权时,是否允许产生审批,以及这个审批应交给谁。真正的命令规则、权限 profile 和 sandbox 强制仍在其他层完成。

rust-v0.150.0 的审批路径已经统一:工具或 exec policy 先产生结构化 requirement,所有允许出现的审批再进入 Permission Request Hook,Hook 没有决定时才交给 Guardian 或用户;最终 ReviewDecision 还要经过统一解释,才能成为工具继续执行、拒绝或终止 Turn 的结果。

本文承接权限审批沙箱三层模型与SandboxPolicy完整参考。前两篇解释权限和强制;本文专门解决四个问题:何时允许提示、提示属于哪一类、谁负责审查、决定能否复用。

这条链中,“policy 允许提示”和“reviewer 批准动作”是两次不同判断。Granular 可以在 reviewer 运行前直接禁止某类 prompt;reviewer 则只处理已经获准进入审批流程的动作。

1. 四种模式 ​

AskForApproval 的公开序列化值是 untrusted、on-request、granular 和 never。旧 on-failure 仍作为 OnRequest 的反序列化 alias,但当前语义不再是“任何失败都询问”。

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

rust
pub enum AskForApproval {
    #[serde(rename = "untrusted")]
    #[strum(serialize = "untrusted")]
    UnlessTrusted,

    #[serde(alias = "on-failure")]
    OnRequest,

    #[strum(serialize = "granular")]
    Granular(GranularApprovalConfig),

    Never,
}

四种模式的基础含义是:

模式允许提示的基本原则非危险普通命令需要升级时
UnlessTrusted未被显式 allow 的命令倾向询问通常 Prompt可询问
OnRequestrestricted sandbox 内先依赖 sandbox通常直接受限运行sandbox override 时 Prompt
Granular类似 OnRequest,但按类别开关通常直接受限运行类别关闭则 Forbidden
Never不产生审批依赖已有 sandbox/rule需要 prompt 时 Forbidden

Never 不是全放行模式。危险命令或没有可执行 sandbox 边界时,启发式 fallback 会返回 Forbidden;普通命令则可以在既有 restricted sandbox 中直接运行。

2. Granular分类 ​

Granular 将五种审批来源拆开控制。字段为 false 的含义是自动拒绝该类请求,而不是退回另一个 reviewer。

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

rust
pub struct GranularApprovalConfig {
    pub sandbox_approval: bool,
    pub rules: bool,
    #[serde(default)]
    pub skill_approval: bool,
    #[serde(default)]
    pub request_permissions: bool,
    pub mcp_elicitations: bool,
}

impl GranularApprovalConfig {
    pub const fn allows_sandbox_approval(self) -> bool {
        self.sandbox_approval
    }

    pub const fn allows_rules_approval(self) -> bool {
        self.rules
    }

    pub const fn allows_skill_approval(self) -> bool {
        self.skill_approval
    }

    pub const fn allows_request_permissions(self) -> bool {
        self.request_permissions
    }

    pub const fn allows_mcp_elicitations(self) -> bool {
        self.mcp_elicitations
    }
}

分类不是按工具名简单切分。例如 Unified Exec 的 prompt 可能来自显式 exec-policy Prompt rule,也可能来自 unmatched command 的 sandbox escalation。前者检查 rules,后者检查 sandbox_approval;两者同时存在时,rule prompt 优先。

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

rust
pub(crate) fn prompt_is_rejected_by_policy(
    approval_policy: AskForApproval,
    prompt_is_rule: bool,
) -> Option<&'static str> {
    match approval_policy {
        AskForApproval::Never => Some(PROMPT_CONFLICT_REASON),
        AskForApproval::OnRequest => None,
        AskForApproval::UnlessTrusted => None,
        AskForApproval::Granular(granular_config) => {
            if prompt_is_rule {
                if !granular_config.allows_rules_approval() {
                    Some(REJECT_RULES_APPROVAL_REASON)
                } else {
                    None
                }
            } else if !granular_config.allows_sandbox_approval() {
                Some(REJECT_SANDBOX_APPROVAL_REASON)
            } else {
                None
            }
        }
    }
}

因此 rules=false, sandbox_approval=true 会拒绝显式 Prompt rule,但仍允许普通 sandbox escalation;反过来则允许规则 prompt,却拒绝模型请求额外文件或无 sandbox 权限。

3. 命令Fallback ​

没有显式 rule 命中时,exec policy fallback 会结合危险命令、文件权限、sandbox override 请求和 approval mode 决定 Allow、Prompt 或 Forbidden。

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

rust
if dangerous_command_match.is_some() || windows_managed_fs_restrictions_without_sandbox_backend {
    return match approval_policy {
        AskForApproval::Never => Decision::Forbidden,
        AskForApproval::OnRequest
        | AskForApproval::UnlessTrusted
        | AskForApproval::Granular(_) => Decision::Prompt,
    };
}

match approval_policy {
    AskForApproval::Never => {
        Decision::Allow
    }
    AskForApproval::UnlessTrusted => {
        Decision::Prompt
    }
    AskForApproval::OnRequest => {
        match file_system_sandbox_policy.kind {
            FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
                Decision::Allow
            }
            FileSystemSandboxKind::Restricted => {
                if sandbox_permissions.requests_sandbox_override() {
                    Decision::Prompt
                } else {
                    Decision::Allow
                }
            }
        }
    }
    AskForApproval::Granular(_) => match file_system_sandbox_policy.kind {
        FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
            Decision::Allow
        }
        FileSystemSandboxKind::Restricted => {
            if sandbox_permissions.requests_sandbox_override() {
                Decision::Prompt
            } else {
                Decision::Allow
            }
        }
    },
}

这段逻辑解释了几个反直觉现象:

  • Never 对普通命令返回 Allow,是因为后续依赖现有 sandbox;危险命令则 Forbidden。
  • OnRequest 在 restricted sandbox 中不为普通命令提示,只有请求 override 时才 Prompt。
  • UnlessTrusted 对未命中 allow rule 的普通命令也 Prompt。
  • Granular 先产生 Prompt,之后再由类别开关决定允许提示还是 Forbidden。

4. Requirement结果 ​

exec policy 最终将 Decision 映射为 ExecApprovalRequirement。Prompt 若被当前 policy 禁止,会转换成 Forbidden;Allow 转成 Skip,但只有每个 command segment 都命中显式 allow rule,才设置 bypass_sandbox=true。

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

rust
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: 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
    },
},

NeedsApproval 的 proposed amendment 只是交互选项,只有 reviewer 返回相应 amendment decision 且后续持久化成功时才会影响未来调用。

5. 集中审批顺序 ​

所有 ApprovalAction 首先转换为 Permission Request Hook payload。Hook 返回 Allow 或 Deny 时不会继续调用 Guardian/用户;只有 Hook 返回 None,才选择 reviewer。

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

rust
let resolution = match run_permission_request_hooks(
    self,
    ctx.review_context.turn(),
    &permission_request_run_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,
};

retry 使用不同 permission_request_run_id:普通动作使用 call ID,retry 追加 :retry;network 与 execve 使用各自稳定 ID。这让 Hook 可以区分初次请求与升级重试。

6. Reviewer路由 ​

reviewer 配置是独立的 ApprovalsReviewer::{User, AutoReview}。AutoReview 只有在 approval policy 为 OnRequest 或 Granular 时自动路由 Guardian;UnlessTrusted 默认仍面向用户。strict auto review 则不受这一普通路由限制,直接强制 Guardian。

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

rust
pub enum ApprovalsReviewer {
    #[default]
    #[serde(rename = "user")]
    User,
    #[serde(rename = "auto_review", alias = "guardian_subagent")]
    #[strum(serialize = "auto_review")]
    AutoReview,
}

源码位置:codex-rs/core/src/guardian/review.rs :: routes_approval_policy_to_guardian

rust
pub(crate) fn routes_approval_policy_to_guardian(
    approval_policy: AskForApproval,
    approvals_reviewer: ApprovalsReviewer,
) -> bool {
    matches!(
        approval_policy,
        AskForApproval::OnRequest | AskForApproval::Granular(_)
    ) && approvals_reviewer == ApprovalsReviewer::AutoReview
}

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

rust
let reviewer = if ctx.strict_auto_review {
    ApprovalReviewer::Guardian
} else if let ApprovalAction::McpToolCall {
    approval_policy,
    reviewer,
    ..
} = &action
{
    ApprovalReviewer::for_policy(*approval_policy, *reviewer)
} else {
    ApprovalReviewer::for_turn(ctx.review_context.turn())
};

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

MCP action 可以携带自己的 approval policy 与 reviewer,避免把某个 connector 的配置错误套用为整个 Turn 的 reviewer 选择。

7. 决定解释 ​

reviewer 返回的并不只有 Approved/Denied。统一 resolution 层处理 timeout、Turn abort、network policy amendment 和 MCP policy amendment,且部分 special decision 只对对应 action 合法。

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

rust
match self.decision {
    ReviewDecision::ApprovedMcpPolicyAmendment => {
        error!("Tool approval received ApprovedMcpPolicyAmendment");
        Err(ToolError::Rejected(
            "Error while requesting approval".to_string(),
        ))
    }
    ReviewDecision::NetworkPolicyAmendment {
        network_policy_amendment,
    } if network_policy_amendment.action == NetworkPolicyRuleAction::Deny => {
        let rejection = match source {
            ApprovalResolutionSource::Hook => "rejected by configuration",
            ApprovalResolutionSource::Guardian => {
                "automatic approval review denied the action"
            }
            ApprovalResolutionSource::User => "rejected by user",
        };
        Err(ToolError::Rejected(rejection.to_string()))
    }
    ReviewDecision::Denied { rejection } => Err(ToolError::Rejected(rejection)),
    ReviewDecision::TimedOut => {
        Err(ToolError::Rejected(guardian_timeout_message(model_info)))
    }
    ReviewDecision::Abort => Err(ToolError::Codex(CodexErr::TurnAborted)),
    decision => Ok(decision),
}

Abort 与 Denied 不同:前者终止当前 Turn,后者只是拒绝工具动作。网络 Guardian 的 Abort 又在 request_approval 中被转换为普通 rejection,避免取消自动 reviewer 意外中止主 Turn。

8. 会话缓存 ​

通用 approval cache 只缓存 ApprovedForSession,且 key 必须完整匹配。Unified Exec key 包含 environment ID、可执行文件、canonical command、cwd、TTY、sandbox permission 和 additional permissions;Apply Patch 则按 environment 与每个 path 分别建 key。

源码位置: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(),
    executable: command.first().cloned(),
    command: canonicalize_command_for_approval(command),
    cwd: cwd.clone(),
    tty: *tty,
    sandbox_permissions: *sandbox_permissions,
    additional_permissions: additional_permissions.clone(),
})],

源码位置: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);
    }
}

环境、cwd 或权限请求变化都会造成 cache miss。NetworkAccess、MCP 与 RequestPermissions 不使用这个通用 key cache:network 有自己的 host/session policy,MCP 有 session/persistent approval 机制,不能把三者混成一种“批准过就永久放行”。

9. 重试审批 ​

Orchestrator 的 already_approved 只是当前工具调用内的局部变量。sandbox denial 后,非 strict、非 network retry 可以由 should_bypass_approval 避免重复询问;strict auto review 必须再次 Guardian 审查,network retry 也不能复用普通 approval。

源码位置:codex-rs/core/src/tools/orchestrator.rs :: retry approval selection

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,
    };
    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 {
        review_context: GuardianReviewContext::from(&tool_ctx.step_context),
        cancellation_token: Some(tool_ctx.cancellation_token.clone()),
        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?;
}

重试审批拥有明确 retry_reason,因此 reviewer 看到的是“初次 sandbox 为什么失败、现在要求什么变化”,而不是把初次批准静默扩大成更高权限。

10. 模式矩阵 ​

场景UnlessTrustedOnRequestGranularNever
restricted 普通命令Promptsandboxed Allowsandboxed Allowsandboxed Allow
dangerous commandPromptPrompt按类别 Prompt/ForbiddenForbidden
exec-policy Prompt rulePromptPromptrules 决定Forbidden
sandbox overridePromptPromptsandbox_approval 决定Forbidden
MCP elicitation允许进入流程允许进入流程mcp_elicitations 决定禁止
AutoReview reviewer默认用户GuardianGuardian默认用户
strict auto reviewGuardianGuardianGuardianGuardian

矩阵中的 “Allow” 表示无需审批,不表示无 sandbox。尤其 Never 的 restricted 普通命令仍依赖 sandbox;危险动作才会因无法提示而 Forbidden。

11. 测试反推边界 ​

以下测试覆盖 prompt 分类、集中 resolution、reviewer 路由和会话缓存相关行为:

text
cd codex-rs
cargo test -p codex-core --lib exec_policy::tests:: -- --nocapture --test-threads=1
cargo test -p codex-core --lib tools::approvals::tests:: -- --nocapture --test-threads=1
cargo test -p codex-protocol --lib config_types::tests::approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent -- --nocapture --test-threads=1
RUST_MIN_STACK=8388608 cargo test -p codex-core --lib session::tests::guardian_tests::strict_auto_review_turn_grant_forces_guardian_for_exec_command_policy_skip -- --nocapture --test-threads=1
cargo test -p codex-core --lib codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_result -- --nocapture --test-threads=1

五组目标共通过 92 项。strict Guardian 用例需要更大的测试线程栈;默认栈会在 approval-review 线程溢出,扩大栈后断言通过。这个运行条件不改变审批逻辑,只影响测试 harness 的线程栈容量。

mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision 同时制造 rule prompt 与 sandbox escalation,再关闭 granular rules。断言拒绝理由来自 rules 类别,证明 prompt 分类有优先级;它不验证 UI 文案。

approval_resolution_aborts_turn_when_approval_is_aborted 构造 Abort decision,断言转换成 CodexErr::TurnAborted;network/MCP special decision 测试则要求错误 action 类型不能被普通工具接受。这验证统一 resolution 层,不验证 reviewer 如何产生决定。

strict_auto_review_turn_grant_forces_guardian_for_exec_command_policy_skip 输入原本可 Skip 的命令与 strict grant,断言仍启动 Guardian,且用户审批通道没有接管。它证明 strict mode 覆盖普通 reviewer routing,但不证明 Guardian 一定批准。

测试能够证明策略分支、决定解释和 reviewer 选择,不能证明用户实际理解提示,也不能把 Guardian 风险判断当成 OS 强制。审批之后仍需继续检查 PermissionProfile 与 sandbox attempt。

调试时先辨认失败发生在哪一步:出现 “policy is Never” 或 granular category reason,说明 prompt 根本未获准;出现 user/Guardian denial,说明进入了 reviewer;出现 TurnAborted,说明 decision 是 Abort;批准后仍有 sandbox denial,则问题已经进入权限与强制层,不应继续归因于 approval policy。

下一篇PermissionProfile解析将继续解释配置 profile、内置 profile、extends、requirements 约束与 environment override 如何生成每个 Turn 的 canonical 权限。