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
#[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
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
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
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
#[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
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
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
#[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
#[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
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
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
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
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
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
assert_eq!(network_approval_context_from_payload(&payload), None);输入和断言能证明策略优先级、来源传播和事件字段;不能证明上游 UI 如何呈现 Ask,也不能证明 DNS、TLS、目标服务器或操作系统防火墙的安全性。连接前的 non-public IP 检查与 domain policy 是两层约束,不能只凭 domain event 推断实际 TCP 已连接。
在源码仓库中可运行:
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=110. 阅读闭环
建议按 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 的连接、转发和环境路由架构。
