Skip to content

NetworkPolicy决策模型

从 PermissionProfile、代理基线和 decider 到连接前检查与决策事件,解释 Codex 网络访问如何被允许、拒绝或转入询问。

基于rust-v0.150.0
CodexRustSecurityNetwork

NetworkPolicy决策模型 ​

Codex 的网络访问决定发生在连接建立之前,而且不是一个简单的布尔值。一次请求同时带有协议、host、port、environment id、client address、HTTP method、命令提示和 execution id;代理状态先给出 baseline host decision,只有 NotAllowed 这一类阻断允许交给可选 decider 再判断。最终结果还要区分 Deny 与 Ask、baseline 与 decider source,并通过 domain/non-domain 事件把 reason、端点和执行身份传给观察者。

本文承接跨平台Sandbox抽象和Linux Seccomp与Namespace。前者说明网络策略最终由哪个执行环境承接,后者说明 Linux 网络隔离的 backend;本篇只讲策略决策、状态约束、连接前检查和事件传播,不展开 HTTP/SOCKS relay 的协议握手。

1. 请求携带上下文 ​

NetworkPolicyRequest 把连接目标和执行上下文放在同一对象中。NetworkPolicyRequest::new 负责从参数构造初始请求,并把 execution_id、disconnect hook 留给后续 state/transport 注入;调用方不应自己拼接一组不完整字段。

源码位置:codex-rs/network-proxy/src/network_policy.rs :: NetworkPolicyRequest、NetworkPolicyRequest::new

rust
#[derive(Clone, Debug)]
pub struct NetworkPolicyRequest {
    pub protocol: NetworkProtocol,
    pub host: String,
    pub port: u16,
    pub environment_id: Option<String>,
    pub client_addr: Option<String>,
    pub method: Option<String>,
    pub command: Option<String>,
    pub exec_policy_hint: Option<String>,
    pub execution_id: Option<String>,
    /// Present only when the local HTTP transport can identify an abandoned request.
    pub disconnect: Option<NetworkRequestDisconnect>,
}

pub struct NetworkPolicyRequestArgs {
    pub protocol: NetworkProtocol,
    pub host: String,
    pub port: u16,
    pub environment_id: Option<String>,
    pub client_addr: Option<String>,
    pub method: Option<String>,
    pub command: Option<String>,
    pub exec_policy_hint: Option<String>,
}

impl NetworkPolicyRequest {
    pub fn new(args: NetworkPolicyRequestArgs) -> Self {
        let NetworkPolicyRequestArgs {
            protocol,
            host,
            port,
            environment_id,
            client_addr,
            method,
            command,
            exec_policy_hint,
        } = args;
        Self {
            protocol,
            host,
            port,
            environment_id,
            client_addr,
            method,
            command,
            exec_policy_hint,
            execution_id: None,
            disconnect: None,
        }
    }
}

协议种类也会进入结果和事件:HTTP、HTTPS CONNECT、SOCKS5 TCP、SOCKS5 UDP 不是同一个字符串标签,NetworkProtocol::as_policy_protocol 为事件提供稳定值。

源码位置:codex-rs/network-proxy/src/network_policy.rs :: NetworkProtocol::as_policy_protocol

rust
impl NetworkProtocol {
    pub const fn as_policy_protocol(self) -> &'static str {
        match self {
            Self::Http => "http",
            Self::HttpsConnect => "https_connect",
            Self::Socks5Tcp => "socks5_tcp",
            Self::Socks5Udp => "socks5_udp",
        }
    }
}

2. Host先归一化 ​

策略匹配之前,host 会 trim 空白、剥离 bracket 或单一 :port、转换小写并去掉 DNS 尾点;IPv6 scope id 仍保留。域名 pattern 再根据 example.com、*.example.com、**.example.com 的语义展开,denylist 禁止未限定的全局 *。

源码位置:codex-rs/network-proxy/src/policy.rs :: normalize_host、compile_globset_with_policy

