网络审批与规则持久化
网络审批的难点不在弹出一个 Allow/Deny 对话框,而在于并发请求、执行身份、session 作用域、取消和持久化必须保持同一套语义。当前实现把一次 host 请求拆成三层状态:HostApprovalKey 描述可复用的目标范围;PendingHostApprovalKey 再加入 turn/execution 维度用于并发去重;ActiveNetworkApprovalCall 则保存触发审批的工具、命令、环境和取消 token,供 proxy blocked request 反向归属。
审批结果也不是单一 bool:AllowOnce 只完成当前 pending,AllowForSession 写入带环境/协议/端口的 session cache,NetworkPolicyAmendment 还要经过 host 校验、规则文件追加和内存 policy 更新。任何一步失败或取消,都必须让当前调用保持拒绝,不能因为缓存或文件写入时序而提前放行。
本文承接NetworkPolicy决策模型和NetworkProxy架构,并结合命令规范化与审批缓存。范围是网络审批 service、并发 owner/waiter、session cache、deferred outcome、amendment 持久化和失败边界;不展开 Guardian 风险评分或 HTTP/SOCKS 握手。
1. 三种审批身份
NetworkApprovalService 同时维护 active calls、pending approvals、session approved hosts 和 session denied hosts。active call 是工具执行注册,pending approval 是同一目标的并发协调,session cache 是已提交的复用结果;三者不能互相替代。
源码位置:codex-rs/core/src/tools/network_approval.rs :: ActiveNetworkApprovalCall、HostApprovalKey、PendingHostApprovalKey、NetworkApprovalService
struct HostApprovalKey {
environment_id: String,
host: String,
protocol: &'static str,
port: u16,
}
struct PendingHostApprovalKey {
host: HostApprovalKey,
turn_id: String,
execution_id: Option<String>,
}
struct ActiveNetworkApprovalCall {
registration_id: String,
turn_id: String,
trigger: GuardianNetworkAccessTrigger,
tool_name: ToolName,
command: String,
environment_id: String,
permission_profile: PermissionProfile,
cancellation_token: CancellationToken,
}
pub(crate) struct NetworkApprovalService {
calls: SyncMutex<NetworkApprovalCallState>,
pending_host_approvals: SyncMutex<HashMap<PendingHostApprovalKey, Arc<PendingHostApproval>>>,
session_policy_commit_lock: Mutex<()>,
session_approved_hosts: Mutex<HashSet<HostApprovalKey>>,
session_denied_hosts: Mutex<HashSet<HostApprovalKey>>,
}HostApprovalKey::from_request 使用小写 host 和协议标签,确保 Example.COM 与 example.com 落入相同目标范围;但 environment、protocol、port 仍保留在 key 中,避免把不同执行环境或不同端口的批准混用。
源码位置: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. 并发owner/waiter
get_or_create_pending_approval 用完整 PendingHostApprovalKey 查找同一代 pending。owner 创建 PendingHostApprovalOwner 并负责最终 set decision;waiter 只等待 Notify,不会重复执行交互式审批。owner 完成时先从 map 删除当前 generation,再唤醒 waiter,避免并发 retry 连接到已完成对象。
源码位置:codex-rs/core/src/tools/network_approval.rs :: PendingHostApproval::wait_for_decision、PendingHostApprovalOwner::publish_and_remove
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 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);
}generation identity 是关键:如果旧 owner 被 drop 后同一 host 已经创建新 pending,旧 owner 不能删除新对象,也不能把旧拒绝写到新请求上。
3. 进入审批前的门槛
owner 在进入 review 前先解析当前 active turn 和 environment。没有 active turn 时,直接为 owner call 记录拒绝;非 Managed profile 或 AskForApproval::Never 也会完成 pending 为 Deny。这里的 Deny 是策略选择,不是用户尚未响应。
源码位置:codex-rs/core/src/tools/network_approval.rs :: allows_network_approval_flow、permission_profile_allows_network_approval_flow
fn allows_network_approval_flow(policy: AskForApproval) -> bool {
!matches!(policy, AskForApproval::Never)
}
fn permission_profile_allows_network_approval_flow(
permission_profile: &PermissionProfile,
) -> bool {
matches!(permission_profile, PermissionProfile::Managed { .. })
}源码位置:codex-rs/core/src/tools/network_approval.rs :: pending owner gate
if !permission_profile.is_some_and(permission_profile_allows_network_approval_flow) {
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(&owner_call.registration_id, policy_denial_message);
}
pending_owner.complete(PendingApprovalDecision::Deny);
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
if !allows_network_approval_flow(turn_context.approval_policy()) {
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(&owner_call.registration_id, policy_denial_message);
}
pending_owner.complete(PendingApprovalDecision::Deny);
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}4. Session缓存
session approved/denied cache 在 session_policy_commit_lock 下读取。批准 key 必须同时匹配 environment、host、protocol 和 port;所以 local environment 的 HTTPS 443 批准不会覆盖 remote environment、HTTP 80 或 HTTPS 8443。
源码位置:codex-rs/core/src/tools/network_approval.rs :: session cache lookup
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;
}同步 session cache 时,目标集合会先 clear 再复制 source snapshot;它不是增量 merge,避免 fork/review session 留下旧 host。
源码位置:codex-rs/core/src/tools/network_approval.rs :: sync_session_approved_hosts_to
pub(crate) async fn sync_session_approved_hosts_to(&self, other: &Self) {
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());
}5. 三种结果映射
PendingApprovalDecision 只描述当前 pending 的最终效果:AllowOnce、AllowForSession 或 Deny。ReviewDecision 的 ApprovedExecpolicyAmendment 也只映射为一次性允许;真正的 network amendment 走独立持久化分支。
源码位置:codex-rs/core/src/tools/network_approval.rs :: PendingApprovalDecision::to_network_decision、approval result mapping
impl PendingApprovalDecision {
fn to_network_decision(self) -> NetworkDecision {
match self {
Self::AllowOnce | Self::AllowForSession => NetworkDecision::Allow,
Self::Deny => NetworkDecision::deny("not_allowed"),
}
}
}
match approval_decision {
ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
PendingApprovalDecision::AllowOnce
}
ReviewDecision::ApprovedForSession => {
self.session_approved_hosts.lock().await.insert(key.clone());
PendingApprovalDecision::AllowForSession
}
_ => PendingApprovalDecision::Deny,
}如果 session_denied_hosts 已包含 key,即使收到 Approved 或 ApprovedForSession,也会再次转成 Deny。这保证并发 blocked request 的明确拒绝不会被稍晚到达的批准覆盖。
6. Deferred与取消
工具调用在开始时拿到 ActiveNetworkApproval;如果执行需要把审批结果延后到 process completion,就通过 into_deferred 保存 registration id、cancellation token 和 execution proxy。finish 用 OnceCell 确保多个消费者看到同一个 outcome;没有正式 outcome 且 token 已取消时,才生成 abandoned denial。
源码位置:codex-rs/core/src/tools/network_approval.rs :: ActiveNetworkApproval::into_deferred、DeferredNetworkApproval::finish
pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {
let ActiveNetworkApproval {
registration_id,
cancellation_token,
execution_proxy,
} = self;
registration_id.map(|registration_id| DeferredNetworkApproval {
registration_id,
cancellation_token,
finish_outcome: Arc::new(OnceCell::new()),
_execution_proxy: Some(execution_proxy),
})
}
async fn finish(&self, service: &NetworkApprovalService) -> Result<(), ToolError> {
let outcome = self
.finish_outcome
.get_or_init(|| async { service.finish_call_outcome(&self.registration_id).await })
.await
.clone();
let outcome =
outcome.or_else(|| abandoned_network_approval_outcome(&self.cancellation_token));
network_approval_outcome_to_result(outcome)
}owner 的 Drop 还会处理 disconnect:如果请求在审批完成前断开,则记录一次性 outcome、取消关联 execution,并发布 Deny;后续显式 approval outcome 可以按源码规则替换较早的 fallback outcome。
7. Amendment提交
NetworkPolicyAmendment 的 Allow/Deny 不是直接改内存 hash set。session 先验证 amendment host 与 NetworkApprovalContext 的 normalized target 一致,再调用 ExecPolicyManager::append_network_rule_and_update。成功后记录 session message,当前和后续 policy evaluation 才能看到新 rule。
源码位置:codex-rs/core/src/session/mod.rs :: persist_network_policy_amendment、validated_network_policy_amendment_host
pub(crate) async fn persist_network_policy_amendment(
&self,
amendment: &NetworkPolicyAmendment,
network_approval_context: &NetworkApprovalContext,
on_policy_applied: impl FnOnce() + Send,
) -> anyhow::Result<()> {
let _refresh_guard = self
.managed_network_proxy_refresh_lock
.acquire()
.await
.map_err(|_| anyhow::anyhow!("managed network proxy refresh semaphore closed"))?;
let host =
Self::validated_network_policy_amendment_host(amendment, network_approval_context)?;
let codex_home = self
.state
.lock()
.await
.session_configuration
.codex_home()
.clone();
let execpolicy_amendment =
execpolicy_network_rule_amendment(amendment, network_approval_context, &host);
let mut on_policy_applied = Some(on_policy_applied);
if let Some(started_network_proxy) = self.services.network_proxy.load_full() {
let proxy = started_network_proxy.proxy();
match amendment.action {
NetworkPolicyRuleAction::Allow => proxy
.add_allowed_domain(&host)
.await
.map_err(|err| anyhow::anyhow!("failed to update runtime allowlist: {err}"))?,
NetworkPolicyRuleAction::Deny => proxy
.add_denied_domain(&host)
.await
.map_err(|err| anyhow::anyhow!("failed to update runtime denylist: {err}"))?,
}
// Active enforcement changed successfully. Notify the owner before
// the next fallible await so cancellation cannot contradict it.
if let Some(on_policy_applied) = on_policy_applied.take() {
on_policy_applied();
}
}
self.services
.exec_policy
.append_network_rule_and_update(
&codex_home,
&host,
execpolicy_amendment.protocol,
execpolicy_amendment.decision,
Some(execpolicy_amendment.justification),
)
.await?;
// Without a running proxy, persistence is the first effective policy change.
if let Some(on_policy_applied) = on_policy_applied {
on_policy_applied();
}
Ok(())
}源码位置:codex-rs/core/src/exec_policy.rs :: append_network_rule_and_update
let _update_guard = self
.update_lock
.acquire()
.await
.map_err(|_| ExecPolicyUpdateError::AddRule {
source: ExecPolicyRuleError::InvalidRule(
"exec policy update semaphore closed".to_string(),
),
})?;
let policy_path = default_policy_path(codex_home);
let host = host.to_string();
spawn_blocking({
let policy_path = policy_path.clone();
let host = host.clone();
let justification = justification.clone();
move || {
blocking_append_network_rule(
&policy_path,
&host,
protocol,
decision,
justification.as_deref(),
)
}
})
.await
.map_err(|source| ExecPolicyUpdateError::JoinBlockingTask { source })?
.map_err(|source| ExecPolicyUpdateError::AppendRule {
path: policy_path,
source,
})?;
let mut updated_policy = self.current().as_ref().clone();
updated_policy.add_network_rule(&host, protocol, decision, justification)?;
self.policy.store(Arc::new(updated_policy));文件追加成功但内存 add_network_rule 失败时,函数返回错误;源码没有把这类异常伪装成普通 Allow。文件写入使用 blocking task,避免阻塞 async runtime。
8. 规则文件格式
blocking_append_network_rule 会先 normalize host,拒绝 wildcard host 和空 justification,再把 protocol、decision、justification 序列化为一行 network_rule(...)。它使用 advisory file lock 和目录创建,保证并发追加不会拼接出半行。
源码位置:codex-rs/execpolicy/src/amend.rs :: blocking_append_network_rule
pub fn blocking_append_network_rule(
policy_path: &Path,
host: &str,
protocol: NetworkRuleProtocol,
decision: Decision,
justification: Option<&str>,
) -> Result<(), AmendError> {
let host = normalize_network_rule_host(host)
.map_err(|err| AmendError::InvalidNetworkRule(err.to_string()))?;
if let Some(raw) = justification
&& raw.trim().is_empty()
{
return Err(AmendError::InvalidNetworkRule(
"justification cannot be empty".to_string(),
));
}
let host = serde_json::to_string(&host)
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
let protocol = serde_json::to_string(protocol.as_policy_string())
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
let decision = serde_json::to_string(match decision {
Decision::Allow => "allow",
Decision::Prompt => "prompt",
Decision::Forbidden => "deny",
})
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
let mut args = vec![
format!("host={host}"),
format!("protocol={protocol}"),
format!("decision={decision}"),
];
if let Some(justification) = justification {
let justification = serde_json::to_string(justification)
.map_err(|source| AmendError::SerializeNetworkRule { source })?;
args.push(format!("justification={justification}"));
}
let rule = format!("network_rule({})", args.join(", "));
append_rule_line(policy_path, &rule)
}9. 测试与边界
测试覆盖的是状态和提交协议,不是“弹窗出现过”这一表面现象:pending key 在 environment/turn/execution/port 上的去重边界,session cache 的作用域,owner drop 的 replacement 保护,disconnect outcome 的优先级,deferred outcome 的复用,blocked request 的 attribution,以及 amendment 的 host 校验和规则文本。
源码位置:codex-rs/core/src/tools/network_approval_tests.rs :: pending_approvals_are_deduped_within_one_execution、pending_approvals_do_not_dedupe_across_environments
let (first, first_is_owner) =
service.get_or_create_pending_approval(pending_key(host.clone(), "turn-1", "execution-1"));
let (second, second_is_owner) =
service.get_or_create_pending_approval(pending_key(host, "turn-1", "execution-1"));
assert!(first_is_owner);
assert!(!second_is_owner);
assert!(Arc::ptr_eq(&first, &second));源码位置:codex-rs/execpolicy/src/amend.rs :: appends_network_rule、rejects_wildcard_network_rule_host
blocking_append_network_rule(
&policy_path,
"Api.GitHub.com",
NetworkRuleProtocol::Https,
Decision::Allow,
Some("Allow https_connect access to api.github.com"),
)
.expect("append network rule");
let contents = std::fs::read_to_string(&policy_path).expect("read policy");
assert_eq!(
contents,
r#"network_rule(host="api.github.com", protocol="https", decision="allow", justification="Allow https_connect access to api.github.com")
"#
);这些断言能证明 owner/waiter 共享、环境隔离和文件格式;不能证明用户会选择安全结果,也不能约束外部进程绕过同一 manager 直接编辑规则文件。取消、文件失败和 active-call attribution 的结果必须结合源码中的 cancellation token、outcome replacement 和 generation 检查理解。
在源码仓库中可运行:
cd codex-rs
cargo test -p codex-core --lib network_approval -- --test-threads=1
cargo test -p codex-execpolicy --lib amend -- --test-threads=1
cargo test -p codex-core --lib network_policy_decision -- --test-threads=1补充审批对象关系和持久化时序,便于把内存中的 pending owner 与磁盘 amendment 区分开。
10. 阅读闭环
建议按 key 结构 → pending owner/waiter → profile/policy gate → session cache → ReviewDecision mapping → deferred finish → amendment validation → file append → ArcSwap memory update 阅读。读完后应能解释:为什么相同目标请求会合并而不同 environment 不会;为什么 AllowForSession 仍受 protocol/port 限制;为什么 disconnect 只能产生 fallback outcome;以及为什么 network rule 必须先写盘、再更新内存策略。
下一篇进入 ShellEscalation 与 Unix 提权边界。
