Refactor network approvals to host/protocol/port scope (#12140)

## Summary
Simplify network approvals by removing per-attempt proxy correlation and
moving to session-level approval dedupe keyed by (host, protocol, port).
Instead of encoding attempt IDs into proxy credentials/URLs, we now
treat approvals as a destination policy decision.

- Concurrent calls to the same destination share one approval prompt.
- Different destinations (or same host on different ports) get separate
prompts.
- Allow once approves the current queued request group only.
- Allow for session caches that (host, protocol, port) and auto-allows
future matching requests.
- Never policy continues to deny without prompting.

Example:
- 3 calls: 
  - a.com (line 443)
  - b.com (line 443)
  - a.com (line 443)
=> 2 prompts total (a, b), second a waits on the first decision.
- a.com:80 is treated separately from a.com line 443

## Testing
- `just fmt` (in `codex-rs`)
- `cargo test -p codex-core tools::network_approval::tests`
- `cargo test -p codex-core` (unit tests pass; existing
integration-suite failures remain in this environment)
This commit is contained in:
viyatb-oai
2026-02-20 10:39:55 -08:00
committed by GitHub
parent 41f15bf07b
commit e8afaed502
40 changed files with 570 additions and 739 deletions

View File

@@ -14,7 +14,6 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-utils-absolute-path = { workspace = true }
codex-utils-rustls-provider = { workspace = true }

View File

@@ -1,5 +1,4 @@
use crate::config::NetworkMode;
use crate::metadata::attempt_id_from_proxy_authorization;
use crate::network_policy::NetworkDecision;
use crate::network_policy::NetworkDecisionSource;
use crate::network_policy::NetworkPolicyDecider;
@@ -160,8 +159,6 @@ async fn http_connect_accept(
}
let client = client_addr(&req);
let network_attempt_id = request_network_attempt_id(&req);
let enabled = app_state
.enabled()
.await
@@ -188,7 +185,6 @@ async fn http_connect_accept(
method: Some("CONNECT".to_string()),
command: None,
exec_policy_hint: None,
attempt_id: network_attempt_id.clone(),
});
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
@@ -213,7 +209,6 @@ async fn http_connect_accept(
method: Some("CONNECT".to_string()),
mode: None,
protocol: "http-connect".to_string(),
attempt_id: network_attempt_id.clone(),
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(authority.port),
@@ -255,7 +250,6 @@ async fn http_connect_accept(
method: Some("CONNECT".to_string()),
mode: Some(NetworkMode::Limited),
protocol: "http-connect".to_string(),
attempt_id: network_attempt_id,
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(authority.port),
@@ -374,8 +368,6 @@ async fn http_plain_proxy(
}
};
let client = client_addr(&req);
let network_attempt_id = request_network_attempt_id(&req);
let method_allowed = match app_state
.method_allowed(req.method().as_str())
.await
@@ -504,7 +496,6 @@ async fn http_plain_proxy(
method: Some(req.method().as_str().to_string()),
command: None,
exec_policy_hint: None,
attempt_id: network_attempt_id.clone(),
});
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
@@ -529,7 +520,6 @@ async fn http_plain_proxy(
method: Some(req.method().as_str().to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: network_attempt_id.clone(),
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
@@ -563,7 +553,6 @@ async fn http_plain_proxy(
method: Some(req.method().as_str().to_string()),
mode: Some(NetworkMode::Limited),
protocol: "http".to_string(),
attempt_id: network_attempt_id,
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
@@ -645,12 +634,6 @@ fn client_addr<T: ExtensionsRef>(input: &T) -> Option<String> {
.map(|info| info.peer_addr().to_string())
}
fn request_network_attempt_id(req: &Request) -> Option<String> {
// Some HTTP stacks normalize proxy credentials into `authorization`; accept both.
attempt_id_from_proxy_authorization(req.headers().get("proxy-authorization"))
.or_else(|| attempt_id_from_proxy_authorization(req.headers().get("authorization")))
}
fn remove_hop_by_hop_request_headers(headers: &mut HeaderMap) {
while let Some(raw_connection) = headers.get(header::CONNECTION).cloned() {
headers.remove(header::CONNECTION);
@@ -738,7 +721,6 @@ async fn proxy_disabled_response(
method,
mode: None,
protocol: protocol.as_policy_protocol().to_string(),
attempt_id: None,
decision: Some("deny".to_string()),
source: Some("proxy_state".to_string()),
port: Some(port),
@@ -796,8 +778,6 @@ mod tests {
use crate::config::NetworkMode;
use crate::config::NetworkProxySettings;
use crate::runtime::network_proxy_state_for_policy;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use pretty_assertions::assert_eq;
use rama_http::Method;
use rama_http::Request;
@@ -873,36 +853,6 @@ mod tests {
);
}
#[test]
fn request_network_attempt_id_reads_proxy_authorization_header() {
let encoded = STANDARD.encode("codex-net-attempt-attempt-1:");
let req = Request::builder()
.method(Method::GET)
.uri("http://example.com")
.header("proxy-authorization", format!("Basic {encoded}"))
.body(Body::empty())
.unwrap();
assert_eq!(
request_network_attempt_id(&req),
Some("attempt-1".to_string())
);
}
#[test]
fn request_network_attempt_id_reads_authorization_header_fallback() {
let encoded = STANDARD.encode("codex-net-attempt-attempt-2:");
let req = Request::builder()
.method(Method::GET)
.uri("http://example.com")
.header("authorization", format!("Basic {encoded}"))
.body(Body::empty())
.unwrap();
assert_eq!(
request_network_attempt_id(&req),
Some("attempt-2".to_string())
);
}
#[test]
fn remove_hop_by_hop_request_headers_keeps_forwarding_headers() {
let mut headers = HeaderMap::new();

View File

@@ -3,7 +3,6 @@
mod admin;
mod config;
mod http_proxy;
mod metadata;
mod network_policy;
mod policy;
mod proxy;

View File

@@ -1,50 +0,0 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use rama_http::HeaderValue;
pub const NETWORK_ATTEMPT_USERNAME_PREFIX: &str = "codex-net-attempt-";
pub fn proxy_username_for_attempt_id(attempt_id: &str) -> String {
format!("{NETWORK_ATTEMPT_USERNAME_PREFIX}{attempt_id}")
}
pub fn attempt_id_from_proxy_authorization(header: Option<&HeaderValue>) -> Option<String> {
let header = header?;
let raw = header.to_str().ok()?;
let encoded = raw.strip_prefix("Basic ")?;
let decoded = STANDARD.decode(encoded.trim()).ok()?;
let decoded = String::from_utf8(decoded).ok()?;
let username = decoded
.split_once(':')
.map(|(user, _)| user)
.unwrap_or(decoded.as_str());
let attempt_id = username.strip_prefix(NETWORK_ATTEMPT_USERNAME_PREFIX)?;
if attempt_id.is_empty() {
None
} else {
Some(attempt_id.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
#[test]
fn parses_attempt_id_from_proxy_authorization_header() {
let encoded = STANDARD.encode(format!("{NETWORK_ATTEMPT_USERNAME_PREFIX}abc123:"));
let header = HeaderValue::from_str(&format!("Basic {encoded}")).unwrap();
assert_eq!(
attempt_id_from_proxy_authorization(Some(&header)),
Some("abc123".to_string())
);
}
#[test]
fn ignores_non_attempt_proxy_authorization_header() {
let encoded = STANDARD.encode("normal-user:password");
let header = HeaderValue::from_str(&format!("Basic {encoded}")).unwrap();
assert_eq!(attempt_id_from_proxy_authorization(Some(&header)), None);
}
}

View File

@@ -71,7 +71,6 @@ pub struct NetworkPolicyRequest {
pub method: Option<String>,
pub command: Option<String>,
pub exec_policy_hint: Option<String>,
pub attempt_id: Option<String>,
}
pub struct NetworkPolicyRequestArgs {
@@ -82,7 +81,6 @@ pub struct NetworkPolicyRequestArgs {
pub method: Option<String>,
pub command: Option<String>,
pub exec_policy_hint: Option<String>,
pub attempt_id: Option<String>,
}
impl NetworkPolicyRequest {
@@ -95,7 +93,6 @@ impl NetworkPolicyRequest {
method,
command,
exec_policy_hint,
attempt_id,
} = args;
Self {
protocol,
@@ -105,7 +102,6 @@ impl NetworkPolicyRequest {
method,
command,
exec_policy_hint,
attempt_id,
}
}
}
@@ -258,7 +254,6 @@ mod tests {
method: Some("GET".to_string()),
command: None,
exec_policy_hint: None,
attempt_id: None,
});
let decision = evaluate_host_policy(&state, Some(&decider), &request)
@@ -292,7 +287,6 @@ mod tests {
method: Some("GET".to_string()),
command: None,
exec_policy_hint: None,
attempt_id: None,
});
let decision = evaluate_host_policy(&state, Some(&decider), &request)
@@ -333,7 +327,6 @@ mod tests {
method: Some("GET".to_string()),
command: None,
exec_policy_hint: None,
attempt_id: None,
});
let decision = evaluate_host_policy(&state, Some(&decider), &request)

View File

@@ -1,7 +1,6 @@
use crate::admin;
use crate::config;
use crate::http_proxy;
use crate::metadata::proxy_username_for_attempt_id;
use crate::network_policy::NetworkPolicyDecider;
use crate::runtime::BlockedRequestObserver;
use crate::runtime::unix_socket_permissions_supported;
@@ -338,12 +337,8 @@ fn apply_proxy_env_overrides(
socks_addr: SocketAddr,
socks_enabled: bool,
allow_local_binding: bool,
network_attempt_id: Option<&str>,
) {
let http_proxy_url = network_attempt_id
.map(proxy_username_for_attempt_id)
.map(|username| format!("http://{username}@{http_addr}"))
.unwrap_or_else(|| format!("http://{http_addr}"));
let http_proxy_url = format!("http://{http_addr}");
let socks_proxy_url = format!("socks5h://{socks_addr}");
env.insert(
ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
@@ -390,9 +385,7 @@ fn apply_proxy_env_overrides(
// Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if
// those vars contain SOCKS URLs. We only switch ALL_PROXY here.
//
// For attempt-scoped runs, point ALL_PROXY at the HTTP proxy URL so the
// attempt metadata survives in proxy credentials for correlation.
if socks_enabled && network_attempt_id.is_none() {
if socks_enabled {
set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url);
set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url);
} else {
@@ -427,14 +420,6 @@ impl NetworkProxy {
}
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
self.apply_to_env_for_attempt(env, None);
}
pub fn apply_to_env_for_attempt(
&self,
env: &mut HashMap<String, String>,
network_attempt_id: Option<&str>,
) {
// Enforce proxying for child processes. We intentionally override existing values so
// command-level environment cannot bypass the managed proxy endpoint.
apply_proxy_env_overrides(
@@ -443,7 +428,6 @@ impl NetworkProxy {
self.socks_addr,
self.socks_enabled,
self.allow_local_binding,
network_attempt_id,
);
}
@@ -751,7 +735,6 @@ mod tests {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
true,
false,
None,
);
assert_eq!(
@@ -802,7 +785,6 @@ mod tests {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
false,
true,
None,
);
assert_eq!(
@@ -813,7 +795,7 @@ mod tests {
}
#[test]
fn apply_proxy_env_overrides_embeds_attempt_id_in_http_proxy_url() {
fn apply_proxy_env_overrides_uses_plain_http_proxy_url() {
let mut env = HashMap::new();
apply_proxy_env_overrides(
&mut env,
@@ -821,28 +803,27 @@ mod tests {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
true,
false,
Some("attempt-123"),
);
assert_eq!(
env.get("HTTP_PROXY"),
Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string())
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("HTTPS_PROXY"),
Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string())
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WS_PROXY"),
Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string())
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WSS_PROXY"),
Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string())
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("ALL_PROXY"),
Some(&"http://codex-net-attempt-attempt-123@127.0.0.1:3128".to_string())
Some(&"socks5h://127.0.0.1:8081".to_string())
);
#[cfg(target_os = "macos")]
assert_eq!(
@@ -867,7 +848,6 @@ mod tests {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
true,
false,
None,
);
assert_eq!(

View File

@@ -75,8 +75,6 @@ pub struct BlockedRequest {
pub mode: Option<NetworkMode>,
pub protocol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub attempt_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub decision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
@@ -92,7 +90,6 @@ pub struct BlockedRequestArgs {
pub method: Option<String>,
pub mode: Option<NetworkMode>,
pub protocol: String,
pub attempt_id: Option<String>,
pub decision: Option<String>,
pub source: Option<String>,
pub port: Option<u16>,
@@ -107,7 +104,6 @@ impl BlockedRequest {
method,
mode,
protocol,
attempt_id,
decision,
source,
port,
@@ -119,7 +115,6 @@ impl BlockedRequest {
method,
mode,
protocol,
attempt_id,
decision,
source,
port,
@@ -365,7 +360,6 @@ impl NetworkProxyState {
let source = entry.source.clone();
let protocol = entry.protocol.clone();
let port = entry.port;
let attempt_id = entry.attempt_id.clone();
guard.blocked.push_back(entry);
guard.blocked_total = guard.blocked_total.saturating_add(1);
let total = guard.blocked_total;
@@ -373,7 +367,7 @@ impl NetworkProxyState {
guard.blocked.pop_front();
}
debug!(
"recorded blocked request telemetry (total={}, host={}, reason={}, decision={:?}, source={:?}, protocol={}, port={:?}, attempt_id={:?}, buffered={})",
"recorded blocked request telemetry (total={}, host={}, reason={}, decision={:?}, source={:?}, protocol={}, port={:?}, buffered={})",
total,
host,
reason,
@@ -381,7 +375,6 @@ impl NetworkProxyState {
source,
protocol,
port,
attempt_id,
guard.blocked.len()
);
debug!("{violation_line}");
@@ -700,7 +693,6 @@ mod tests {
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: None,
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
@@ -741,7 +733,6 @@ mod tests {
method: Some("GET".to_string()),
mode: None,
protocol: "http".to_string(),
attempt_id: None,
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
@@ -764,7 +755,6 @@ mod tests {
method: Some("GET".to_string()),
mode: Some(NetworkMode::Full),
protocol: "http".to_string(),
attempt_id: Some("attempt-1".to_string()),
decision: Some("ask".to_string()),
source: Some("decider".to_string()),
port: Some(80),
@@ -773,7 +763,7 @@ mod tests {
assert_eq!(
blocked_request_violation_log_line(&entry),
r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","attempt_id":"attempt-1","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"#
r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"#
);
}

View File

@@ -168,7 +168,6 @@ async fn handle_socks5_tcp(
method: None,
mode: None,
protocol: "socks5".to_string(),
attempt_id: None,
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
@@ -202,7 +201,6 @@ async fn handle_socks5_tcp(
method: None,
mode: Some(NetworkMode::Limited),
protocol: "socks5".to_string(),
attempt_id: None,
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
@@ -229,7 +227,6 @@ async fn handle_socks5_tcp(
method: None,
command: None,
exec_policy_hint: None,
attempt_id: None,
});
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
@@ -254,7 +251,6 @@ async fn handle_socks5_tcp(
method: None,
mode: None,
protocol: "socks5".to_string(),
attempt_id: None,
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
@@ -318,7 +314,6 @@ async fn inspect_socks5_udp(
method: None,
mode: None,
protocol: "socks5-udp".to_string(),
attempt_id: None,
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
@@ -352,7 +347,6 @@ async fn inspect_socks5_udp(
method: None,
mode: Some(NetworkMode::Limited),
protocol: "socks5-udp".to_string(),
attempt_id: None,
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),
@@ -375,7 +369,6 @@ async fn inspect_socks5_udp(
method: None,
command: None,
exec_policy_hint: None,
attempt_id: None,
});
match evaluate_host_policy(&state, policy_decider.as_ref(), &request).await {
@@ -400,7 +393,6 @@ async fn inspect_socks5_udp(
method: None,
mode: None,
protocol: "socks5-udp".to_string(),
attempt_id: None,
decision: Some(details.decision.as_str().to_string()),
source: Some(details.source.as_str().to_string()),
port: Some(port),