网络代理 · 联网权限怎么落地
沙箱只能把网络整个打开或整个关掉;codex-network-proxy 补上中间档:会话启动一个本地 HTTP 与 SOCKS5 代理,命令经环境变量被引向它,操作系统沙箱再保证命令只能连到代理端口。代理按“拒绝名单优先、本地地址默认挡、允许名单兜底”裁决,名单外的主机交给内核的审批服务内联询问;需要检查 HTTPS 内容时它自签 CA 做中间人,还能替命令注入真实凭据。
网络代理 · 联网权限怎么落地
前三篇的平台沙箱对网络只有两档:NetworkSandboxPolicy 是 Restricted 就断网,是 Enabled 就全通。“只许访问这几个域名”这种中间档,由 codex-network-proxy 实现,这个 crate 去掉测试约 1.7 万行,在这一部分涉及的 crate 里仅次于 Windows 沙箱。
用户看到的样子
代理是实验特性 network_proxy(默认关闭),而且只约束已经开放的网络:先让沙箱允许联网([sandbox_workspace_write] 的 network_access,或配置档的 network.enabled),再在 [features.network_proxy] 或配置档的 [permissions.<name>.network] 里列 domains,值为 allow 或 deny,还可以设 mode = "limited" 只许只读请求。被拦的请求收到 403,响应头 x-proxy-error 写着原因,如 blocked-by-allowlist、blocked-by-denylist、blocked-by-method-policy。遇到需要审批的主机,TUI 给出 Yes, just this once、Yes, and allow this host for this conversation、Yes, and allow this host in the future 等选项。写法与通配规则见手册的沙箱、审批与安全。
什么时候起代理
if feature_enabled
&& candidate_permission_profile
.network_sandbox_policy()
.is_enabled()
{
if let Some(FeatureToml::Config(config)) =
features.and_then(|features| features.network_proxy.as_ref())
{
apply_network_proxy_feature_config(&mut configured_proxy, config);
}
configured_proxy.set_credential_broker_openai_base_url(credential_broker_base_url);
configured_proxy.enabled = true;
}(codex-rs/core/src/config/network_config.rs:98)
特性打开、配置档本身允许联网,代理才启用;配置档的 network 表只提供备料,不会自己拉起代理(见权限模型)。管理员在 requirements.toml 里写了 [network] 时例外:这时总会生成代理规格,管理员的名单与限制压在用户配置之上。会话启动时 start_managed_network_proxy 先把规则文件里的 network_rule(见执行策略)并进域名名单,再拉起代理:HTTP 监听默认 127.0.0.1:3128,SOCKS5 默认 127.0.0.1:8081 且默认开启;非回环的监听地址会被压回回环,除非显式设置 dangerously_allow_non_loopback_proxy。
命令怎么被引到代理上
代理本身不拦截任何流量,它只是一个等着被连的服务。起命令时,apply_proxy_env_overrides(codex-rs/network-proxy/src/proxy.rs:769)往子进程环境里写一组变量:HTTP_PROXY、HTTPS_PROXY 及其小写形式,yarn、npm、bundler、pip、docker 各自的代理变量,WebSocket 用的 WS_PROXY、WSS_PROXY,都指向 HTTP 代理;SOCKS5 开着时 ALL_PROXY 与 FTP_PROXY 指向 socks5h:// 地址;NODE_USE_ENV_PROXY=1 让 Node.js 内置的 HTTP 客户端也认代理变量;NO_PROXY 只在允许本地绑定时列出本地地址,否则置空,好让本地目标也经过代理检查;最后是一个 CODEX_NETWORK_PROXY_ACTIVE=1 标记。守规矩的程序会走代理;不守规矩的,由操作系统沙箱兜底:
| 平台 | 怎么保证“只能连代理” |
|---|---|
| macOS | Seatbelt 网络段只放行到 localhost 上代理端口的出站连接(见 macOS 沙箱) |
| Linux | 新的网络命名空间里只有一座通往代理的桥,seccomp 只许 IP 套接字(见 Linux 沙箱) |
| Windows | 离线沙箱用户的防火墙规则只放行代理端口的回环连接,要求 elevated 后端(见 Windows 沙箱) |
受管网络下,每次执行还会拿到一个执行级的代理视图(NetworkProxy::for_execution),带一个归属令牌。Linux 的桥在转发前先写一帧令牌,代理据此知道这个连接属于哪条命令,审批和拦截记录都能落到具体的工具调用上。
放行、拒绝、询问
代理收到 CONNECT 或普通 HTTP 请求后,先规范化主机名(去空白、去端口、去方括号、转小写),然后按固定顺序判定:
// Decision order matters:
// 1) explicit deny always wins
// 2) local/private networking is opt-in (defense-in-depth)
// 3) allowlist is enforced when configured
if globset_matches_host_or_unscoped(&deny_set, host_str) {
return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied));
}
let is_allowlisted = globset_matches_host_or_unscoped(&allow_set, host_str);(codex-rs/network-proxy/src/runtime.rs:721)
第二步挡的是回环、私有网段、链路本地、CGNAT、测试网段等非公网地址,除非打开 allow_local_binding。字面量的本地地址要显式列进允许名单才放行;主机名则先做一次 DNS 解析,解析到非公网地址就拦,哪怕它在允许名单里,注释点名防的是 DNS rebinding。第三步:允许名单为空时一律拒绝,不在名单里就拒绝。名单的写法由这段注释定义:
// Supported domain patterns:
// - "example.com": match the exact host
// - "*.example.com": match any subdomain (not the apex)
// - "**.example.com": match the apex and any subdomain
// - "api?.example.com": match exactly one character after "api"
// - "*": match every host when explicitly enabled for allowlist compilation(codex-rs/network-proxy/src/policy.rs:219)
全局 * 只能用在允许名单里,出现在拒绝名单会报错。同一个模式同时写了 allow 与 deny 时,按 None < Allow < Deny 的枚举顺序取 deny。mode = "limited" 再加一道方法检查:只许 GET、HEAD、OPTIONS。HTTPS 的 CONNECT 隧道里看不到方法,所以 limited 模式会自动打开中间人(下文),在解开的请求上检查方法;没有中间人状态时,CONNECT 直接拦下。
三种拦截里,只有“不在允许名单”这一种可以商量:
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 decider_decision = map_decider_decision(decider.decide(request).await);
let policy_override = matches!(decider_decision, NetworkDecision::Allow);
(decider_decision, policy_override)
} else {
// ...
}
}
HostBlockDecision::Blocked(reason) => ((codex-rs/network-proxy/src/network_policy.rs:369)
显式拒绝和本地地址直接 403,只有名单外的主机才去问“决定者”(NetworkPolicyDecider);没有决定者时同样按名单拒绝。
询问落到谁头上
决定者在内核里就是 NetworkApprovalService::handle_inline_policy_request(codex-rs/core/src/tools/network_approval.rs:625)。它是“内联”的:命令的请求挂在代理上等着,审批走完再放行或拒绝,命令不必失败重跑。几条规则:
- 先查会话级的缓存:同一环境、主机、协议、端口之前被放行过或拒绝过,直接返回。同一条命令对同一主机的并发请求只弹一次,其余等同一个结果。
- 审批策略是
never,或配置档不是Managed,直接拒绝。 - 否则构造
ApprovalAction::NetworkAccess交给Session::request_approval,照样是钩子、自动审查、用户的顺序(见审批流程)。选“本次会话允许”记进会话缓存;选“以后都允许”或“以后都拦截”,会同时更新运行中代理的名单,并往default.rules追加一条network_rule。
决定者不是总在:会话级代理只在 requirements.toml 有 [network] 时装上它;工作环境带网络策略时,每次执行会拿到一个兜底的决定者。管理员设了 managed_allowed_domains_only,决定者被整个拿掉,名单外一律硬拒。
中间人证书与凭据代理
有三种情况需要看清 HTTPS 的内容:limited 模式、配置了 MITM 钩子(按主机、方法、路径前缀匹配请求,删除或注入请求头),以及凭据代理。这时代理为进程生成一个 CA,私钥只留在内存里,证书写进 $CODEX_HOME/proxy/,并把证书与原有信任链合成一个信任包,经 SSL_CERT_FILE、REQUESTS_CA_BUNDLE、NODE_EXTRA_CA_CERTS、GIT_SSL_CAINFO、CARGO_HTTP_CAINFO 等 11 个环境变量(CUSTOM_CA_ENV_KEYS,codex-rs/network-proxy/src/certs.rs:260)告诉常见的 HTTPS 客户端。
凭据代理(credential_broker)把“密钥不进沙箱”做成了机制:子进程环境里的 GH_TOKEN、GITHUB_TOKEN、OPENAI_API_KEY 等被替换成形状相同的假值;请求真正发往绑定的主机(如 api.github.com、api.openai.com)时,代理在中间人层把 Authorization 头换回真实凭据。明文 HTTP 请求默认不注入,除非显式打开 dangerously_allow_plaintext_credential_injection。沙箱里的命令即使把环境变量打印出来,也拿不到真密钥。
和《从 LLM 到 Coding Agent》对照
那本书的权限系统在开篇就担心过“一个发请求的工具加一段 API key”。Codex 的回答分两层:操作系统沙箱保证命令只能连到代理,代理按名单与审批决定去哪,凭据代理再让真密钥根本不出现在沙箱里。Grok Build 的做法(见其沙箱与权限)是限网档下用 seccomp 把子进程网络整个封死;Codex 多出来的这一层代理,让“允许联网,但只许去这些地方”成为可能。