mirror of
https://github.com/openai/codex.git
synced 2026-05-25 13:34:51 +00:00
## Stack 1. Parent PR: #18868 adds MITM hook config and model only. 2. Parent PR: #20659 wires hook enforcement into the proxy request path. 3. This PR changes the user facing PermissionProfile TOML shape. ## Why 1. The broader goal is to make MITM clamping usable from the same permission profile that already controls network behavior. 2. This PR is the config UX layer for the stack. It moves MITM policy into `[permissions.<profile>.network.mitm]` instead of exposing the flat runtime shape to users. 3. The named hook and action tables belong here because users need reusable policy blocks that are easy to review, while the proxy runtime only needs a flat hook list. 4. This PR validates action refs during config parsing so mistakes in the user facing policy fail before a proxy session starts. 5. Keeping the lowering here lets the proxy keep its simpler runtime model and lets PermissionProfile remain the single source of network permission policy. ## Summary 1. Keep MITM policy inside `[permissions.<profile>.network.mitm]` so the selected PermissionProfile owns network proxy policy. 2. Use named MITM hooks under `[permissions.<profile>.network.mitm.hooks.<name>]`. 3. Put host, methods, path prefixes, query, headers, body, and action refs on the hook table. 4. Define reusable action blocks under `[permissions.<profile>.network.mitm.actions.<name>]`. 5. Represent action blocks with `NetworkMitmActionToml`, then lower them into the proxy runtime action config. 6. Reject unknown refs, empty refs, and empty action blocks during config parsing. 7. Keep the runtime hook model unchanged by lowering config into the existing proxy hook list. 8. Preserve the #20659 activation fix for nested MITM policy. ## Example ```toml [permissions.workspace.network.mitm] enabled = true [permissions.workspace.network.mitm.hooks.github_write] host = "api.github.com" methods = ["POST", "PUT"] path_prefixes = ["/repos/openai/"] action = ["strip_auth"] [permissions.workspace.network.mitm.actions.strip_auth] strip_request_headers = ["authorization"] ``` ## Validation 1. Regenerated the config schema. 2. Ran the core MITM config parsing and validation tests. 3. Ran the core PermissionProfile MITM proxy activation tests. 4. Ran the core config schema fixture test. 5. Ran the network proxy MITM policy tests. 6. Ran the scoped Clippy fixer for the network proxy crate. 7. Ran the scoped Clippy fixer for the core crate. --------- Co-authored-by: Winston Howes <winston@openai.com>
230 lines
9.0 KiB
Markdown
230 lines
9.0 KiB
Markdown
# 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:
|
|
|
|
```toml
|
|
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
|
|
# HTTPS MITM is enabled automatically when `mode = "limited"` or when MITM hooks are configured.
|
|
# CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key).
|
|
|
|
# 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 = false
|
|
|
|
# DANGEROUS (macOS-only): bypasses unix socket allowlisting and permits any
|
|
# absolute socket path from `x-unix-socket`.
|
|
dangerously_allow_all_unix_sockets = false
|
|
|
|
# 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 no domain entries are marked `allow`, the proxy blocks requests until an allowlist is configured.
|
|
[permissions.workspace.network.domains]
|
|
"*.openai.com" = "allow"
|
|
"localhost" = "allow"
|
|
"127.0.0.1" = "allow"
|
|
"::1" = "allow"
|
|
"evil.example" = "deny"
|
|
|
|
# MITM hooks match HTTPS requests after CONNECT is terminated.
|
|
[permissions.workspace.network.mitm.hooks.github_write]
|
|
host = "api.github.com"
|
|
methods = ["POST", "PUT"]
|
|
path_prefixes = ["/repos/openai/"]
|
|
action = ["strip_auth"]
|
|
|
|
# Named actions can be shared across hooks and overridden by higher-precedence config layers.
|
|
[permissions.workspace.network.mitm.actions.strip_auth]
|
|
strip_request_headers = ["authorization"]
|
|
|
|
# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`.
|
|
[permissions.workspace.network.unix_sockets]
|
|
"/tmp/example.sock" = "allow"
|
|
```
|
|
|
|
### 2) Run the proxy
|
|
|
|
```bash
|
|
cargo run -p codex-network-proxy --
|
|
```
|
|
|
|
### 3) Point a client at it
|
|
|
|
For HTTP(S) traffic:
|
|
|
|
```bash
|
|
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`):
|
|
|
|
```bash
|
|
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-allowlist`
|
|
- `blocked-by-denylist`
|
|
- `blocked-by-method-policy`
|
|
- `blocked-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:
|
|
|
|
```rust
|
|
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 (`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 (`domain` and `non_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).
|
|
|
|
Common fields:
|
|
|
|
- `event.name`
|
|
- `event.timestamp` (RFC3339 UTC, millisecond precision)
|
|
- optional metadata:
|
|
- `conversation.id`
|
|
- `app.version`
|
|
- `user.account_id`
|
|
- policy/network:
|
|
- `network.policy.scope` (`domain` or `non_domain`)
|
|
- `network.policy.decision` (`allow`, `deny`, or `ask`)
|
|
- `network.policy.source` (`baseline_policy`, `mode_guard`, `proxy_state`, `decider`)
|
|
- `network.policy.reason`
|
|
- `network.transport.protocol`
|
|
- `server.address`
|
|
- `server.port`
|
|
- `http.request.method` (defaults to `"none"` when absent)
|
|
- `client.address` (defaults to `"unknown"` when absent)
|
|
- `network.policy.override` (`true` only when decider-allow overrides baseline `not_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-socket` header 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 `domains` has no `allow` entries, requests are blocked until an allowlist is configured.
|
|
- Domain patterns: exact hosts are supported, `*.example.com` matches subdomains only, and `**.example.com` matches the apex plus subdomains; the global `*` wildcard is only accepted when explicitly enabled for allowlist compilation and is otherwise rejected.
|
|
- Deny wins: `domains` entries marked `deny` always 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 (or `localhost`)
|
|
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`, and `OPTIONS` are allowed
|
|
- HTTPS `CONNECT` remains a tunnel; limited-mode method enforcement does not apply to HTTPS
|
|
- Listener safety defaults:
|
|
- the HTTP proxy listener clamps non-loopback binds unless explicitly enabled via
|
|
`dangerously_allow_non_loopback_proxy`
|
|
- 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 = true` bypasses the unix socket allowlist entirely (still
|
|
macOS-only and absolute-path-only). Use only in tightly controlled environments.
|
|
- `enabled` is 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).
|