夜雨聆风学习资料网

ARTICLE · 1220707

Codex 源码-Network Proxy 网络代理引擎——域名白名单与凭据经纪

Codex 源码-Network Proxy 网络代理引擎——域名白名单与凭据经纪

Codex 源码解析系列

第 27 讲:Network Proxy 网络代理引擎——域名白名单与凭据经纪

基于 OpenAI Codex 源码 · 2026-10-21

💡 本讲一句话:Codex 给沙箱里的子进程配了一个"守门员代理"——所有出站流量必须经过它,域名白名单、DNS rebinding 防御、凭据虚拟化注入全在这一层完成;读完你能看懂一个生产级网络策略引擎的完整裁决链路。

一、为什么 Codex 要自己造一个代理

前几讲我们拆了 bwrap、Landlock、Windows 沙箱——它们管的是"进程能碰哪些文件、哪些系统调用"。但网络是另一条逃逸通道:子进程里跑一条 curl http://169.254.169.254/latest/meta-data/(云元数据服务)或者把 API key POST 到任意第三方,文件沙箱完全拦不住。

Codex 的解法不是"禁止网络",而是强制所有流量走一个 Codex 自己启动的 loopback 代理(codex-rs/network-proxy/,约 2.2 万行 Rust)。子进程的环境变量被改写成 HTTP_PROXY=http://127.0.0.1:<port>,而沙箱层(bwrap/Landlock)只放行这几个 loopback 端口——想绕过代理直连?连接根本建不起来。

这个 crate 的模块清单本身就是一张能力地图:lib.rs(第 3-32 行)声明了 proxy / http_proxy / socks5 / network_policy / credential_broker / mitm / environment_policy / request_cancellation 等近 30 个模块——HTTP/1.1 代理、SOCKS5、域名策略、凭据经纪、MITM 头注入、请求取消,一个都不少。

先看子进程启动时环境是怎么被"接管"的——proxy.rs 的 prepare_for_addrs():

📄 codex-rs/network-proxy/src/proxy.rs (第 1051-1110 行)

let runtime_settings = self.runtime_settings(); // 读取当前运行时设置快照(本地绑定开关、MITM CA 路径等)apply_proxy_env_overrides( // 把子进程环境里的 HTTP_PROXY/ALL_PROXY 等一律改写指向本 loopback 代理——用户设什么都被覆盖   &mut env, // 正在为子进程准备的环境变量表(原地修改)    addrs.http_addr, // HTTP 代理地址(loopback:port)    addrs.socks_addr, // SOCKS5 代理地址(若启用)    self.socks_enabled, // 是否写入 socks 相关变量    runtime_settings.allow_local_binding, // 本地绑定开关——影响 NO_PROXY 的生成策略    runtime_settings.mitm_ca_trust_bundle.as_ref(), // MITM 开启时把子进程 CA 信任链指向 Codex 托管证书);let credential_environment_id = environment_id.or_else(|| { // 优先用调用方指定的环境 id,没有就退回本次执行作用域   self.execution_scope // 当前执行作用域(可能为 None)      .as_ref()      .map(|scope| scope.environment_id.as_str()) // 从作用域里取出环境 id});self.state   .virtualize_child_credentials_for_environment(&mut env, credential_environment_id); // 把子进程环境里的真实凭据换成假值——请求时由代理注回真值if let Some(execution_scope) = self.execution_scope.as_ref() {   env.insert( // 写入归因 token:代理据此识别这条连接属于哪次命令执行       PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(), // 归因 token 的固定环境变量名       execution_scope.attribution_token.clone(), // token 值(每次执行唯一)   );} else {   env.remove(PROXY_ATTRIBUTION_TOKEN_ENV_KEY); // 没有作用域就清掉残留 token,防止跨执行归因串线}let mut loopback_ports = [ // 收集需要在沙箱里放行的代理端口(HTTP + 可选 SOCKS)   Some(addrs.http_addr), // HTTP 端口恒在   expose_socks_port.then_some(addrs.socks_addr), // SOCKS 端口仅在对外暴露时加入].into_iter().flatten() // 把 Option 展平成地址迭代器.filter(|addr| addr.ip().is_loopback()) // 只保留 loopback 地址——非回环本就不该出现在这里.map(|addr| addr.port()) // 只留端口号.collect::<Vec<_>>(); // 收集成 Vec,便于排序去重loopback_ports.sort_unstable(); // 排序保证输出确定——沙箱规则比对和日志都依赖稳定顺序loopback_ports.dedup(); // 去重(HTTP/SOCKS 万一同端口)PreparedManagedNetwork {   env, // 最终子进程环境:代理变量 + 假凭据 + 归因 token,三件套齐活   sandbox_context: ManagedNetworkSandboxContext {      loopback_ports, // 交给 bwrap/Landlock 层——沙箱只放行这几个端口      allow_local_binding: runtime_settings.allow_local_binding, // 本地绑定开关透传给沙箱策略   },}

为什么这样设计:注意 loopback_ports 这个返回值——它不是日志装饰,而是沙箱与代理之间的契约:代理先占好端口、算出清单,再交给 bwrap/Landlock 生成"只放行这几个端口"的规则。顺序反了(先建沙箱后起代理)就会出现竞态窗口。同时 attribution_token 让每条连接都能回溯到具体哪条 shell 命令发起的——这是后面审计和审批流的基础设施。

