Skip to content

Approvals协议

沿着审批请求、reviewer路由、缓存与回传链,理解命令、补丁、网络、MCP和权限审批的真实协议边界。

基于rust-v0.150.0
CodexRustProtocolApprovalSecurity

Approvals协议 ​

本文承接ConversationItem类型体系和Event与EventMsg总表。审批在 Codex 中不是一个布尔字段,而是一条跨层协议:工具把动作描述成 ApprovalAction,Core 先运行 permission hook,再选择 Guardian 或用户 reviewer,结果通过 ReviewDecision 回到挂起的工具;App Server 和 TUI 还会把同一个结果投影成自己的决策枚举。

阅读时要分开三件事:请求描述了什么动作,决定会改变什么状态,以及决定如何唤醒等待者。尤其要区分一次批准、会话缓存批准、持久策略 amendment、拒绝、超时和取消。

1. 请求类型 ​

源码位置:codex-rs/protocol/src/approvals.rs :: ExecApprovalRequestEvent、ApplyPatchApprovalRequestEvent、ElicitationRequestEvent

命令审批不仅携带命令和 cwd,还携带 command item 的 call_id、可能不同的 approval_id、turn/environment 身份、网络上下文、额外权限和可选策略修改。补丁审批描述文件变更;MCP elicitation 使用 server 和 request id;这些不是同一套 payload。

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

rust
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ExecApprovalRequestEvent {
    #[serde(default)]
    pub kind: ExecApprovalKind,
    pub call_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub plugin_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub script_path: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval_id: Option<String>,
    #[serde(default)]
    pub turn_id: String,
    #[serde(default, rename = "environmentId", alias = "environment_id")]
    pub environment_id: Option<String>,
    pub started_at_ms: i64,
    pub command: Vec<String>,
    pub cwd: AbsolutePathBuf,
    pub reason: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub network_approval_context: Option<NetworkApprovalContext>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub proposed_network_policy_amendments: Option<Vec<NetworkPolicyAmendment>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub additional_permissions: Option<AdditionalPermissionProfile>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub available_decisions: Option<Vec<ReviewDecision>>,
    pub parsed_cmd: Vec<ParsedCommand>,
}

approval_id 默认为空并不代表请求没有身份:effective_approval_id() 会回退到 call_id。而 stdin 或 execve 子命令审批必须使用独立 callback id,否则同一 command item 的多个等待者无法区分。

源码位置:codex-rs/protocol/src/approvals.rs :: effective_approval_id、effective_available_decisions

rust
pub fn effective_approval_id(&self) -> String {
    self.approval_id
        .clone()
        .unwrap_or_else(|| self.call_id.clone())
}

pub fn effective_available_decisions(&self) -> Vec<ReviewDecision> {
    match &self.available_decisions {
        Some(decisions) => decisions.clone(),
        None => Self::default_available_decisions(
            self.network_approval_context.as_ref(),
            self.proposed_execpolicy_amendment.as_ref(),
            self.proposed_network_policy_amendments.as_deref(),
            self.additional_permissions.as_ref(),
        ),
    }
}

2. 可选决定 ​

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

rust
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum ReviewDecision {
    Approved,
    ApprovedExecpolicyAmendment {
        proposed_execpolicy_amendment: ExecPolicyAmendment,
    },
    ApprovedForSession,
    ApprovedMcpPolicyAmendment,
    NetworkPolicyAmendment {
        network_policy_amendment: NetworkPolicyAmendment,
    },
    Denied { rejection: String },
    TimedOut,
    Abort,
}

语义不能折叠:Approved 只放行当前等待者;ApprovedForSession 允许命中 session-scoped cache;execpolicy 和 network amendment 会改变未来匹配请求;Denied 让 turn 有机会继续尝试其他方案;Abort 则要求停止当前 turn;TimedOut 表示自动 review 没有及时给出终态。

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

