工具审批架构
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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);
}
}
decision7.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
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
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_*
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
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
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
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. 阅读实践
在源码仓库根目录运行下面的定向测试:
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然后用三个场景复述:
- 受限 sandbox 下的
OnRequestshell 命令,哪一个函数先产生NeedsApproval,哪一个函数把它变成用户事件? - 用户选择
ApprovedForSession后,哪些字段组成 cache key?改变 cwd、tty、sandbox permissions 或文件路径会怎样? - 首次 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;本文先把共享审批骨架限定清楚。