代理的行为由 NetworkProxyConfig(config.rs 第 117-142 行)驱动,关键项如下:

配置项
默认值
作用
enabled
false
总开关;关闭时所有 CONNECT/转发请求直接拒绝并记录审计
mode
Full
Limited=只读(仅 GET/HEAD/OPTIONS,CONNECT 需 MITM 才能管方法);Full=全方法放行
domains
None
域名 allow/deny glob 列表;配置后未命中即拒绝(白名单语义)
allow_local_binding
false
是否允许访问 loopback/内网;关闭时触发 DNS rebinding 防御检查
mitm
false
TLS 终结:代理可看到/改写 HTTPS 内部请求(Limited 模式管方法的前提)
credential_broker
false
凭据经纪:子进程只见假 token,代理转发时注入真实凭据(第六节细讲)

二、策略大脑:NetworkProxyState 与热重载

代理的所有决策都从一个共享状态对象出发。runtime.rs(第 170-245 行)定义了它的核心结构:

📄 codex-rs/network-proxy/src/runtime.rs (第 170-245 行)

pub struct ConfigState { // 一份"策略快照":决策所需的一切都在这里,整体原子替换   pub config: NetworkProxyConfig, // 原始配置(enabled、domains、mode、mitm、credential_broker…)   pub(crate) brokerage_created_default_allowlist: bool, // 白名单是否由凭据经纪自动创建——审计时区分"人配的"和"系统配的"   pub allow_set: GlobSet, // 允许域名的预编译 glob 集合——决策时直接匹配,不用每次解析字符串   pub deny_set: GlobSet, // 拒绝域名的预编译 glob 集合;显式拒绝永远优先   pub mitm: Option<Arc<MitmState>>, // MITM 状态(CA + hooks);None 表示纯隧道不终结 TLS   pub mitm_hooks: MitmHooksByHost, // 按 host 划分的头注入/正文改写规则   pub constraints: NetworkProxyConstraints, // 策略变更约束——防止运行时把配置改出安全边界   pub blocked: VecDeque<BlockedRequest>, // 最近被拒请求的环形缓冲——供审计查询和 UI 展示   pub blocked_total: u64, // 累计拒绝计数;不受环形缓冲淘汰影响,永远单调递增}pub trait ConfigReloader: Send + Sync { // 热重载抽象:配置从哪来是可插拔的(本地文件 / 远端下发)   fn source_label(&self) -> String; // 人类可读的来源名,日志里显示"config.toml"之类   fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>>; // 无变化返回 None——避免每次请求都全量重载   fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState>; // 强制重载(force_reload / replace_config_state 用)}pub struct NetworkProxyState { // 代理的"大脑":所有 listener 通过 Arc 共享,读写锁保护   state: Arc<RwLock<ConfigState>>, // 策略快照;读多写少——并发读不互斥,写独占   reloader: Arc<dyn ConfigReloader>, // 构造时注入的重载源(静态 / 文件监听)   blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>, // 被拒请求观察者——运行时可换(如接入审批流)   pub(crate) policy_audit_observer: Option<NetworkPolicyAuditObserver>, // 每次决策的审计观察者(allow/deny 都记)   credential_broker: CredentialBroker, // 凭据经纪:子进程虚拟化 + 请求时注入   audit_metadata: NetworkProxyAuditMetadata, // 会话级审计上下文(thread id 等)   execution_attributions: Arc<Mutex<HashMap<String, ExecutionAttribution>>>, // 连接→执行归因表:这条连接属于哪条命令   environment_id: Option<Arc<str>>, // 环境隔离维度(多 environment 场景)   execution_id: Option<Arc<str>>, // 执行 id——盖在每次决策和拒绝记录上}

为什么这样设计:两个细节值得注意。其一,allow_set/deny_set 是预编译的 GlobSet——配置加载时就把 glob 模式编译好,热路径上只做集合匹配,不在每个请求里解析字符串。其二,ConfigReloader 是个 trait:maybe_reload() 返回 Option,"没变化就什么都不做"。配合每个查询入口的 reload_if_needed()(第 571-592 行注释写明:"Callers treat NetworkProxyState as a live view of policy"),实现改 config.toml 立即生效、无需重启代理——这对"用户中途加白名单域名"的体验至关重要。

🔍 热重载的原子性:force_reload()(第 609-630 行)先调 reloader.reload_now() 拿到完整的新 ConfigState,再一次性写锁替换——期间旧快照继续服务请求。重载失败则保留旧配置并 warn("keeping previous config")。策略切换永远原子,不存在半新半旧的中间态。

三、域名裁决流水线:deny > local > allowlist

每条出站请求的域名判定都汇聚到 host_blocked()(runtime.rs 第 644-719 行)。这段代码是理解整个安全模型的关键——注意它防的不仅是"访问外网",还有更隐蔽的DNS rebinding:

📄 codex-rs/network-proxy/src/runtime.rs (第 644-719 行)

pub async fn host_blocked(&self, host: &str, port: u16) -> Result<HostBlockDecision> { // 域名裁决核心入口:每个 CONNECT / absolute-form 请求都先过这里   self.reload_if_needed().await?; // 按需重载——config.toml 的改动无需重启即可生效   let host = match Host::parse(host) {      Ok(host) => host, // 解析成功,拿到规范化 host 继续判断      Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)), // 解析不了的 host 直接拒——fail closed,绝不放行未知格式   };   let (deny_set, allow_set, allow_local_binding, allowed_domains) = { // 读锁取出裁决输入后立即释放,把持锁时间压到最短      let guard = self.state.read().await; // 拿策略快照的读锁(并发读不互斥)      let allowed_domains = guard.config.allowed_domains(); // 原始白名单(None 表示"未配置白名单",语义不同)      ( // 四个裁决输入一次性拷出锁作用域         guard.deny_set.clone(), // deny glob 集合         guard.allow_set.clone(), // allow glob 集合         guard.config.allow_local_binding, // 是否允许本地/内网访问         allowed_domains, // 原始白名单模式列表      )   };   let allowed_domains_empty = allowed_domains.is_none(); // 记住"是否配置了白名单"——影响最终裁决语义   let allowed_domains = allowed_domains.unwrap_or_default(); // None 统一成空 Vec,后面匹配逻辑不用分叉   let host_str = host.as_str(); // host 的字符串形式,供 glob 匹配使用   if globset_matches_host_or_unscoped(&deny_set, host_str) { // 第 1 关:显式 deny 永远优先——命中即死      return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied)); // Denied 与 NotAllowed 是不同 reason,审计里可区分"被拉黑"和"没在白名单"   }   let is_allowlisted = globset_matches_host_or_unscoped(&allow_set, host_str); // 第 3 关的输入:是否命中白名单(留到最后用)   if !allow_local_binding { // 未允许本地绑定时,必须防"借道内网"——这是防御纵深      let local_literal = { // 先查字面量:host 本身是不是 loopback/私网 IP?         let host_no_scope = unscoped_ip_literal(host_str).unwrap_or(host_str); // 剥掉 IPv6 zone scope(fe80::1%eth0 之类)再判断         if is_loopback_host(&host) { // localhost / 127.0.0.1 等回环名            true // 是回环 → 本地字面量         } else if let Ok(ip) = host_no_scope.parse::<IpAddr>() { // IP 字面量则解析后分类            is_non_public_ip(ip) // 10/8、172.16/12、192.168/16 等私网段 → true         } else {            false // 不是本地字面量——但可能是解析到私网 IP 的域名(下面再查)         }      };      if local_literal { // 是本地字面量         if !is_explicit_local_allowlisted(&allowed_domains, &host) { // 除非被显式加进白名单(localhost:8080 这种)            return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); // 本地访问未授权 → NotAllowedLocal,reason 单独成类         }      } else if host_resolves_to_non_public_ip( // 不是字面量,但域名可能解析到私网 IP——DNS rebinding 防御         host_str, // 要解析的域名         port, // 端口(部分 resolver 需要)         DNS_LOOKUP_TIMEOUT, // 解析超时——慢 resolver 不能拖死裁决         |host, port| async move { // 注入式查找函数(可测试、可替换)            lookup_host((host.as_str(), port)) // 执行真实 DNS 解析               .await               .map(Iterator::collect) // 收集全部 A/AAAA 记录供分类判断         },      ).await      {         return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); // 解析到私网 IP → 即使域名在白名单也拒(纵深防御)      }   }   if allowed_domains_empty || !is_allowlisted { // 第 3 关:白名单未配置,或 host 不在名单里      Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)) // 拒绝——NotAllowed(基线策略拦截)   } else {      Ok(HostBlockDecision::Allowed) // 三关全过 → 放行   }}

为什么这样设计:源码注释(第 675-682 行)把动机写得很直白:如果只靠 localhost/127.0.0.1 这类字符串匹配,攻击者可以用 DNS rebinding——注册一个域名先解析到公网 IP(骗过检查),再解析到 169.254.169.254(云元数据服务)或内网地址。所以 Codex 对非字面量 host 主动做一次 DNS 解析并分类所有返回 IP,任何一条落到私网段就拒。代价是裁决路径上多了一次网络 IO——用 DNS_LOOKUP_TIMEOUT 兜底,且注入式查找函数让测试可以 mock。

优先级
检查项
结果 reason
1(最高)
命中 deny glob 集合
Denied(显式拉黑,decider 不可覆盖)
2
!allow_local_binding 且 host 是 loopback/私网字面量(未显式白名单)
NotAllowedLocal(本地访问红线,不可覆盖)
3
!allow_local_binding 且域名 DNS 解析到私网 IP(rebinding)
NotAllowedLocal(同上,纵深防御)
4
白名单未配置,或 host 未命中 allow glob
NotAllowed(唯一可被 decider 覆盖的拒绝)
5(最低)
以上全过
Allowed → 进入隧道/转发路径

四、Decider 覆盖与全量审计

host_blocked() 只是基线裁决。真正对外暴露的入口是 evaluate_host_policy()(network_policy.rs 第 363-442 行)——它允许外部 decider(审批流、远端策略服务)对"没在白名单"的请求说 yes,但边界划得很死:

📄 codex-rs/network-proxy/src/network_policy.rs (第 363-442 行)

pub(crate) async fn evaluate_host_policy( // 统一裁决入口:基线策略先行,decider 只能把"未授权"翻成"允许"   state: &NetworkProxyState, // 代理状态(持有策略快照)   decider: Option<&Arc<dyn NetworkPolicyDecider>>, // 可选的外部裁决者(审批流 / 远端策略服务)   request: &NetworkPolicyRequest, // 裁决上下文:host/port/协议/方法/客户端地址等) -> Result<NetworkDecision> {   let execution_id = state.execution_id(); // 当前执行 id——盖进审计事件,关联到具体命令   let host_decision = state.host_blocked(&request.host, request.port).await?; // 先跑基线裁决(deny > local > allowlist)   let (decision, policy_override) = match host_decision {      HostBlockDecision::Allowed => (NetworkDecision::Allow, false), // 基线放行 → 直接结束,无需覆盖      HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => { // 只有"未在白名单"有资格被 decider 翻案         if let Some(decider) = decider { // 配置了 decider → 给它一次机会            let mut request = request.clone(); // clone 一份——补上下文字段,不动调用方数据            if request.environment_id.is_none() && let Some(environment_id) = state.environment_id() { // 缺环境 id 就补上(多环境隔离)               request.environment_id = Some(environment_id.to_string()); // 把代理自身的环境 id 盖进裁决上下文            }            request.execution_id = execution_id.clone(); // 盖执行 id——decider 能关联到具体哪条命令            let decider_decision = map_decider_decision(decider.decide(request).await); // 问外部裁决者要结论(allow/deny/ask)            let policy_override = matches!(decider_decision, NetworkDecision::Allow); // 若放行 → 标记"策略覆盖",审计里单独可辨            (decider_decision, policy_override) // 返回 decider 结论 + 覆盖标志         } else {            ( // 没有 decider → 基线拒绝维持原判               NetworkDecision::deny_with_source( // 构造标准拒绝决策                  HostBlockReason::NotAllowed.as_str(), // reason:未在白名单                  NetworkDecisionSource::BaselinePolicy, // source:基线策略(非 decider)               ),               false, // 无覆盖         )      }      }      HostBlockDecision::Blocked(reason) => ( // 其他拒绝(Denied / NotAllowedLocal)不可覆盖——安全红线         NetworkDecision::deny_with_source( // 构造标准拒绝决策            reason.as_str(), // 保留原始 reason(denied vs local)            NetworkDecisionSource::BaselinePolicy, // source:基线策略         ),         false, // 无覆盖      ),   };   emit_policy_audit_event( // 每次决策都发审计事件——allow 和 deny 一视同仁      state, // 状态(持有审计观察者)      PolicyAuditEventArgs { // 审计上下文字段,一次给全         scope: POLICY_SCOPE_DOMAIN, // scope:域名级策略         decision: policy_decision, // 最终决策字符串(allow/deny/ask…)         source: source.as_str(), // 决策来源(基线 vs decider)         reason, // reason 码         protocol: request.protocol, // 协议类型(https-connect / http-absolute-form…)         server_address: request.host.as_str(), // 目标 host         server_port: request.port, // 目标端口         method: request.method.as_deref(), // HTTP method(若有)         client_addr: request.client_addr.as_deref(), // 客户端地址——哪个子进程发起的         execution_id: execution_id.as_deref(), // 执行 id——关联到具体命令         policy_override, // 是否 decider 覆盖放行(审计重点字段)      },   );   Ok(decision) // 返回最终决策给调用方(CONNECT accept / plain proxy)}

⚖️ 覆盖边界是安全设计,不是功能限制:decider 只能翻转 NotAllowed("没在白名单");Denied(显式拉黑)和 NotAllowedLocal(本地/内网红线,含 rebinding)任何外部裁决者都翻不了案。同时每次放行若来自覆盖,审计事件里 policy_override=true + source=Decider——"谁批的、为什么批"全程可追溯。

五、HTTP 代理请求路径:CONNECT 闸门 + 纯转发

http_proxy.rs(第 150-184 行)用 Rama 框架搭出两条路径:CONNECT 走"accept 闸门 → 隧道服务",其余方法走 absolute-form 纯转发。注意一个平台细节——为什么不用自动版本嗅探:

📄 codex-rs/network-proxy/src/http_proxy.rs (第 150-184 行)

pub(crate) fn http_proxy_service( // 构建 HTTP 代理服务:CONNECT 走 accept 闸门,其余方法走纯转发   state: Arc<NetworkProxyState>, // 共享策略状态   policy_decider: Option<Arc<dyn NetworkPolicyDecider>>, // 可选外部裁决者(两条路径都传)   environment_id: Option<String>, // 环境 id(多环境隔离维度)) -> BoxService<TcpStream, (), rama_core::error::BoxError> { // 返回装箱的 TCP service——listener 每连接调一次   ensure_rustls_crypto_provider(); // 初始化 rustls crypto provider(MITM TLS 终结需要)   // 源码注释:auto builder 会让每个 socket 先过 HTTP 版本嗅探 pre-read,macOS 上部分本地客户端会卡住   let http_service = HttpServer::http1().service( // 只要 HTTP/1 代理语义——显式指定,绕开嗅探路径      ( // layer 栈:CONNECT 升级层 + hop-by-hop 头剥离,套在纯转发 service 外         UpgradeLayer::new( // 拦截 CONNECT:先过 accept 闸门,再交给隧道 service            MethodMatcher::CONNECT, // 只匹配 CONNECT 方法            service_fn({ // accept 闸门闭包(每请求 clone decider/environment_id)               let policy_decider = policy_decider.clone(); // clone Arc——引用计数 +1,开销极小               let environment_id = environment_id.clone(); // 环境 id 同理               move |req| { // 每请求处理器                  http_connect_accept(policy_decider.clone(), environment_id.clone(), req) // 裁决闸门:放行→隧道,拒绝→403+审计            }         }),            CancelOnShutdown::new(service_fn(http_connect_proxy)), // 过闸后进入纯 TCP 隧道(或 MITM 终结);shutdown 时干净取消      ),         RemoveResponseHeaderLayer::hop_by_hop(), // 剥掉 hop-by-hop 头——代理转发不能泄漏连接级状态(Connection/Keep-Alive…)      )         .into_layer(service_fn({ // 非 CONNECT 请求走 absolute-form 纯转发(含凭据注入)            let policy_decider = policy_decider.clone(); // 同上 clone 模式            let environment_id = environment_id.clone(); // 环境 id 同理            move |req| http_plain_proxy(policy_decider.clone(), environment_id.clone(), req) // plain proxy:裁决→上游转发→响应回传      })),   );   BindConnectionAttribution::new(http_service, state, environment_id).boxed() // 再包一层:把每条连接绑定到执行归因(attribution token → execution id)}

为什么这样设计:UpgradeLayer 把 CONNECT 拆成"闸门 + 隧道"两段,是典型的关注点分离:裁决逻辑(策略、审计、decider)集中在 http_connect_accept(),隧道转发(字节泵送)在 http_connect_proxy()——后者完全不知道策略存在。最外层的 BindConnectionAttribution 则把"这条 TCP 连接属于哪次命令执行"织进每条连接,为审计和请求取消(request_cancellation.rs)提供锚点。

闸门内部,拒绝路径的"仪式感"值得细看——http_connect_accept()(第 231-289 行):

📄 codex-rs/network-proxy/src/http_proxy.rs (第 231-289 行)

let disconnect = NetworkRequestDisconnect::default(); // 断连句柄:客户端提前断开时,可取消在途裁决/转发,不浪费资源let mut request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { // 构造裁决上下文——decider 可能用到的字段一次给全   protocol: NetworkProtocol::HttpsConnect, // 协议类型:HTTPS CONNECT(隧道)   host: host.clone(), // 目标 host(已规范化)   port: authority.port, // 目标端口   environment_id, // 环境 id(此处可能为 None,evaluate_host_policy 会补)   client_addr: client.clone(), // 客户端地址——哪个子进程在发起请求   method: Some("CONNECT".to_string()), // method:CONNECT   command: None, // 未关联具体 shell 命令(归因层有则补)   exec_policy_hint: None, // 无 execpolicy 提示});request.disconnect = Some(disconnect.clone()); // 把断连句柄挂进裁决上下文——decider 可查"客户端还在不在"match disconnect // 用断连追踪器包住整个裁决:客户端跑了就别再白干   .track_http_request(      started_at, // 请求开始时间(延迟统计用)      evaluate_host_policy(&app_state, policy_decider.as_ref(), &request), // 跑统一裁决流水线(基线 + decider 覆盖 + 审计)   ).await{   Ok(NetworkDecision::Deny { reason, source, decision }) => { // 裁决结果:拒绝      let details = PolicyDecisionDetails { // 构造结构化详情块——错误响应和日志共用         decision, // 最终决策字符串         reason: &reason, // reason 码(NotAllowed / Denied / NotAllowedLocal)         source, // 来源(基线策略 vs decider)         protocol: NetworkProtocol::HttpsConnect, // 协议类型         host: &host, // 目标 host         port: authority.port, // 目标端口      };      let _ = app_state // 把拒绝记进审计环形缓冲(best-effort——记录失败不阻塞响应)         .record_blocked(BlockedRequest::new(BlockedRequestArgs {            host: host.clone(), // 被拒的 host            reason: reason.clone(), // 拒绝原因            client: client.clone(), // 发起方(哪个子进程)            method: Some("CONNECT".to_string()), // method            mode: None, // 网络模式(已知时填)            protocol: "http-connect".to_string(), // 协议标签            decision: Some(details.decision.as_str().to_string()), // 决策字符串            source: Some(details.source.as_str().to_string()), // 来源字符串            port: Some(authority.port), // 端口      }))         .await; // 等记录完成(纯内存操作,很快)      warn!("CONNECT blocked (client={client}, host={host}, reason={reason})"); // warn 日志——运维能看到"谁被拒、为什么"      return Err(blocked_text_with_details(&reason, &details)); // 回 403 + 结构化详情给客户端(子进程能读到拒绝原因)   }   Ok(NetworkDecision::Allow) => { // 裁决结果:放行      info!("CONNECT allowed (client={client}, host={host})"); // info 日志——放行也有审计痕迹   }   Err(err) => { // 裁决流水线本身出错(状态读取失败等)      error!("failed to evaluate host for CONNECT {host}: {err}"); // error 日志带上下文      return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); // 回 500——不泄漏内部细节给子进程   }}

为什么这样设计:拒绝时返回的不是裸 403,而是 blocked_text_with_details() 生成的结构化详情——子进程(比如 npm/curl)能读到"为什么被拒",用户排查体验好得多;同时同一条记录进环形缓冲、打 warn 日志、触发 observer,三路审计互不依赖。而裁决流水线出错时只回 "error"——对子进程最小化信息暴露,细节留在服务端日志里。

六、Credential Broker:先虚拟化,后注入

域名管住了"去哪",凭据经纪管的是"带什么"。场景很常见:子进程要 git push 或调 GitHub API,需要 token——但把真实 token 放进沙箱环境等于把它交给任意代码。Codex 的方案(credential_broker.rs,约 1600 行):子进程只拿假 token,代理在转发请求时注入真 token。三步走:

第 1 步:发现。子进程启动前,broker 扫描父进程环境里的真实凭据,与子进程环境比对(credential_broker.rs 第 473-518 行):

📄 codex-rs/network-proxy/src/credential_broker.rs (第 473-518 行)

pub(crate) fn discover_parent_credentials_for_environment( // 发现子进程环境里需要虚拟化的凭据并注册进 broker   &self, // credential broker(持有注册表)   parent_env: &HashMap<String, String>, // 父进程(代理所在)环境——真实值的来源   child_env: &HashMap<String, String>, // 子进程环境——检测"真值是否已泄漏进来"   environment_id: Option<&str>, // 注册的环境作用域(多环境隔离)) {   let mut state = self.write_state(); // 拿 broker 写锁(注册是变更操作)   if !state.enabled { // broker 未启用 → 无事可做      return; // 提前退出,零开销   }   state.observe_credential_owners(parent_env); // 记录父进程里哪些 env var 持有凭据(供后续 restore 逻辑用)   let active_sources = active_credential_sources(&state, child_env); // 找出子进程环境里的"活跃凭据源"(如 github.com 对应的 GITHUB_TOKEN)   for source in &active_sources { // 遍历每个活跃源      for env_var in &source.env_vars { // ...及其覆盖的每个环境变量         let Some(real_value) = brokerable_credential_value(parent_env, &state, env_var, &source.provider) // 从父环境取真实值(provider 感知的提取逻辑)            .map(str::to_string) // 转成 owned String 存起来         else {            continue; // 父环境没有对应真值 → 跳过这个变量         }         if state.credentials.iter().any(|credential| { // 若同一凭据已注册且子进程带着稳定身份别名 → 跳过            env_key_matches(&credential.env_var, env_var) // 同一个环境变量名(大小写不敏感匹配)            && credential.provider.same_provider(&source.provider) // 同一 provider(github vs gitlab…)            && credential.real_value == real_value // 真值相同——没有发生轮换            && credential.has_carried_identity(child_env, &state.credentials, &active_sources) // 且子进程已携带其身份别名         }) {            continue; // 已虚拟化且身份稳定 → 不重复注册(避免别名抖动,子进程缓存的 token 继续有效)         }         if env_value(child_env, env_var) != Some(real_value.as_str()) // 情形 1:子进程该变量的值与真值不同(说明已被替换/是别的 token)…         && child_env.values().any(|value| { // …或情形 2:真值以某种形式出现在子进程环境里            value == &real_value // 某个变量精确等于真值               || is_builtin_shaped_credential(&real_value) && value.contains(&real_value) // 内置形态凭据(如 PAT)作为子串嵌在别的值里               || source.provider.contains_embedded_value(value, &real_value) // provider 特定的嵌入模式(如 basic-auth URL 里的 token)         })         {            let _ = state.register( // 注册凭据:建立"真值 → 假别名"映射               env_var, // 哪个环境变量承载它               source.provider.clone(), // provider(决定之后注入哪种 header)               source.host_binding.clone(), // host 绑定——只对匹配的目标域名注入               environment_id, // 环境作用域               &real_value, // 真值本身(只存在 broker 里,绝不进子进程环境)               parent_env, // 父环境上下文(owner 追踪用)            );         }      }   }}

为什么这样设计:两个防御点。其一,嵌入检测(contains_embedded_value):token 不一定单独占一个环境变量——可能藏在 https://user:TOKEN@github.com 这种 URL 里,broker 按 provider 模式识别并整体虚拟化。其二,身份稳定性(has_carried_identity):同一真值已注册且子进程持有其别名时不重复注册——否则每次 spawn 都换假 token,子进程里缓存的凭据全部失效,git/npm 这类工具会反复要求认证。

第 2 步:虚拟化。runtime.rs(第 459-471 行)把子进程环境里的真值换成假别名:

📄 codex-rs/network-proxy/src/runtime.rs (第 459-471 行)

pub(crate) fn virtualize_child_credentials_for_environment( // 为指定环境的子进程虚拟化凭据:真值 → 假别名   &self, // 代理状态(持有 credential broker)   env: &mut HashMap<String, String>, // 子进程环境表——原地修改   environment_id: Option<&str>, // 环境隔离维度(凭据按环境划作用域)) {   let parent_env = std::env::vars_os() // 快照代理进程自身的环境(真值就住在这里)      .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))) // 过滤非 UTF-8 条目(防御性——env var 理应是字符串)      .collect(); // 收集成 HashMap 供 broker 扫描   self.credential_broker // 第一步:发现子进程环境里哪些凭据是"真值"需要虚拟化      .discover_parent_credentials_for_environment(&parent_env, env, environment_id); // 注册真值 + 建立假别名(按 provider/host 绑定)   self.credential_broker // 第二步:改写子进程环境——把真值替换成假值      .virtualize_child_env_for_environment(env, environment_id); // 子进程从此只见假凭据;请求时由代理注回真值}

第 3 步:注入。转发请求时,broker 按目标 URL 匹配注册表,把真凭据写进 header(http_proxy.rs 第 949-975 行):

📄 codex-rs/network-proxy/src/http_proxy.rs (第 949-975 行)

async fn inject_forward_request_credentials( // 给转发请求注入真实凭据——子进程自始至终只见过假值   app_state: &NetworkProxyState, // 代理状态(持有 broker)   context: &RequestContext, // 解析后的请求上下文(authority 等)   req: &mut Request, // 发往上游的请求——header 原地修改) -> Result<()> {   let authority = context.host_with_port(); // 目标 host:port(来自 absolute-form URI)   let scheme = req.uri().scheme_str().unwrap_or("http"); // scheme(缺失时默认 http)   let request_path = req // 提取 path+query,用于 destination 匹配      .uri()      .path_and_query()      .map(rama_http::uri::PathAndQuery::as_str)      .filter(|path| *path != "*") // "*" 是 server-wide OPTIONS 通配——按根作用域处理      .unwrap_or("/"); // 无 path → "/"(根路径)   let destination = format!("{scheme}://{authority}{request_path}"); // 完整目标 URL——凭据注册表按它匹配   let unrestricted_plaintext = app_state.plaintext_credential_injection_enabled().await?; // "危险"明文注入开关是否打开?   app_state.inject_request_credentials(&destination, req.headers_mut()); // 按 destination 匹配注册表,注入真实凭据 header(Authorization 等)   if unrestricted_plaintext { // 若运维显式允许根作用域注入…      app_state.inject_request_credentials( // …再按裸 host 注入一次(server-wide 凭据,如 OPTIONS *)         &normalize_host(&authority.host.to_string()), // 规范化后的纯 host(无 path 作用域)         req.headers_mut(), // 同一个 header map      );   }   Ok(()) // 完成——请求已带真实凭据,而子进程从未见过它}
阶段
触发时机
做什么
① discover
子进程 spawn 前(apply_to_env 链路)
比对父/子环境,识别真值(含 URL 嵌入形态),注册"真值→假别名"
② virtualize
子进程环境最终化时
把子进程 env 里的真值替换成假别名;真值只留在 broker 内存中
③ inject
代理转发每个 HTTP 请求时
按 destination URL + host 绑定匹配注册表,把真凭据写进上游 header

🛡️ 安全模型一句话:沙箱里的任意代码(包括被注入的恶意 npm 包)能使用凭据(通过代理发请求),但永远看不到凭据本身——它读到的 env var 是假别名,抓包看到的是代理与上游之间的 TLS。"能用不可见"是比"不给用"更实用的安全姿态:工具链照常工作,密钥零暴露。

七、拒绝审计与可移植策略导出

每次拒绝都会落进 record_blocked()(runtime.rs 第 721-755 行)——注意它的内存有界设计和观察者解耦:

📄 codex-rs/network-proxy/src/runtime.rs (第 721-755 行)

pub async fn record_blocked(&self, mut entry: BlockedRequest) -> Result<()> { // 记录一次被拒请求:审计环形缓冲 + 通知观察者   self.reload_if_needed().await?; // 先按需重载(保证 observer 配置是新的)   entry.execution_id = self.execution_id(); // 盖执行 id——把这次拒绝关联到具体命令   let blocked_for_observer = entry.clone(); // clone 一份给观察者(原值马上要 move 进环形缓冲)   let blocked_request_observer = self.blocked_request_observer.read().await.clone(); // 快照当前 observer(可能为 None——读锁内只取 Arc)   let violation_line = blocked_request_violation_log_line(&entry); // 预格式化一条结构化"违规"日志行   let host = entry.host.clone(); // 取出字段供日志用(entry 稍后被 move)   let reason = entry.reason.clone(); // reason 码   let decision = entry.decision.clone(); // 决策字符串   let source = entry.source.clone(); // 来源标签   let protocol = entry.protocol.clone(); // 协议标签   let port = entry.port; // 端口(Copy 类型,无需 clone)   let (total, buffered) = { // 写锁内变更环形缓冲——临界区尽量短      let mut guard = self.state.write().await; // 拿 ConfigState 独占写锁      guard.blocked.push_back(entry); // 追加到 FIFO 环形缓冲尾部      guard.blocked_total = guard.blocked_total.saturating_add(1); // 累计计数 +1(saturating——永不溢出 panic)      let total = guard.blocked_total; // 快照总数供日志      while guard.blocked.len() > MAX_BLOCKED_EVENTS { // 环形缓冲超上限…         guard.blocked.pop_front(); // …淘汰最旧条目(内存有界,拒绝风暴也打不爆)      }      (total, guard.blocked.len()) // 返回 (累计总数, 当前缓冲条数) 供日志   };   debug!(      "recorded blocked request telemetry (total={total}, host={host}, reason={reason}, decision={decision:?}, source={source:?}, protocol={protocol}, port={port:?}, buffered={buffered})" // debug 日志带全上下文——运维可追溯"谁被拒、为什么、第几次"   );   if let Some(observer) = blocked_request_observer { // 若注册了观察者…      observer.on_blocked_request(blocked_for_observer).await; // …通知它(审批流 / UI 角标)——在锁外执行,不阻塞其他请求   }   Ok(()) // 完成}

为什么这样设计:MAX_BLOCKED_EVENTS 环形缓冲 + blocked_total 累计计数是经典的有界审计日志模式:内存占用恒定,"总共被拒了多少次"永不丢失。observer 通知放在写锁之外——审批流回调再慢也不会卡住其他请求的裁决路径。

最后看策略如何"出笼"。environment_policy.rs(第 14-33 行)定义了一个可移植的策略视图——只含流量限制语义,不含控制器运行时设置:

📄 codex-rs/network-proxy/src/environment_policy.rs (第 14-33 行)

pub struct EnvironmentNetworkPolicy { // 可移植网络策略:只描述"允许什么流量",不暴露控制器内部   pub domains: Option<NetworkDomainPermissions>, // 域名 allow/deny 列表(None = 不限)   pub unix_sockets: Option<NetworkUnixSocketPermissions>, // Unix socket 权限(None = 全禁)   pub allow_upstream_proxy: bool, // 子进程是否允许再链式挂一个上游代理   pub dangerously_allow_all_unix_sockets: bool, // 逃生舱:放行所有 unix socket(名字里就写着 dangerous)   pub allow_local_binding: bool, // 是否允许本地/内网访问   pub managed_allowed_domains_only: bool, // true = 白名单由托管经纪自动创建(审计时区分来源)}impl EnvironmentNetworkPolicy {   /// Captures portable traffic restrictions without exposing controller runtime settings.   pub fn from_config(config: &NetworkProxyConfig, managed_allowed_domains_only: bool) -> Self { // 从完整代理配置构造可移植视图      Self {         domains: config.domains.clone(), // 拷贝域名权限(owned 数据,必须 clone)         unix_sockets: config.unix_sockets.clone(), // 拷贝 socket 权限         allow_upstream_proxy: config.allow_upstream_proxy, // 拷贝开关(Copy 类型)         dangerously_allow_all_unix_sockets: config.dangerously_allow_all_unix_sockets, // 拷贝逃生舱开关         allow_local_binding: config.allow_local_binding, // 拷贝本地绑定权限         managed_allowed_domains_only, // 透传经纪来源标志      }   }}

为什么这样设计:这个结构是代理与外部世界(exec-server、environment 描述、审计导出)之间的 API 边界——它刻意排除了 proxy_url、mitm 这类"怎么实现"的字段,只保留"允许什么"的语义。策略可以跨进程/跨机器传递(第 28 讲 Exec Server 会用到),而实现细节留在代理内部——这正是前几讲反复出现的 Codex 分层哲学:策略与执行分离。

八、全景:一条请求的完整旅程

子进程网络请求全链路(network-proxy crate)

① 环境接管:prepare_for_addrs → apply_proxy_env_overrides

子进程 env 被改写:代理变量指向 loopback、真凭据换假别名、写入归因 token。

▼

② 沙箱放行:loopback_ports → bwrap/Landlock

沙箱只开放代理端口——绕过代理直连在连接层就不可能。

▼

③ 连接进入:http_proxy_service(CONNECT / absolute-form)

🔹 CONNECT → accept 闸门 + TCP 隧道🔹 其他方法 → plain proxy 纯转发(含凭据注入)

▼

④ 裁决闸门:evaluate_host_policy → host_blocked

deny > local/rebinding > allowlist;仅 NotAllowed 可被 decider 覆盖,每次决策发审计事件。

▼

⑤a 拒绝路径:record_blocked + observer

环形缓冲(有界)+ 累计计数 + warn 日志 + 结构化 403 详情回给子进程。

▼

⑤b 放行路径:credential broker inject → upstream

按 destination 匹配注册表注入真实凭据 header,转发上游;子进程全程只见假 token。

本讲小结:network-proxy 用约 2.2 万行 Rust 把"网络访问控制"做成了一个完整的子系统——loopback 强制路由(沙箱配合)、三级域名裁决 + DNS rebinding 防御、decider 覆盖边界、凭据虚拟化注入、有界审计日志,外加 MITM/SOCKS5/请求取消等扩展面。它的核心哲学与前几讲的沙箱一脉相承:默认拒绝、显式放行、全程可审计——而"能用不可见"的凭据经纪,是这套哲学在密钥管理上的延伸。

📚 系列导航

← 第 26 讲:Guardian 安全审查 v1+v2

→ 第 28 讲:Exec Server 远程/本地执行服务

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料