rust
pub fn to_opaque_string(&self) -> &'static str {
    match self {
        ReviewDecision::Approved => "approved",
        ReviewDecision::ApprovedExecpolicyAmendment { .. } => "approved_with_amendment",
        ReviewDecision::ApprovedForSession => "approved_for_session",
        ReviewDecision::ApprovedMcpPolicyAmendment => "approved_mcp_policy_amendment",
        ReviewDecision::NetworkPolicyAmendment { network_policy_amendment } => match network_policy_amendment.action {
            NetworkPolicyRuleAction::Allow => "approved_with_network_policy_allow",
            NetworkPolicyRuleAction::Deny => "denied_with_network_policy_deny",
        },
        ReviewDecision::Denied { .. } => "denied",
        ReviewDecision::TimedOut => "timed_out",
        ReviewDecision::Abort => "abort",
    }
}

遥测使用 opaque label,不把拒绝文本写入决定分类。Denied { rejection } 的 message 仍会作为工具错误返回,但不应被当作稳定枚举值。

3. Core动作模型 ​

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

rust
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ApprovalAction {
    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>,
    },
    #[cfg(unix)]
    Execve {
        id: String,
        approval_id: String,
        environment_id: String,
        source: GuardianCommandSource,
        program: AbsolutePathBuf,
        argv: Vec<String>,
        command: Vec<String>,
        cwd: AbsolutePathBuf,
        additional_permissions: Option<AdditionalPermissionProfile>,
    },
    ApplyPatch {
        id: String,
        environment_id: String,
        cwd: PathUri,
        files: Vec<PathUri>,
        patch: String,
        changes: Arc<HashMap<PathBuf, FileChange>>,
        permissions_preapproved: bool,
    },
    McpToolCall { /* server, tool, connector and reviewer fields */ },
    NetworkAccess { /* turn, target, host, protocol and port fields */ },
    RequestPermissions { /* reason and requested profile */ },
}

这是 Core 的动作分类,不是对外 wire schema。permission_request_payload() 会把它们投影为 hook 能理解的工具名和 JSON 输入;into_guardian_request() 再投影为 Guardian 的 canonical action。

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

rust
pub(crate) fn permission_request_payload(&self) -> PermissionRequestPayload {
    match self {
        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 }),
        },
        Self::McpToolCall { hook_tool_name, arguments, .. } => PermissionRequestPayload {
            tool_name: hook_tool_name.clone(),
            tool_input: arguments.clone().unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
        },
        Self::NetworkAccess { hook_command, target, .. } => PermissionRequestPayload::bash(
            hook_command.clone(),
            Some(format!("network-access {target}")),
        ),
        Self::RequestPermissions { reason, permissions, .. } => PermissionRequestPayload {
            tool_name: HookToolName::new("request_permissions"),
            tool_input: serde_json::json!({ "reason": reason, "permissions": permissions }),
        },
        #[cfg(unix)]
        Self::Execve { command, .. } => PermissionRequestPayload::bash(
            codex_shell_command::parse_command::shlex_join(command),
            None,
        ),
    }
}

4. 审批优先级 ​

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

rust
pub(crate) async fn request_approval(
    self: &Arc<Self>,
    action: ApprovalAction,
    ctx: ApprovalContext,
) -> Result<ReviewDecision, ToolError> {
    let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. });
    let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. });
    let permission_request_run_id = match &action {
        #[cfg(unix)]
        ApprovalAction::Execve { approval_id, .. } => approval_id.clone(),
        ApprovalAction::NetworkAccess { hook_run_id, .. } => hook_run_id.clone(),
        _ if ctx.retry_reason.is_some() => format!("{}:retry", ctx.call_id),
        _ => ctx.call_id.clone(),
    };

    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,
    };
    resolution.into_tool_result(&ctx.review_context.turn().model_info)
}

优先级是 hook → Guardian 或用户 reviewer。strict auto review 强制 Guardian;普通路径依据 turn 的 approval policy 和 ApprovalsReviewer 选择 reviewer。请求结果还要经过 ApprovalResolution::into_tool_result,决定不是直接等于工具返回值。

