Skip to content

网络审批与规则持久化

从 pending 去重、环境作用域和取消收口,到网络规则校验、文件追加与内存策略更新,解释网络审批如何跨请求生效。

基于rust-v0.150.0
CodexRustSecurityNetworkApproval

网络审批与规则持久化 ​

网络审批的难点不在弹出一个 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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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 检查理解。

在源码仓库中可运行:

text
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 提权边界。