rust
pub fn normalize_host(host: &str) -> String {
    let host = host.trim();
    if host.starts_with('[')
        && let Some(end) = host.find(']')
    {
        return normalize_dns_host_or_ip_literal(&host[1..end]);
    }

    if host.bytes().filter(|b| *b == b':').count() == 1 {
        let host = host.split(':').next().unwrap_or_default();
        return normalize_dns_host_or_ip_literal(host);
    }

    normalize_dns_host_or_ip_literal(host)
}

源码位置:codex-rs/network-proxy/src/policy.rs :: compile_globset_with_policy

rust
fn compile_globset_with_policy(
    patterns: &[String],
    global_wildcard: GlobalWildcard,
) -> Result<GlobSet> {
    let mut builder = GlobSetBuilder::new();
    let mut seen = HashSet::new();
    for pattern in patterns {
        if global_wildcard == GlobalWildcard::Reject
            && is_global_wildcard_domain_pattern(pattern)
        {
            bail!(
                "unsupported global wildcard domain pattern \"*\"; use exact hosts or scoped wildcards like *.example.com or **.example.com"
            );
        }
        let pattern = normalize_pattern(pattern);
        for candidate in expand_domain_pattern(&pattern) {
            if !seen.insert(candidate.clone()) {
                continue;
            }
            let glob = GlobBuilder::new(&candidate)
                .case_insensitive(true)
                .build()
                .with_context(|| format!("invalid glob pattern: {candidate}"))?;
            builder.add(glob);
        }
    }
    Ok(builder.build()?)
}

3. Baseline约束 ​

NetworkProxyState::host_blocked 返回 Allowed 或携带具体原因的 Blocked。当前原因包括显式 deny、allowlist 未命中和 local/private target 不允许。evaluate_host_policy 只把 NotAllowed 交给 decider;显式 deny 或 local block 即使存在 decider 也保持 baseline deny。

源码位置:codex-rs/network-proxy/src/runtime.rs :: HostBlockReason、HostBlockDecision

rust
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostBlockReason {
    Denied,
    NotAllowed,
    NotAllowedLocal,
}