5. 缓存键与持久化 ​

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

rust
pub(crate) fn cache_keys(&self) -> Vec<ApprovalCacheKey> {
    match self {
        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(),
        })],
        #[cfg(unix)]
        Self::Execve { .. } => Vec::new(),
        Self::McpToolCall { .. }
        | Self::NetworkAccess { .. }
        | Self::RequestPermissions { .. } => Vec::new(),
        Self::ApplyPatch { environment_id, files, .. } => files
            .iter()
            .cloned()
            .map(|path| ApprovalCacheKey::ApplyPatch(ApplyPatchApprovalKey {
                environment_id: environment_id.clone(),
                path,
            }))
            .collect(),
    }
}

命令缓存键包含 environment、规范化命令、cwd、TTY、sandbox permissions 和 additional permissions;补丁按 environment 与文件路径建键;网络、MCP 和权限请求不使用这个通用 cache。不能把“同一 command 文本”当成唯一批准条件。

6. 决策回传 ​

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

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

pub async fn patch_approval(sess: &Arc<Session>, id: String, decision: ReviewDecision) {
    match decision {
        ReviewDecision::Abort => sess.interrupt_task().await,
        other => sess.notify_approval(&id, other).await,
    }
}

策略 amendment 的持久化失败只发 warning,不会自动改写用户的原决定;Abort 绕过 approval waiter,直接中断 task。其他决定通过 callback id 唤醒挂起的工具。

7. 决定与工具结果 ​

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

