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
#[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
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
#[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
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
#[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
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
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
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
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
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
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>
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
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 这条链阅读,才能定位一次批准究竟在哪一层被转换或拒绝。