impl HostBlockReason {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Denied => REASON_DENIED,
            Self::NotAllowed => REASON_NOT_ALLOWED,
            Self::NotAllowedLocal => REASON_NOT_ALLOWED_LOCAL,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostBlockDecision {
    Allowed,
    Blocked(HostBlockReason),
}

非公开地址还会在实际 TCP connector 中再次检查。即使策略允许一个 hostname,解析到 loopback/private/link-local 地址时仍可能被拒绝;allow_local_binding 或目标 host 的显式匹配才会改变这一步。

源码位置:codex-rs/network-proxy/src/connect_policy.rs :: TargetCheckedStreamConnector::connect、allows_non_public_target

rust
async fn connect(&self, addr: SocketAddr) -> Result<TcpStream, Self::Error> {
    if is_non_public_ip(addr.ip()) && !self.allows_non_public_target(addr).await? {
        return Err(io::Error::new(
            io::ErrorKind::PermissionDenied,
            "network target rejected by policy",
        )
        .into());
    }

    tokio::net::TcpStream::connect(addr)
        .await
        .map(TcpStream::from)
        .map_err(Into::into)
}

4. Decider阻断范围 ​

evaluate_host_policy 保存 state 的 execution id,先读取 baseline,再决定是否调用 decider。调用 decider 时,如果 request 没有 environment id,会从 state 补上;execution id 总是由 state 注入,而不是信任调用方传入的值。decider 返回的 deny/ask 会被重写为 Decider source。

源码位置:codex-rs/network-proxy/src/network_policy.rs :: evaluate_host_policy

rust
pub(crate) async fn evaluate_host_policy(
    state: &NetworkProxyState,
    decider: Option<&Arc<dyn NetworkPolicyDecider>>,
    request: &NetworkPolicyRequest,
) -> Result<NetworkDecision> {
    let execution_id = state.execution_id();
    let host_decision = state.host_blocked(&request.host, request.port).await?;
    let (decision, policy_override) = match host_decision {
        HostBlockDecision::Allowed => (NetworkDecision::Allow, false),
        HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => {
            if let Some(decider) = decider {
                let mut request = request.clone();
                if request.environment_id.is_none()
                    && let Some(environment_id) = state.environment_id()
                {
                    request.environment_id = Some(environment_id.to_string());
                }
                request.execution_id = execution_id.clone();
                let decider_decision = map_decider_decision(decider.decide(request).await);
                let policy_override = matches!(decider_decision, NetworkDecision::Allow);
                (decider_decision, policy_override)
            } else {
                (
                    NetworkDecision::deny_with_source(
                        HostBlockReason::NotAllowed.as_str(),
                        NetworkDecisionSource::BaselinePolicy,
                    ),
                    false,
                )
            }
        }
        HostBlockDecision::Blocked(reason) => (
            NetworkDecision::deny_with_source(
                reason.as_str(),
                NetworkDecisionSource::BaselinePolicy,
            ),
            false,
        ),
    };

剩余部分把 Allow、Deny、Ask 投影为事件字段,并返回最终 decision;它不会因为 event observer 失败而改变 enforcement。

5. Decision来源 ​

NetworkDecision 的 Deny 同时携带 reason、source 和 NetworkPolicyDecision。Ask 仍然是一个阻断结果,只有上层审批完成并产生新的策略变更后,后续请求才可能变成 Allow。

源码位置:codex-rs/network-proxy/src/network_policy.rs :: NetworkDecision、NetworkDecision::ask_with_source

rust
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NetworkDecision {
    Allow,
    Deny {
        reason: String,
        source: NetworkDecisionSource,
        decision: NetworkPolicyDecision,
    },
}

pub fn ask_with_source(reason: impl Into<String>, source: NetworkDecisionSource) -> Self {
    let reason = reason.into();
    let reason = if reason.is_empty() {
        REASON_POLICY_DENIED.to_string()
    } else {
        reason
    };
    Self::Deny {
        reason,
        source,
        decision: NetworkPolicyDecision::Ask,
    }
}

来源枚举还包括 ModeGuard 和 ProxyState,用于非 domain 的方法、socket、代理状态阻断;不能把所有 deny 都归为 decider。

源码位置:codex-rs/network-proxy/src/network_policy.rs :: NetworkDecisionSource

rust
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NetworkDecisionSource {
    BaselinePolicy,
    ModeGuard,
    ProxyState,
    Decider,
}

6. 事件记录最终决定 ​

domain host policy 和 non-domain method/socket policy 使用同一个事件构造器,但 scope 不同。事件写入 tracing,同时通知可选 observer;observer 接收的是不带 tenant/session identity 的 NetworkPolicyAuditEvent,不会延迟或改变策略 enforcement。

源码位置:codex-rs/network-proxy/src/network_policy.rs :: emit_policy_audit_event

rust
fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs<'_>) {
    let metadata = state.audit_metadata();
    let timestamp = audit_timestamp();
    tracing::event!(
        target: AUDIT_TARGET,
        tracing::Level::INFO,
        event.name = POLICY_DECISION_EVENT_NAME,
        event.timestamp = %timestamp,
        conversation.id = metadata.conversation_id.as_deref(),
        app.version = metadata.app_version.as_deref(),
        auth_mode = metadata.auth_mode.as_deref(),
        originator = metadata.originator.as_deref(),
        user.account_id = metadata.user_account_id.as_deref(),
        user.email = metadata.user_email.as_deref(),
        terminal.type = metadata.terminal_type.as_deref(),
        model = metadata.model.as_deref(),
        slug = metadata.slug.as_deref(),
        network.policy.scope = args.scope,
        network.policy.decision = args.decision,
        network.policy.source = args.source,
        network.policy.reason = args.reason,
        network.transport.protocol = args.protocol.as_policy_protocol(),
        server.address = args.server_address,
        server.port = args.server_port,
        http.request.method = args.method.unwrap_or(DEFAULT_METHOD),
        client.address = args.client_addr.unwrap_or(DEFAULT_CLIENT_ADDRESS),
        execution.id = args.execution_id,
        network.policy.override = args.policy_override,
    );
    if let Some(observer) = &state.policy_audit_observer {
        observer(NetworkPolicyAuditEvent {
            timestamp,
            scope: args.scope.to_string(),
            decision: args.decision.to_string(),
            source: args.source.to_string(),
            reason: args.reason.to_string(),
            protocol: args.protocol,
            host: args.server_address.to_string(),
            port: args.server_port,
            method: args.method.map(str::to_string),
            client: args.client_addr.map(str::to_string),
            policy_override: args.policy_override,
        });
    }
}

evaluate_host_policy 在返回 decision 前固定填入 scope = domain、source、reason、protocol、server address/port、method、client 和 state execution id;因此事件记录的是最终决定,而不是 decider 的中间输入。

7. 权限合并 ​

单次审批可能携带 AdditionalPermissionProfile。effective_network_sandbox_policy 不会把附加权限无条件替换原策略:只要基础策略或附加 profile 允许网络,结果为 Enabled;存在附加 profile 但没有网络允许时,结果保持 Restricted;没有附加 profile 时保留原值。文件系统权限也在同一函数中合并,最后重新生成带原 enforcement 的 PermissionProfile。

源码位置:codex-rs/sandboxing/src/policy_transforms.rs :: effective_network_sandbox_policy、effective_permission_profile

rust
fn merge_network_access(
    base_network_access: bool,
    additional_permissions: &AdditionalPermissionProfile,
) -> bool {
    base_network_access
        || additional_permissions
            .network
            .as_ref()
            .and_then(|network| network.enabled)
            .unwrap_or(false)
}

pub fn effective_network_sandbox_policy(
    network_policy: NetworkSandboxPolicy,
    additional_permissions: Option<&AdditionalPermissionProfile>,
) -> NetworkSandboxPolicy {
    if additional_permissions
        .is_some_and(|permissions| merge_network_access(network_policy.is_enabled(), permissions))
    {
        NetworkSandboxPolicy::Enabled
    } else if additional_permissions.is_some() {
        NetworkSandboxPolicy::Restricted
    } else {
        network_policy
    }
}

pub fn effective_permission_profile(
    permission_profile: &PermissionProfile,
    additional_permissions: Option<&AdditionalPermissionProfile>,
) -> PermissionProfile {
    let (file_system_policy, network_policy) = permission_profile.to_runtime_permissions();
    let effective_file_system_policy =
        effective_file_system_sandbox_policy(&file_system_policy, additional_permissions);
    let effective_network_policy =
        effective_network_sandbox_policy(network_policy, additional_permissions);
    PermissionProfile::from_runtime_permissions_with_enforcement(
        permission_profile.enforcement(),
        &effective_file_system_policy,
        effective_network_policy,
    )
}

这解释了为什么网络审批不能只改 proxy 层状态:单次附加权限要先进入 environment-scoped sandbox context,再由具体执行 backend 决定如何隔离或允许网络。

8. 上层拒绝处理 ​

Core 收到代理返回的 BlockedRequest 后,会先解析 decision。只有明确的 deny 才生成“访问被策略阻止”的用户消息;ask 不会被误报成永久 deny,因为它仍然需要上层审批处理。reason 决定文案是否说明显式 deny、allowlist 未命中、local/private 地址或 proxy disabled。

源码位置:codex-rs/core/src/network_policy_decision.rs :: denied_network_policy_message

rust
pub(crate) fn denied_network_policy_message(blocked: &BlockedRequest) -> Option<String> {
    let decision = blocked
        .decision
        .as_deref()
        .and_then(parse_network_policy_decision);
    if decision != Some(NetworkPolicyDecision::Deny) {
        return None;
    }

    let host = blocked.host.trim();
    if host.is_empty() {
        return Some("Network access was blocked by policy.".to_string());
    }

    let detail = match blocked.reason.as_str() {
        "denied" => "domain is explicitly denied by policy and cannot be approved from this prompt",
        "not_allowed" => "domain is not on the allowlist for the current sandbox mode",
        "not_allowed_local" => "local/private network addresses are blocked by the sandbox policy",
        "method_not_allowed" => "request method is blocked by the current network mode",
        "proxy_disabled" => "network proxy is disabled",
        _ => "request is blocked by network policy",
    };

    Some(format!(
        "Network access to \"{host}\" was blocked: {detail}."
    ))
}

9. 测试与边界 ​

网络策略测试直接构造 baseline config、decider、execution state 和事件 observer。它们分别验证:baseline allow 不调用 decider;NotAllowed + decider Allow 产生 Decider override;显式 deny 和 local block 不被覆盖;Ask 返回带 Ask 的 deny;事件包含 execution id、scope、source、reason 和 endpoint。

源码位置:codex-rs/network-proxy/src/network_policy.rs :: evaluate_host_policy_emits_domain_event_for_decider_allow_override

rust
let (decision, events) = capture_events(|| async {
    evaluate_host_policy(&state, Some(&decider), &request)
        .await
        .unwrap()
})
.await;
assert_eq!(decision, NetworkDecision::Allow);
assert_eq!(calls.load(Ordering::SeqCst), 1);

let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
    .expect("expected policy decision audit event");
assert_eq!(event.field("network.policy.decision"), Some("allow"));
assert_eq!(event.field("network.policy.source"), Some("decider"));
assert_eq!(event.field("network.policy.reason"), Some(REASON_NOT_ALLOWED));
assert_eq!(event.field("network.policy.override"), Some("true"));

源码位置:codex-rs/network-proxy/src/network_policy.rs :: evaluate_host_policy_still_denies_not_allowed_local_without_decider_override

rust
let decision = evaluate_host_policy(&state, /*decider*/ None, &request)
    .await
    .unwrap();
assert_eq!(
    decision,
    NetworkDecision::Deny {
        reason: REASON_NOT_ALLOWED_LOCAL.to_string(),
        source: NetworkDecisionSource::BaselinePolicy,
        decision: NetworkPolicyDecision::Deny,
    }
);

Core 测试进一步验证只有 ask-from-decider 才能构造审批上下文,并把 HTTP、HTTPS、SOCKS5 协议映射到统一 approval payload;deny decision 才生成用户可见的 blocked message。

源码位置:codex-rs/core/src/network_policy_decision_tests.rs :: network_approval_context_requires_ask_from_decider、denied_network_policy_message_requires_deny_decision

rust
assert_eq!(network_approval_context_from_payload(&payload), None);

输入和断言能证明策略优先级、来源传播和事件字段;不能证明上游 UI 如何呈现 Ask,也不能证明 DNS、TLS、目标服务器或操作系统防火墙的安全性。连接前的 non-public IP 检查与 domain policy 是两层约束,不能只凭 domain event 推断实际 TCP 已连接。

在源码仓库中可运行:

text
cd codex-rs
cargo test -p codex-network-proxy --lib network_policy -- --test-threads=1
cargo test -p codex-core --lib network_policy_decision -- --test-threads=1
cargo test -p codex-sandboxing --lib policy_transforms -- --test-threads=1

10. 阅读闭环 ​

建议按 NetworkPolicyRequest → host normalization → host_blocked → evaluate_host_policy → NetworkDecision → tracing/observer event → Core message/approval context → connector 的顺序阅读。读完后应能解释:为什么 decider 只能覆盖 NotAllowed;为什么 Ask 仍是阻断;execution id 为什么必须由 state 注入;为什么附加权限会改变网络 baseline;以及为什么策略事件不能替代实际连接层的 non-public IP 检查。

下一篇进入 NetworkProxy 的连接、转发和环境路由架构。