rust
fn into_tool_result(self, model_info: &ModelInfo) -> Result<ReviewDecision, ToolError> {
    let source = self.source;
    match self.decision {
        ReviewDecision::ApprovedMcpPolicyAmendment => Err(ToolError::Rejected(
            "Error while requesting approval".to_string(),
        )),
        ReviewDecision::NetworkPolicyAmendment {
            network_policy_amendment,
        } if network_policy_amendment.action == NetworkPolicyRuleAction::Deny => {
            Err(ToolError::Rejected("rejected by reviewer".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),
    }
}

MCP policy amendment 在 command approval 结果转换中会被拒绝,MCP 自身走 elicitation 协议;网络 deny amendment 作为拒绝返回;超时使用当前 acting model 的 timeout instructions;Abort 映射为 TurnAborted。这就是为什么 UI 的“取消”和工具层的错误类型不能只靠字符串比较。

8. MCP与App Server ​

MCP elicitation 的 wire 动作是 Accept、Decline、Cancel,由 session handler 把客户端请求转换成 rmcp 的 ElicitationResponse。Accept 没有 content 时会补空对象,兼容只发送 action 的旧客户端。

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

rust
let action = match decision {
    codex_protocol::approvals::ElicitationAction::Accept => ElicitationAction::Accept,
    codex_protocol::approvals::ElicitationAction::Decline => ElicitationAction::Decline,
    codex_protocol::approvals::ElicitationAction::Cancel => ElicitationAction::Cancel,
};
let content = match action {
    ElicitationAction::Accept => Some(content.unwrap_or_else(|| serde_json::json!({}))),
    ElicitationAction::Decline | ElicitationAction::Cancel => None,
};
let response = ElicitationResponse { action, content, meta };

App Server v2 又有自己的 command/file decision enum。它把 Core ReviewDecision::Abort 映射为 Cancel,Denied/TimedOut 映射为 Decline,execpolicy 和 network amendment 映射为带 payload 的决定;MCP policy amendment 在 command approval 投影中 fail closed。

源码位置:codex-rs/app-server-protocol/src/protocol/v2/item.rs :: CommandExecutionApprovalDecision、From<CoreReviewDecision>

rust
pub enum CommandExecutionApprovalDecision {
    Accept,
    AcceptForSession,
    AcceptWithExecpolicyAmendment {
        execpolicy_amendment: ExecPolicyAmendment,
    },
    ApplyNetworkPolicyAmendment {
        network_policy_amendment: NetworkPolicyAmendment,
    },
    Decline,
    Cancel,
}

impl From<CoreReviewDecision> for CommandExecutionApprovalDecision {
    fn from(value: CoreReviewDecision) -> Self {
        match value {
            CoreReviewDecision::Approved => Self::Accept,
            CoreReviewDecision::ApprovedForSession => Self::AcceptForSession,
            CoreReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment } => {
                Self::AcceptWithExecpolicyAmendment {
                    execpolicy_amendment: proposed_execpolicy_amendment.into(),
                }
            }
            CoreReviewDecision::NetworkPolicyAmendment { network_policy_amendment } => {
                Self::ApplyNetworkPolicyAmendment {
                    network_policy_amendment: network_policy_amendment.into(),
                }
            }
            CoreReviewDecision::Abort => Self::Cancel,
            CoreReviewDecision::Denied { .. } | CoreReviewDecision::TimedOut => Self::Decline,
            CoreReviewDecision::ApprovedMcpPolicyAmendment => Self::Decline,
        }
    }
}

9. 测试与边界 ​

Core 单元测试覆盖 deny network amendment、MCP policy amendment、Abort 和 timeout message;network approval 测试覆盖 environment/port/turn/execution 去重、pending waiter、disconnect fallback 和具体 outcome 覆盖。协议和 App Server 测试则验证字段序列化与决定映射。

源码位置:codex-rs/core/src/tools/approvals_tests.rs :: approval_resolution_rejects_denied_network_policy_amendment、approval_resolution_rejects_mcp_policy_amendment、approval_resolution_aborts_turn_when_approval_is_aborted、approval_resolution_uses_acting_model_timeout_instructions

源码位置:codex-rs/core/src/tools/network_approval_tests.rs :: pending_approvals_are_deduped_within_one_execution、pending_waiters_receive_owner_decision、disconnect_fallback_cancels_execution_and_yields_to_explicit_denial

源码位置:codex-rs/app-server-protocol/src/protocol/common.rs :: serialize_server_response

text
cd codex-rs
cargo test -p codex-core approval_resolution_rejects_denied_network_policy_amendment -- --nocapture --test-threads=1
cargo test -p codex-core approval_resolution_rejects_mcp_policy_amendment -- --nocapture --test-threads=1
cargo test -p codex-core approval_resolution_aborts_turn_when_approval_is_aborted -- --nocapture --test-threads=1
cargo test -p codex-core approval_resolution_uses_acting_model_timeout_instructions -- --nocapture --test-threads=1
cargo test -p codex-core pending_approvals_are_deduped_within_one_execution -- --nocapture --test-threads=1
cargo test -p codex-core pending_waiters_receive_owner_decision -- --nocapture --test-threads=1

这些测试证明决定转换、waiter 唤醒和去重边界;不能证明 TUI 或远端 App Server 一定展示了每个可选决定,也不能证明 amendment 在所有平台即时生效。

10. 源码定位练习 ​

遇到“允许后命令仍未运行”,先用 effective_approval_id 对照请求和回传,再检查 notify_approval 的 waiter;遇到“取消没有结束 turn”,确认是否真的走了 ReviewDecision::Abort,而不是 Denied 或 TimedOut。

遇到“同一请求重复弹窗”,检查 ApprovalAction::cache_keys、environment/cwd/TTY/sandbox 字段和 network approval 的 pending key;遇到“网络规则已选但本次仍失败”,区分 NetworkPolicyAmendment 的 allow/deny 结果与策略持久化路径。

审批协议的核心不是按钮文案,而是动作身份、reviewer 所有权、缓存范围、策略副作用和等待者生命周期。沿着 ApprovalAction → ReviewDecision → handler → tool result 这条链阅读,才能定位一次批准究竟在哪一层被转换或拒绝。