网络审批与规则保存
网络审批发生在请求已经抵达网络代理之后。代理先产生 BlockedRequest,Codex 再把它归因到某个活动工具调用,按 environment_id + host + protocol + port 查找会话状态;如果没有现成决定,同一个 host 的并发请求共享一个 pending approval。用户或 Guardian 的决定随后可能只放行本次、放行本 session,或把 allow/deny 写入 network policy 文件。
本文面向已经读过工具审批架构、ToolOrchestrator执行流程 和ToolOutput与错误模型的读者。前文解释通用 reviewer 路由、sandbox retry 和工具错误,本文只研究网络策略 在代理阻断后的专用状态机,不重复一般 command approval,也不展开代理实现和 MCP elicitation。读完后,读者应能解释一个 网络阻断如何找到所属 call、为什么 HTTP 和 SOCKS5 不共享批准、Deferred 如何改变失败时机,以及规则保存失败 时为什么不会误把 host 当作已授权。
1. 网络边界
1.1 代理阻断
工具 runtime 只有在 managed network active 时才创建网络审批 spec。spec 携带执行代理、触发信息、 command、environment 和 permission profile;真正的网络请求由代理在运行时观察并回调 NetworkPolicyDecider。
相关源码:
codex-rs/core/src/tools/sandboxing.rs :: ToolRuntime::network_approval_speccodex-rs/core/src/tools/runtimes/unified_exec.rs :: UnifiedExecRuntime::network_approval_spec
let managed_network_active = turn_ctx.network.is_some();
let network_approval = begin_network_approval(
&tool_ctx.session,
turn_ctx,
managed_network_active,
tool.network_approval_spec(req, tool_ctx),
)
.await?;当前实现不再由 runtime 暴露独立的两阶段枚举;ToolOrchestrator::run_attempt 在工具结果返回后, 把活动审批转换为 DeferredNetworkApproval,统一交给后续 finish 路径。Unified Exec 进程 manager 还会在进程退出或晚到 网络拒绝时再次 finish,保证网络 outcome 不因工具先返回而丢失。
1.2 四元键
网络审批不会只按域名缓存。HostApprovalKey 将 host 转成 ASCII 小写,并保留 environment、协议标签和端口;因此 HTTP example.com:80、HTTPS example.com:443、SOCKS5 example.com:1080 是三个不同的决策对象。
源码位置:codex-rs/core/src/tools/network_approval.rs :: HostApprovalKey::from_request
fn from_request(
request: &NetworkPolicyRequest,
protocol: NetworkApprovalProtocol,
environment_id: String,
) -> Self {
Self {
environment_id,
host: request.host.to_ascii_lowercase(),
protocol: protocol_key_label(protocol),
port: request.port,
}
}2. 活动调用
2.1 注册代理
begin_network_approval 为一次工具执行生成 registration id 和 attribution token,创建 execution-scoped proxy,并把 ActiveNetworkApprovalCall 放入 active_calls。取消令牌同时交给代理和后续 pending owner;网络拒绝时可以让正在运行的 命令尽快停止。
源码位置:codex-rs/core/src/tools/network_approval.rs :: begin_network_approval
let registration_id = Uuid::new_v4().to_string();
let attribution_token = Uuid::new_v4().to_string();
let execution_proxy = network
.for_execution(
&environment_id,
®istration_id,
attribution_token,
environment_policy,
fallback_policy_decider,
)
.map_err(|err| {
ToolError::Codex(codex_protocol::error::CodexErr::Io(
io::Error::other(format!(
"failed to create execution-scoped network proxy: {err}"
)),
))
})?;
let cancellation_token = CancellationToken::new();
session
.services
.network_approval
.register_call(ActiveNetworkApprovalCall {
registration_id: registration_id.clone(),
turn_id: turn.sub_id.clone(),
trigger,
tool_name,
command,
environment_id,
permission_profile,
cancellation_token: cancellation_token.clone(),
})
.await;2.2 请求归因
代理回调可能携带 execution_id,也可能只有 environment,甚至两者都没有。服务优先使用 execution id 精确查找;没有 execution id 时,只有一个活动调用才允许推断归属;多个活动调用会得到 Ambiguous,避免把一个 host 阻断错误地归给另一个 命令。
源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkApprovalService::resolve_request_attribution
if let Some(execution_id) = request.execution_id.as_deref() {
let call = self
.resolve_active_call_by_execution_id(execution_id)
.await?;
let environment_id = request
.environment_id
.clone()
.unwrap_or_else(|| call.environment_id.clone());
return (call.environment_id == environment_id).then_some(
NetworkRequestAttribution {
owner_call: Some(call),
environment_id: Some(environment_id),
},
);
}2.3 结束清理
ActiveNetworkApproval 在一次 attempt 中持有 registration、取消令牌和 execution proxy。工具返回后, ToolOrchestrator::run_attempt 将它转换成 DeferredNetworkApproval;如果工具已经失败,会立即 finish, 成功结果则把 deferred handle 交给上层。两条路径最终都会从 active_calls 移除 registration,并把记录的 outcome 转换成 ToolError。
源码位置:codex-rs/core/src/tools/orchestrator.rs :: ToolOrchestrator::run_attempt
let network_approval = begin_network_approval(
&tool_ctx.session,
&tool_ctx.step_context.turn,
attempt.enforce_managed_network,
network_approval_spec,
)
.await?;
let run_result = tool
.run(req, &attempt_with_network_approval, &attempt_tool_ctx)
.await;
let deferred = network_approval.into_deferred();
if run_result.is_err() {
let finalize_result =
finish_deferred_network_approval(&tool_ctx.session, deferred).await;
if let Err(err) = finalize_result {
return (Err(err), None);
}
return (run_result, None);
}
(run_result, deferred)3. 阻断处理
3.1 观察者入口
代理的 blocked observer 不直接弹 UI,而是先记录 sandbox violation,再把 BlockedRequest 交给 NetworkApprovalService::record_blocked_request。服务只有找到了唯一 owner 才会写入 call_outcomes 并取消该 call。
源码位置:codex-rs/core/src/tools/network_approval.rs :: build_blocked_request_observer
pub(crate) fn build_blocked_request_observer(
network_approval: Arc<NetworkApprovalService>,
) -> Arc<dyn BlockedRequestObserver> {
Arc::new(move |blocked: BlockedRequest| {
let network_approval = Arc::clone(&network_approval);
async move {
record_network_sandbox_violation(&blocked);
network_approval.record_blocked_request(blocked).await;
}
})
}源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkApprovalService::record_blocked_request
let Some(message) = denied_network_policy_message(&blocked) else {
return;
};
let owner_call = if let Some(execution_id) = blocked.execution_id.as_deref() {
self.resolve_active_call_by_execution_id(execution_id).await
} else {
self.resolve_single_active_call().await
};
let Some(owner_call) = owner_call else {
return;
};
self.record_call_outcome_if_absent(&owner_call.registration_id, message);3.2 内联决策器
build_network_policy_decider 是代理的同步概念与 Session 的异步服务之间的适配器。Session 已被 drop 时返回 NetworkDecision::ask("not_allowed");否则调用 handle_inline_policy_request,由它处理缓存、pending approval 和 reviewer。
源码位置:codex-rs/core/src/tools/network_approval.rs :: build_network_policy_decider
pub(crate) fn build_network_policy_decider(
network_approval: Arc<NetworkApprovalService>,
network_policy_decider_session: Arc<RwLock<std::sync::Weak<Session>>>,
) -> Arc<dyn NetworkPolicyDecider> {
Arc::new(move |request: NetworkPolicyRequest| {
let network_approval = Arc::clone(&network_approval);
let network_policy_decider_session = Arc::clone(&network_policy_decider_session);
async move {
let Some(session) = network_policy_decider_session.read().await.upgrade() else {
return NetworkDecision::ask("not_allowed");
};
network_approval
.handle_inline_policy_request(session, request)
.await
}
})
}4. Pending并发
4.1 Pending键
同一个 host 的并发请求不应该弹多个相同审批框,但不同 turn 或 execution 不能互相唤醒。PendingHostApprovalKey 在 HostApprovalKey 外增加 turn_id 和可选 execution id,因此服务能共享正确范围的等待者。
源码位置:codex-rs/core/src/tools/network_approval.rs :: PendingHostApprovalKey
struct PendingHostApprovalKey {
host: HostApprovalKey,
turn_id: String,
execution_id: Option<String>,
}get_or_create_pending_approval 返回 (pending, is_owner)。第一个请求成为 owner,负责运行 Hook/Guardian/User;后续请求 只等待 PendingHostApproval::wait_for_decision,不会重复发起 reviewer。
源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkApprovalService::get_or_create_pending_approval
let (pending, is_owner) = self.get_or_create_pending_approval(pending_key.clone());
if !is_owner {
return pending.wait_for_decision().await.to_network_decision();
}
let mut pending_owner = PendingHostApprovalOwner::new(
self,
pending_key,
Arc::clone(&pending),
owner_call
.as_ref()
.map(|call| call.cancellation_token.clone()),
);4.2 Drop清理
PendingHostApprovalOwner 是 owner 的 RAII 清理边界。正常完成时先从 map 删除当前 generation,再唤醒等待者;如果 owner 在完成前 drop,则使用 decision_on_drop,默认 Deny,并取消关联执行。删除前检查 Arc::ptr_eq,避免旧 owner 删除了 同 key 的新一代 pending。
源码位置:codex-rs/core/src/tools/network_approval.rs :: PendingHostApprovalOwner::publish_and_remove
fn publish_and_remove(&self, decision: PendingApprovalDecision) {
let mut approvals = self
.service
.pending_host_approvals
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if approvals
.get(&self.key)
.is_some_and(|current| Arc::ptr_eq(current, &self.pending))
{
approvals.remove(&self.key);
}
drop(approvals);
self.pending.set_decision(decision);
}4.3 决策广播
PendingHostApproval 用 SyncOnceLock 保证决定只写一次,Notify 唤醒所有等待者。AllowOnce 和 AllowForSession 都映射为代理的 NetworkDecision::Allow;Deny 映射为带 not_allowed 的拒绝。
源码位置:codex-rs/core/src/tools/network_approval.rs :: PendingHostApproval
async fn wait_for_decision(&self) -> PendingApprovalDecision {
loop {
let notified = self.notify.notified();
if let Some(decision) = self.decision.get() {
return *decision;
}
notified.await;
}
}
fn set_decision(&self, decision: PendingApprovalDecision) {
if self.decision.set(decision).is_ok() {
self.notify.notify_waiters();
}
}5. 决策路径
5.1 缓存优先
handle_inline_policy_request 在创建 pending 前先检查 session_denied_hosts 和 session_approved_hosts,两者都在 session_policy_commit_lock 下读取。已 deny 的 host 立即拒绝,已 approve 的 host 立即允许,不会再访问 reviewer。
源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkApprovalService::handle_inline_policy_request
let _commit_guard = self.session_policy_commit_lock.lock().await;
{
let denied_hosts = self.session_denied_hosts.lock().await;
if denied_hosts.contains(&key) {
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
}
let approved_hosts = self.session_approved_hosts.lock().await;
if approved_hosts.contains(&key) {
return NetworkDecision::Allow;
}5.2 Hook与Guardian
没有缓存命中时,owner 先运行 permission request hook。Hook Allow 直接放行;Hook Deny 写入 policy outcome 并唤醒所有等待者。 Hook 没有决定时,routes_approval_to_guardian 选择 Guardian 或用户 command approval。Guardian 请求的 target 包含协议、host、 port 和触发工具;User 请求使用 network_approval_context,因此界面可以显示“哪个 host 被阻止”。
源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkApprovalService::handle_inline_policy_request
let hook_approval_decision = match run_permission_request_hooks(
&session,
&turn_context,
&hook_run_id_suffix,
PermissionRequestPayload::bash(
command,
Some(format!("network-access {target}")),
),
)
.await
{
Some(PermissionRequestDecision::Allow) => Some(ReviewDecision::Approved),
Some(PermissionRequestDecision::Deny { message }) => {
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(&owner_call.registration_id, message);
}
pending_owner.complete(PendingApprovalDecision::Deny);
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
None => None,
};5.3 决定映射
网络审批把 ReviewDecision 映射到三个不同层次:Approved 是一次性 allow;ApprovedForSession 写入 host cache; NetworkPolicyAmendment 先尝试持久化规则,再依据持久化结果决定是否更新 session cache。网络审批服务收到 ApprovedMcpPolicyAmendment、Denied、TimedOut 或 Abort 时会记录 centralized decision error 并让 pending owner Deny; 这些变体不在网络审批服务内再次解释为不同的用户拒绝来源。
源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkApprovalService::handle_inline_policy_request
ReviewDecision::ApprovedForSession => {
if self.session_denied_hosts.lock().await.contains(&key) {
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(
&owner_call.registration_id,
policy_denial_message.clone(),
);
}
PendingApprovalDecision::Deny
} else {
self.session_approved_hosts.lock().await.insert(key.clone());
PendingApprovalDecision::AllowForSession
}
}源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkApprovalService::handle_inline_policy_request
ReviewDecision::Denied { rejection } => {
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(&owner_call.registration_id, rejection);
}
PendingApprovalDecision::Deny
}
ReviewDecision::TimedOut => {
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(
&owner_call.registration_id,
crate::guardian::guardian_timeout_message(),
);
}
PendingApprovalDecision::Deny
}6. 规则保存
6.1 Allow规则
选择 NetworkPolicyAmendment::Allow 时,服务调用 Session::persist_network_policy_amendment,同时传入 NetworkApprovalContext。该函数在有运行中 proxy 时先更新 proxy,再追加 execpolicy 规则;proxy 更新成功会先把 owner 的 drop decision 设为 AllowForSession。随后规则文件写入失败会发送 Warning,但当前 Session 可能已经保留这次内存 allow;只有 重新启动 Session 时,是否继续允许才取决于规则文件是否写入成功。
源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkPolicyRuleAction::Allow
match session
.persist_network_policy_amendment(
&network_policy_amendment,
&network_approval_context,
|| {
pending_owner
.set_decision_on_drop(PendingApprovalDecision::AllowForSession);
},
)
.await
{
Ok(()) => {
session
.record_network_policy_amendment_message(
&turn_context.sub_id,
&network_policy_amendment,
)
.await;
}
Err(err) => {
let message = format!("Failed to apply network policy amendment: {err}");
warn!("{message}");
session
.send_event_raw(Event {
id: turn_context.sub_id.clone(),
msg: EventMsg::Warning(WarningEvent { message }),
})
.await;
}
}6.2 Deny规则
选择 NetworkPolicyAmendment::Deny 会尝试保存 deny 规则,然后移除 approved host、加入 denied host,并把当前 owner outcome 记为用户拒绝。当前实现即使规则保存失败也会更新内存 denied cache;失败只通过 Warning 反馈,后续同 key 请求仍会在当前 Session 内直接拒绝,但重新启动 Session 是否加载该规则取决于持久化是否成功。
源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkPolicyRuleAction::Deny
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(
&owner_call.registration_id,
"rejected by user".to_string(),
);
}
self.session_approved_hosts.lock().await.remove(&key);
self.session_denied_hosts.lock().await.insert(key.clone());
PendingApprovalDecision::Deny6.3 提交锁
session_policy_commit_lock 同时保护 approved/denied host cache 和规则保存相关的提交顺序。它避免一个请求在规则落盘 过程中被另一个请求看到半完成状态。sync_session_approved_hosts_to 也在同一把锁下复制 session approved hosts,适用于 子 session 继承当前已批准 host 的场景。
源码位置:codex-rs/core/src/tools/network_approval.rs :: NetworkApprovalService::sync_session_approved_hosts_to
let _commit_guard = self.session_policy_commit_lock.lock().await;
let approved_hosts = self.session_approved_hosts.lock().await.clone();
let mut other_approved_hosts = other.session_approved_hosts.lock().await;
other_approved_hosts.clear();
other_approved_hosts.extend(approved_hosts.iter().cloned());7. 延迟审批收尾
7.1 收尾时机
当前 ToolOrchestrator::run_attempt 对所有带 network approval spec 的 runtime 使用同一 deferred handle:工具 结果失败时立即 finish,工具成功时把 DeferredNetworkApproval 返回给上层。Unified Exec process manager 在 进程退出、晚到网络拒绝和取消路径中继续调用 finish,因此网络结果不会因为工具先返回而丢失。
源码位置:codex-rs/core/src/tools/orchestrator.rs :: ToolOrchestrator::run_attempt
let run_result = tool
.run(req, &attempt_with_network_approval, &attempt_tool_ctx)
.await;
let Some(network_approval) = network_approval else {
return (run_result, None);
};
let deferred = network_approval.into_deferred();
if run_result.is_err() {
let finalize_result =
finish_deferred_network_approval(&tool_ctx.session, deferred).await;
if let Err(err) = finalize_result {
return (Err(err), None);
}
return (run_result, None);
}
(run_result, deferred)run_attempt 本身只负责把 Deferred handle 交给上层;unified exec process manager 在进程退出、晚到的网络拒绝或失败输出 路径中继续调用 finish_deferred_network_approval。因此 Deferred 不是“工具成功就忽略网络结果”,而是把最终检查延后到 进程生命周期更适合的时机。
7.2 取消
网络拒绝会取消 active call 的 cancellation token;owner drop 也默认 Deny 并取消 execution。若审批在决定返回前被取消, abandoned_network_approval_outcome 把 cancellation 转为明确的 policy rejection,而不是无限等待。
源码位置:codex-rs/core/src/tools/network_approval.rs :: abandoned_network_approval_outcome
fn abandoned_network_approval_outcome(
cancellation_token: &CancellationToken,
) -> Option<String> {
cancellation_token
.is_cancelled()
.then(|| ABANDONED_NETWORK_APPROVAL_MESSAGE.to_string())
}8. 非正常路径
8.1 无法归因
没有 execution id 且存在多个 active calls 时,服务不会猜 owner;请求被拒绝或保持 ask,具体取决于代理调用点。这个保守 策略牺牲了一次自动放行,换取不会把 host allow 错绑到另一条命令。
8.2 没有活动Turn
如果 active turn、environment 或 permission profile 不存在,服务会记录 policy outcome 并返回 not_allowed。网络审批不 会为了填补缺失上下文而创建一个脱离 turn 的全局审批。
8.3 保存失败
规则保存失败时发送 Warning。若运行中 proxy 已先更新成功,当前 Session 会保留对应的 approved/denied cache;若没有可更新的 运行中 proxy,则 cache 只有在 execpolicy 写入成功后才会更新。后续请求是否重新审批因此取决于当前 Session 的内存状态, 而新 Session 只会读取成功写入的规则文件。
8.4 用户Abort
User reviewer 的 Abort 会让当前 turn 进入中断;Guardian 的取消则记录 policy denial。两者都让网络代理收到 Deny,但 诊断来源和上层 turn 终态不同。
9. 测试路径
9.1 Host与端口
网络集成测试先批准一个 host,随后使用相同 host、不同端口的命令,断言不同端口仍然触发审批;再使用不同 protocol,断言 HTTP 与 SOCKS5 不共享批准。测试输入直接验证四元 key,而不是只验证 UI 是否出现。
源码位置:codex-rs/core/tests/suite/network_approval.rs :: user_network_approval_once_session_and_denial_semantics
let approval = expect_network_approval_target(
&test,
LOCAL_ENVIRONMENT_ID,
&different_port_target,
NetworkApprovalProtocol::Http,
)
.await?;
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: Some(approval.turn_id),
decision: ReviewDecision::denied("rejected by user"),
})
.await?;
wait_for_turn_complete(&test).await;这证明端口和协议参与审批身份;不证明 DNS 别名、IP literal 或代理重定向会被视为同一 host。
9.2 并发Pending
网络单元测试构造相同 pending key 的多个请求,断言只有第一个请求成为 owner;owner drop 测试再验证 waiters 收到 Deny、关联 execution 被取消、旧 generation 不会删除新 pending。
相关测试:
codex-rs/core/src/tools/network_approval_tests.rs :: pending_approvals_are_deduped_within_one_executioncodex-rs/core/src/tools/network_approval_tests.rs :: dropping_pending_owner_denies_waiters_and_preserves_replacement
源码位置:codex-rs/core/src/tools/network_approval_tests.rs :: pending_approvals_are_deduped_within_one_execution
let (first, first_is_owner) =
service.get_or_create_pending_approval(key.clone());
let (second, second_is_owner) =
service.get_or_create_pending_approval(key);
assert!(first_is_owner);
assert!(!second_is_owner);
assert!(Arc::ptr_eq(&first, &second));对应的 owner-drop 测试还将两个 waiter 与一个 replacement pending 串起来,证明 Drop 清理不是简单 remove(key),而是需要 检查当前 Arc 是否仍属于这一代。
9.3 规则保存
集成测试提交 allow amendment,检查 approval event 同时提供 allow/deny 两个 network amendments;决定返回后检查规则文件 包含 allow 记录,并检查后续相同 host 不再弹网络审批。另一个测试提交错误 host 的 amendment,断言当前网络请求仍被 policy block,错误 amendment 不会授权原 host。
源码位置:codex-rs/core/tests/suite/network_approval.rs :: allowing_network_policy_amendment_persists_context_and_bypasses_prompt
let amendments = approval
.proposed_network_policy_amendments
.clone()
.context("expected network policy amendments")?;
assert_eq!(
amendments,
vec![
NetworkPolicyAmendment {
host: NETWORK_TEST_HOST.to_string(),
action: NetworkPolicyRuleAction::Allow,
},
NetworkPolicyAmendment {
host: NETWORK_TEST_HOST.to_string(),
action: NetworkPolicyRuleAction::Deny,
},
]
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: Some(approval.turn_id),
decision: ReviewDecision::NetworkPolicyAmendment {
network_policy_amendment: amendments[0].clone(),
},
})
.await?;
wait_for_turn_complete(&test).await;
let policy = fs::read_to_string(test.home.path().join("rules/default.rules"))?;
assert!(policy.contains(
r#"network_rule(host="codex-network-test.invalid", protocol="http", decision="allow""#
));9.4 拒绝与取消
测试还覆盖用户 Denied、用户 Abort 和 policy amendment deny:Denied 保持 turn 可继续,Abort 产生 TurnAborted,deny amendment 则把 host 写入 denied cache 并阻止后续请求。三条路径都不应被统称为“网络失败”。
10. 阅读实践
在源码仓库根目录运行定向测试:
RUST_MIN_STACK=16777216 cargo test -p codex-core network_approval
RUST_MIN_STACK=16777216 cargo test -p codex-core allowing_network_policy_amendment_persists_context_and_bypasses_prompt
RUST_MIN_STACK=16777216 cargo test -p codex-core dropping_pending_owner_denies_waiters_and_preserves_replacement然后复述三个场景:
- 两个并发工具访问同一 host,但 execution id 不同。哪个结构负责避免错误归因,何时会拒绝自动归因?
- Deferred network approval 为什么要等到工具结果或进程退出后 finish?拒绝 outcome 在哪个生命周期点影响工具结果?
- 用户选择 allow amendment 后,哪些状态必须同时更新,规则文件写失败时哪些状态绝不能更新?
能够从代理 BlockedRequest 追到 record_blocked_request、handle_inline_policy_request、pending owner、规则保存和 finish_deferred_network_approval,就掌握了网络审批从阻断到执行结果的完整控制流。后续阅读可以回到工具审批架构, 对比通用 command approval 与 host-specific network approval 的共享层和差异层。
