## Why `SandboxPolicy` currently mixes together three separate concerns: - parsing layered config from `config.toml` - representing filesystem sandbox state - carrying basic network policy alongside filesystem choices That makes the existing config awkward to extend and blocks the new TOML proposal where `[permissions]` becomes a table of named permission profiles selected by `default_permissions`. (The idea is that if `default_permissions` is not specified, we assume the user is opting into the "traditional" way to configure the sandbox.) This PR adds the config-side plumbing for those profiles while still projecting back to the legacy `SandboxPolicy` shape that the current macOS and Linux sandbox backends consume. It also tightens the filesystem profile model so scoped entries only exist for `:project_roots`, and so nested keys must stay within a project root instead of using `.` or `..` traversal. This drops support for the short-lived `[permissions.network]` in `config.toml` because now that would be interpreted as a profile named `network` within `[permissions]`. ## What Changed - added `PermissionsToml`, `PermissionProfileToml`, `FilesystemPermissionsToml`, and `FilesystemPermissionToml` so config can parse named profiles under `[permissions.<profile>.filesystem]` - added top-level `default_permissions` selection, validation for missing or unknown profiles, and compilation from a named profile into split `FileSystemSandboxPolicy` and `NetworkSandboxPolicy` values - taught config loading to choose between the legacy `sandbox_mode` path and the profile-based path without breaking legacy users - introduced `codex-protocol::permissions` for the split filesystem and network sandbox types, and stored those alongside the legacy projected `sandbox_policy` in runtime `Permissions` - modeled `FileSystemSpecialPath` so only `ProjectRoots` can carry a nested `subpath`, matching the intended config syntax instead of allowing invalid states for other special paths - restricted scoped filesystem maps to `:project_roots`, with validation that nested entries are non-empty descendant paths and cannot use `.` or `..` to escape the project root - kept existing runtime consumers working by projecting `FileSystemSandboxPolicy` back into `SandboxPolicy`, with an explicit error for profiles that request writes outside the workspace root - loaded proxy settings from top-level `[network]` - regenerated `core/config.schema.json` ## Verification - added config coverage for profile deserialization, `default_permissions` selection, top-level `[network]` loading, network enablement, rejection of writes outside the workspace root, rejection of nested entries for non-`:project_roots` special paths, and rejection of parent-directory traversal in `:project_roots` maps - added protocol coverage for the legacy bridge rejecting non-workspace writes ## Docs - update the Codex config docs on developers.openai.com/codex to document named `[permissions.<profile>]` entries, `default_permissions`, scoped `:project_roots` syntax, the descendant-path restriction for nested `:project_roots` entries, and top-level `[network]` proxy configuration --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/13434). * #13453 * #13452 * #13451 * #13449 * #13448 * #13445 * #13440 * #13439 * __->__ #13434
8.3 KiB
codex-network-proxy
codex-network-proxy is Codex's local network policy enforcement proxy. It runs:
- an HTTP proxy (default
127.0.0.1:3128) - a SOCKS5 proxy (default
127.0.0.1:8081, enabled by default)
It enforces an allow/deny policy and a "limited" mode intended for read-only network access.
Quickstart
1) Configure
codex-network-proxy reads from Codex's merged config.toml (via codex-core config loading).
Network settings live under the selected permissions profile. Example config:
default_permissions = "workspace"
[permissions.workspace.network]
enabled = true
proxy_url = "http://127.0.0.1:3128"
# SOCKS5 listener (enabled by default).
enable_socks5 = true
socks_url = "http://127.0.0.1:8081"
enable_socks5_udp = true
# When `enabled` is false, the proxy no-ops and does not bind listeners.
# When true, respect HTTP(S)_PROXY/ALL_PROXY for upstream requests (HTTP(S) proxies only),
# including CONNECT tunnels in full mode.
allow_upstream_proxy = true
# By default, non-loopback binds are clamped to loopback for safety.
# If you want to expose these listeners beyond localhost, you must opt in explicitly.
dangerously_allow_non_loopback_proxy = false
mode = "full" # default when unset; use "limited" for read-only mode
# When true, HTTPS CONNECT can be terminated so limited-mode method policy still applies.
mitm = false
# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key).
# Hosts must match the allowlist (unless denied).
# Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`.
# The global `*` wildcard is rejected.
# If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured.
allowed_domains = ["*.openai.com", "localhost", "127.0.0.1", "::1"]
denied_domains = ["evil.example"]
# If false, local/private networking is rejected. Explicit allowlisting of local IP literals
# (or `localhost`) is required to permit them.
# Hostnames that resolve to local/private IPs are still blocked even if allowlisted.
allow_local_binding = true
# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`.
allow_unix_sockets = ["/tmp/example.sock"]
# DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any
# absolute socket path from `x-unix-socket`.
dangerously_allow_all_unix_sockets = false
2) Run the proxy
cargo run -p codex-network-proxy --
3) Point a client at it
For HTTP(S) traffic:
export HTTP_PROXY="http://127.0.0.1:3128"
export HTTPS_PROXY="http://127.0.0.1:3128"
export WS_PROXY="http://127.0.0.1:3128"
export WSS_PROXY="http://127.0.0.1:3128"
For SOCKS5 traffic (when enable_socks5 = true):
export ALL_PROXY="socks5h://127.0.0.1:8081"
4) Understand blocks / debugging
When a request is blocked, the proxy responds with 403 and includes:
x-proxy-error: one of:blocked-by-allowlistblocked-by-denylistblocked-by-method-policyblocked-by-policy
In "limited" mode, only GET, HEAD, and OPTIONS are allowed. HTTPS CONNECT requests require
MITM to enforce limited-mode method policy; otherwise they are blocked. SOCKS5 remains blocked in
limited mode.
Websocket clients typically tunnel wss:// through HTTPS CONNECT; those CONNECT targets still go
through the same host allowlist/denylist checks.
Library API
codex-network-proxy can be embedded as a library with a thin API:
use codex_network_proxy::{NetworkProxy, NetworkDecision, NetworkPolicyRequest};
let proxy = NetworkProxy::builder()
.http_addr("127.0.0.1:8080".parse()?)
.policy_decider(|request: NetworkPolicyRequest| async move {
// Example: auto-allow when exec policy already approved a command prefix.
if let Some(command) = request.command.as_deref() {
if command.starts_with("curl ") {
return NetworkDecision::Allow;
}
}
NetworkDecision::Deny {
reason: "policy_denied".to_string(),
}
})
.build()
.await?;
let handle = proxy.run().await?;
handle.shutdown().await?;
When unix socket proxying is enabled (allow_unix_sockets or
dangerously_allow_all_unix_sockets), proxy bind overrides are still clamped to loopback to
avoid turning the proxy into a remote bridge to local daemons.
Policy hook (exec-policy mapping)
The proxy exposes a policy hook (NetworkPolicyDecider) that can override allowlist-only blocks.
It receives command and exec_policy_hint fields when supplied by the embedding app. This lets
core map exec approvals to network access, e.g. if a user already approved curl * for a session,
the decider can auto-allow network requests originating from that command.
Important: Explicit deny rules still win. The decider only gets a chance to override
not_allowed (allowlist misses), not denied or not_allowed_local.
OTEL Audit Events (embedded/managed)
When codex-network-proxy is embedded in managed Codex runtime, policy decisions emit structured
OTEL-compatible events with target=codex_otel.network_proxy.
Event name:
codex.network_proxy.policy_decision- emitted for each policy decision (
domainandnon_domain). network.policy.scope = "domain"for host-policy evaluations (evaluate_host_policy).network.policy.scope = "non_domain"for mode-guard/proxy-state checks (including unix-socket guard paths and unix-socket allow decisions).
- emitted for each policy decision (
Common fields:
event.nameevent.timestamp(RFC3339 UTC, millisecond precision)- optional metadata:
conversation.idapp.versionuser.account_id
- policy/network:
network.policy.scope(domainornon_domain)network.policy.decision(allow,deny, orask)network.policy.source(baseline_policy,mode_guard,proxy_state,decider)network.policy.reasonnetwork.transport.protocolserver.addressserver.porthttp.request.method(defaults to"none"when absent)client.address(defaults to"unknown"when absent)network.policy.override(trueonly when decider-allow overrides baselinenot_allowed)
Unix-socket block-path audits use sentinel endpoint values:
server.address = "unix-socket"server.port = 0
Audit events intentionally avoid logging full URL/path/query data.
Platform notes
- Unix socket proxying via the
x-unix-socketheader is macOS-only; other platforms will reject unix socket requests. - HTTPS tunneling uses rustls via Rama's
rama-tls-rustls; this avoids BoringSSL/OpenSSL symbol collisions in mixed TLS dependency graphs.
Security notes (important)
This section documents the protections implemented by codex-network-proxy, and the boundaries of
what it can reasonably guarantee.
-
Allowlist-first policy: if
allowed_domainsis empty, requests are blocked until an allowlist is configured. -
Domain patterns: exact hosts plus scoped wildcards (
*.example.com,**.example.com) are supported; the global*wildcard is rejected. -
Deny wins: entries in
denied_domainsalways override the allowlist. -
Local/private network protection: when
allow_local_binding = false, the proxy blocks loopback and common private/link-local ranges. Explicit allowlisting of local IP literals (orlocalhost) is required to permit them; hostnames that resolve to local/private IPs are still blocked even if allowlisted (best-effort DNS lookup). -
Limited mode enforcement:
- only
GET,HEAD, andOPTIONSare allowed - HTTPS
CONNECTremains a tunnel; limited-mode method enforcement does not apply to HTTPS
- only
-
Listener safety defaults:
- the HTTP proxy listener clamps non-loopback binds unless explicitly enabled via
dangerously_allow_non_loopback_proxy
- the HTTP proxy listener clamps non-loopback binds unless explicitly enabled via
-
when unix socket proxying is enabled, all proxy listeners are forced to loopback to avoid turning the proxy into a remote bridge into local daemons.
-
dangerously_allow_all_unix_sockets = truebypasses the unix socket allowlist entirely (still macOS-only and absolute-path-only). Use only in tightly controlled environments. -
enabledis enforced at runtime; when false the proxy no-ops and does not bind listeners. Limitations: -
DNS rebinding is hard to fully prevent without pinning the resolved IP(s) all the way down to the transport layer. If your threat model includes hostile DNS, enforce network egress at a lower layer too (e.g., firewall / VPC / corporate proxy policies).