Files
codex/codex-rs/linux-sandbox/src/proxy_routing.rs
T
1013295c2d fix: attribut network requests to the exact exec on linux (#29697)
## Why

Managed-network commands within one Codex conversation share the same
HTTP and SOCKS proxy ingress. When several exec calls run concurrently,
the proxy sees the requested destination but cannot tell which exec
opened the connection.

For example:

```text
exec A: curl https://example.com/a ─┐
                                    ├─> conversation proxy ─> Guardian
exec B: curl https://example.com/b ─┘                        host: example.com
                                                               trigger: unknown
```. Three parallel network execs reached Guardian without their
triggering call IDs or commands. Guardian denied the requests, but Codex
could not safely associate those outcomes with the individual tool
calls.

## What changes

Keep the shared proxy ingress and tag each connection at the existing
trusted Linux bridge:

```text
exec A ─> existing Linux bridge ─> [token A][proxy bytes] ─┐
                                                           ├─> shared HTTP/SOCKS ingress
exec B ─> existing Linux bridge ─> [token B][proxy bytes] ─┘
                                                                    │
                                              token A ─> exec A ─────┤
                                              token B ─> exec B ─────┘
```

The complete path is:

```text
active exec registration
        │
        ├─ registers its UUID as a short-lived attribution token
        ├─ passes the token to the Linux sandbox helper
        ├─ helper removes the token before launching the user command
        ├─ existing host bridge prepends the token to each proxy connection
        ├─ shared proxy consumes the bounded attribution frame
        └─ proxy attaches the matching execution-scoped state
                ├─ Guardian receives the exact call ID and command
                └─ a denial finishes/cancels the matching tool call
```

Dropping the active or deferred exec registration removes the token.
Connections that were already accepted retain their resolved
attribution; new connections using an expired token fail closed.

## Before and after

Before, Guardian could receive only the network destination:

```json
{
  "tool": "network_access",
  "host": "www.17track.net",
  "port": 443,
  "protocol": "https"
}
```

After, the same request includes the action that caused it:

```json
{
  "tool": "network_access",
  "host": "www.17track.net",
  "port": 443,
  "protocol": "https",
  "trigger": {
    "callId": "exec-network-first",
    "command": ["/bin/sh", "-c", "curl https://www.17track.net"]
  }
}
```

## Listener accounting

This PR does **not** create proxy listeners per exec.

```text
Existing topology:
  one conversation -> one HTTP listener + optional one SOCKS listener

Discarded per-exec approach:
  one conversation -> existing listener pair
                   + up to one additional listener pair per active exec

This PR:
  one conversation -> existing listener pair only
                   + one small token-map entry per active exec
```

The Linux sandbox already creates a trusted routing bridge for each
sandboxed command. This PR adds a short frame write to that bridge
rather than introducing another listener, task, or proxy process.

The existing conversation-scoped listener pair remains. Making a single
proxy service shared across multiple conversations would be a separate
multi-tenant architecture change involving per-conversation policy,
configuration, audit, and Guardian routing.

## Keeping the implementation small

The attribution is bound once, when the TCP connection enters the proxy.
The ingress installs an execution-scoped clone of the existing
`NetworkProxyState`, so the established HTTP, SOCKS, MITM, policy,
audit, and blocked-request paths continue using their existing state
lookup.

This avoids plumbing a new request-context type through every protocol
handler. Outside the two ingress wrappers, protocol-specific request
handling is unchanged.

## Security behavior

- Tokens are generated from the existing random execution registration
IDs.
- The trusted Linux helper consumes and removes the token before
executing user code.
- Attribution frames have a fixed magic prefix, bounded token length,
and bounded read timeout.
- Unknown or expired tokens close the connection.
- A token presented to a proxy for another environment closes the
connection.
- Existing unframed callers preserve the current conservative
attribution behavior.

## Platform scope

Exact bridge attribution is enabled on Linux. macOS and Windows retain
their current shared-proxy behavior.

## Test coverage

The concurrent end-to-end test starts two managed-network execs together
and synchronizes them so both are active before either connects. It then
inspects the two Guardian requests and compares the complete attribution
pairs:

```text
(exec-network-first,  exact first command)
(exec-network-second, exact second command)
```

Focused proxy coverage verifies the bounded frame and that a registered
framed connection receives the matching execution and environment state.

## Scope

This fixes the Linux network-to-exec attribution path and records a
denial against the exact matching tool call. It intentionally does not
change:

- delivery of an entirely unattributed denial to the parent turn;
- how parallel denials count toward the Guardian circuit breaker;
- how the UI displays the rejection reason or completed-turn state.

Those remain separate concerns from attribution.

## Relationship to #29456 and #29668

#29456 made the proxy environment and sandbox policy come from the same
prepared network context. This PR adds the execution token to that
prepared launch and consumes it at the shared ingress.

This follows #29668's shared-ingress framing direction, but completes
the production registration, Linux bridge, core call mapping, denial
mapping, and concurrent end-to-end path. It also keeps attribution in
the existing per-connection proxy state instead of introducing
request-context plumbing through every HTTP, SOCKS, and MITM handler.
This PR is intended to supersede #29668 for the Linux attribution fix.

---------

Co-authored-by: viyatb-oai <viyatb@openai.com>
Co-authored-by: Codex <noreply@openai.com>
2026-07-06 14:51:34 -07:00

909 lines
28 KiB
Rust

use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
use codex_network_proxy::write_attribution_frame;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fs::DirBuilder;
use std::fs::File;
use std::fs::Permissions;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::net::TcpStream;
use std::os::fd::FromRawFd;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::DirBuilderExt;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixListener;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use url::Url;
const PROXY_ENV_KEYS: &[&str] = &[
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"FTP_PROXY",
"YARN_HTTP_PROXY",
"YARN_HTTPS_PROXY",
"NPM_CONFIG_HTTP_PROXY",
"NPM_CONFIG_HTTPS_PROXY",
"NPM_CONFIG_PROXY",
"BUNDLE_HTTP_PROXY",
"BUNDLE_HTTPS_PROXY",
"PIP_PROXY",
"DOCKER_HTTP_PROXY",
"DOCKER_HTTPS_PROXY",
];
const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-";
const HOST_BRIDGE_READY: u8 = 1;
const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo";
// Linux sockaddr_un.sun_path allows 108 bytes, including the trailing NUL.
const UNIX_SOCKET_PATH_MAX_BYTES: usize = 107;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ProxyRouteSpec {
routes: Vec<ProxyRouteEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct ProxyRouteEntry {
env_key: String,
uds_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PlannedProxyRoute {
env_key: String,
endpoint: SocketAddr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProxyRoutePlan {
routes: Vec<PlannedProxyRoute>,
has_proxy_config: bool,
}
pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<String> {
let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect());
// SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or
// executes the user command.
unsafe {
std::env::remove_var(PROXY_ATTRIBUTION_TOKEN_ENV_KEY);
}
if plan.routes.is_empty() {
let message = if plan.has_proxy_config {
"managed proxy mode requires parseable loopback proxy endpoints"
} else {
"managed proxy mode requires proxy environment variables"
};
return Err(io::Error::new(io::ErrorKind::InvalidInput, message));
}
let socket_parent_dir = proxy_socket_parent_dir();
let _ = cleanup_stale_proxy_socket_dirs_in(socket_parent_dir.as_path());
let socket_dir = create_proxy_socket_dir()?;
let mut socket_by_endpoint: BTreeMap<SocketAddr, PathBuf> = BTreeMap::new();
let mut next_index = 0usize;
for route in &plan.routes {
if socket_by_endpoint.contains_key(&route.endpoint) {
continue;
}
let socket_path = socket_dir.join(format!("proxy-route-{next_index}.sock"));
next_index += 1;
socket_by_endpoint.insert(route.endpoint, socket_path);
}
let mut host_bridge_pids = Vec::with_capacity(socket_by_endpoint.len());
for (endpoint, socket_path) in &socket_by_endpoint {
host_bridge_pids.push(spawn_host_bridge(
*endpoint,
socket_path,
attribution_token.as_deref(),
)?);
}
spawn_proxy_socket_dir_cleanup_worker(socket_dir, host_bridge_pids)?;
let mut routes = Vec::with_capacity(plan.routes.len());
for route in plan.routes {
let Some(uds_path) = socket_by_endpoint.get(&route.endpoint) else {
return Err(io::Error::other(format!(
"missing UDS path for endpoint {}",
route.endpoint
)));
};
routes.push(ProxyRouteEntry {
env_key: route.env_key,
uds_path: uds_path.clone(),
});
}
serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other)
}
fn extract_attribution_token_and_plan(
mut env: HashMap<String, String>,
) -> (Option<String>, ProxyRoutePlan) {
let attribution_token = env.remove(PROXY_ATTRIBUTION_TOKEN_ENV_KEY);
let plan = plan_proxy_routes(&env);
(attribution_token, plan)
}
pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> {
let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?;
if spec.routes.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"proxy routing spec contained no routes",
));
}
let mut local_port_by_uds_path: BTreeMap<PathBuf, u16> = BTreeMap::new();
for route in &spec.routes {
if local_port_by_uds_path.contains_key(&route.uds_path) {
continue;
}
let local_port = spawn_local_bridge(route.uds_path.as_path())?;
local_port_by_uds_path.insert(route.uds_path.clone(), local_port);
}
for route in spec.routes {
let Some(local_port) = local_port_by_uds_path.get(&route.uds_path) else {
return Err(io::Error::other(format!(
"missing local bridge port for UDS path {}",
route.uds_path.display()
)));
};
let original_value = std::env::var(&route.env_key).map_err(|_| {
io::Error::new(
io::ErrorKind::NotFound,
format!("missing proxy env key {}", route.env_key),
)
})?;
let Some(rewritten) = rewrite_proxy_env_value(&original_value, *local_port) else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("could not rewrite proxy URL for env key {}", route.env_key),
));
};
// SAFETY: this helper process is single-threaded at this point, and
// env mutation happens before execing the user command.
unsafe {
std::env::set_var(route.env_key, rewritten);
}
}
Ok(())
}
fn plan_proxy_routes(env: &HashMap<String, String>) -> ProxyRoutePlan {
let mut routes = Vec::new();
let mut has_proxy_config = false;
for (key, value) in env {
if !is_proxy_env_key(key) {
continue;
}
let trimmed = value.trim();
if trimmed.is_empty() {
continue;
}
has_proxy_config = true;
let Some(endpoint) = parse_loopback_proxy_endpoint(trimmed) else {
continue;
};
routes.push(PlannedProxyRoute {
env_key: key.clone(),
endpoint,
});
}
routes.sort_by(|left, right| left.env_key.cmp(&right.env_key));
ProxyRoutePlan {
routes,
has_proxy_config,
}
}
fn is_proxy_env_key(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
PROXY_ENV_KEYS.contains(&upper.as_str())
}
fn parse_loopback_proxy_endpoint(proxy_url: &str) -> Option<SocketAddr> {
let candidate = if proxy_url.contains("://") {
proxy_url.to_string()
} else {
format!("http://{proxy_url}")
};
let parsed = Url::parse(&candidate).ok()?;
let host = parsed.host_str()?;
if !is_loopback_host(host) {
return None;
}
let scheme = parsed.scheme().to_ascii_lowercase();
let port = parsed
.port()
.unwrap_or_else(|| default_proxy_port(scheme.as_str()));
if port == 0 {
return None;
}
let ip = if host.eq_ignore_ascii_case("localhost") {
IpAddr::V4(Ipv4Addr::LOCALHOST)
} else {
host.parse::<IpAddr>().ok()?
};
if ip.is_loopback() {
Some(SocketAddr::new(ip, port))
} else {
None
}
}
fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
fn default_proxy_port(scheme: &str) -> u16 {
match scheme {
"https" => 443,
"socks5" | "socks5h" | "socks4" | "socks4a" => 1080,
_ => 80,
}
}
fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option<String> {
let had_scheme = proxy_url.contains("://");
let candidate = if had_scheme {
proxy_url.to_string()
} else {
format!("http://{proxy_url}")
};
let mut parsed = Url::parse(&candidate).ok()?;
parsed.set_host(Some("127.0.0.1")).ok()?;
parsed.set_port(Some(local_port)).ok()?;
let mut rewritten = parsed.to_string();
if !had_scheme {
rewritten = rewritten
.strip_prefix("http://")
.unwrap_or(rewritten.as_str())
.to_string();
}
if !proxy_url.ends_with('/')
&& !proxy_url.contains('?')
&& !proxy_url.contains('#')
&& rewritten.ends_with('/')
{
rewritten.pop();
}
Some(rewritten)
}
fn create_proxy_socket_dir() -> io::Result<PathBuf> {
let temp_dir = proxy_socket_parent_dir();
let pid = std::process::id();
let uid = unsafe { libc::geteuid() };
for attempt in 0..128 {
let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{uid}-{attempt}"));
// The bridge UDS paths live under a shared temp root, so the per-run
// directory should not be traversable by other processes.
let mut dir_builder = DirBuilder::new();
dir_builder.mode(0o700);
match dir_builder.create(&candidate) {
Ok(()) => return Ok(candidate),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(err),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"failed to allocate proxy routing temp dir under {}",
temp_dir.display()
),
))
}
fn proxy_socket_parent_dir() -> PathBuf {
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
let candidate = PathBuf::from(codex_home).join("tmp");
if proxy_socket_paths_fit(candidate.as_path())
&& ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok()
{
return candidate;
}
}
let temp_dir = std::env::temp_dir();
if proxy_socket_paths_fit(temp_dir.as_path()) {
temp_dir
} else {
PathBuf::from("/tmp")
}
}
fn proxy_socket_paths_fit(parent: &Path) -> bool {
let socket_path = parent
.join(format!(
"{PROXY_SOCKET_DIR_PREFIX}{}-{}-127",
u32::MAX,
libc::uid_t::MAX
))
.join(format!("proxy-route-{}.sock", usize::MAX));
socket_path.as_os_str().as_bytes().len() <= UNIX_SOCKET_PATH_MAX_BYTES
}
fn ensure_private_proxy_socket_parent_dir(path: &Path) -> io::Result<()> {
let mut dir_builder = DirBuilder::new();
dir_builder.recursive(true);
dir_builder.mode(0o700);
match dir_builder.create(path) {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err),
}
std::fs::set_permissions(path, Permissions::from_mode(0o700))
}
fn cleanup_stale_proxy_socket_dirs_in(temp_dir: &Path) -> io::Result<()> {
for entry in std::fs::read_dir(temp_dir)? {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if !file_type.is_dir() {
continue;
}
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
let Some(owner_pid) = parse_proxy_socket_dir_owner_pid(file_name.as_ref()) else {
continue;
};
if is_pid_alive(owner_pid) {
continue;
}
let _ = cleanup_proxy_socket_dir(entry.path().as_path());
}
Ok(())
}
fn parse_proxy_socket_dir_owner_pid(file_name: &str) -> Option<u32> {
let suffix = file_name.strip_prefix(PROXY_SOCKET_DIR_PREFIX)?;
let (pid_raw, _) = suffix.split_once('-')?;
pid_raw.parse::<u32>().ok().filter(|pid| *pid != 0)
}
fn is_pid_alive(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
is_pid_alive_raw(pid)
}
fn is_pid_alive_raw(pid: libc::pid_t) -> bool {
let status = unsafe { libc::kill(pid, 0) };
if status == 0 {
return true;
}
let err = io::Error::last_os_error();
!matches!(err.raw_os_error(), Some(libc::ESRCH))
}
fn spawn_proxy_socket_dir_cleanup_worker(
socket_dir: PathBuf,
host_bridge_pids: Vec<libc::pid_t>,
) -> io::Result<()> {
let pid = unsafe { libc::fork() };
if pid < 0 {
return Err(io::Error::last_os_error());
}
if pid == 0 {
loop {
if host_bridge_pids
.iter()
.copied()
.all(|bridge_pid| !is_pid_alive_raw(bridge_pid))
{
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = cleanup_proxy_socket_dir(socket_dir.as_path());
unsafe { libc::_exit(0) };
}
Ok(())
}
fn cleanup_proxy_socket_dir(socket_dir: &Path) -> io::Result<()> {
for _ in 0..20 {
match std::fs::remove_dir_all(socket_dir) {
Ok(()) => return Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(_) => std::thread::sleep(Duration::from_millis(100)),
}
}
match std::fs::remove_dir_all(socket_dir) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
fn spawn_host_bridge(
endpoint: SocketAddr,
uds_path: &Path,
attribution_token: Option<&str>,
) -> io::Result<libc::pid_t> {
let (read_fd, write_fd) = create_ready_pipe()?;
let pid = unsafe { libc::fork() };
if pid < 0 {
let err = io::Error::last_os_error();
close_fd(read_fd)?;
close_fd(write_fd)?;
return Err(err);
}
if pid == 0 {
if close_fd(read_fd).is_err() {
unsafe { libc::_exit(1) };
}
let result = run_host_bridge(endpoint, uds_path, write_fd, attribution_token);
if result.is_err() {
unsafe { libc::_exit(1) };
}
unsafe { libc::_exit(0) };
}
close_fd(write_fd)?;
let mut ready = [0_u8; 1];
let mut read_file = unsafe { File::from_raw_fd(read_fd) };
read_file.read_exact(&mut ready)?;
if ready[0] != HOST_BRIDGE_READY {
return Err(io::Error::other(
"host bridge did not acknowledge readiness",
));
}
Ok(pid)
}
fn run_host_bridge(
endpoint: SocketAddr,
uds_path: &Path,
ready_fd: libc::c_int,
attribution_token: Option<&str>,
) -> io::Result<()> {
harden_bridge_process()?;
if uds_path.exists() {
std::fs::remove_file(uds_path)?;
}
let listener = UnixListener::bind(uds_path)?;
let mut ready_file = unsafe { File::from_raw_fd(ready_fd) };
ready_file.write_all(&[HOST_BRIDGE_READY])?;
drop(ready_file);
let attribution_token = attribution_token.map(str::to_owned);
loop {
let (unix_stream, _) = listener.accept()?;
let attribution_token = attribution_token.clone();
std::thread::spawn(move || {
let mut tcp_stream = match TcpStream::connect(endpoint) {
Ok(stream) => stream,
Err(_) => return,
};
if let Some(attribution_token) = attribution_token
&& write_attribution_frame(&mut tcp_stream, &attribution_token).is_err()
{
// The shared ingress must reject unauthenticated connections; do not forward
// application bytes if this bridge cannot prove the exec attribution first.
return;
}
let _ = proxy_bidirectional(tcp_stream, unix_stream);
});
}
}
fn spawn_local_bridge(uds_path: &Path) -> io::Result<u16> {
let (read_fd, write_fd) = create_ready_pipe()?;
let pid = unsafe { libc::fork() };
if pid < 0 {
let err = io::Error::last_os_error();
close_fd(read_fd)?;
close_fd(write_fd)?;
return Err(err);
}
if pid == 0 {
if close_fd(read_fd).is_err() {
unsafe { libc::_exit(1) };
}
let result = run_local_bridge(uds_path, write_fd);
if result.is_err() {
unsafe { libc::_exit(1) };
}
unsafe { libc::_exit(0) };
}
close_fd(write_fd)?;
let mut port_bytes = [0_u8; 2];
let mut read_file = unsafe { File::from_raw_fd(read_fd) };
read_file.read_exact(&mut port_bytes)?;
Ok(u16::from_be_bytes(port_bytes))
}
fn run_local_bridge(uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> {
harden_bridge_process()?;
let listener = bind_local_loopback_listener()?;
let port = listener.local_addr()?.port();
let mut ready_file = unsafe { File::from_raw_fd(ready_fd) };
ready_file.write_all(&port.to_be_bytes())?;
drop(ready_file);
let uds_path = uds_path.to_path_buf();
loop {
let (tcp_stream, _) = listener.accept()?;
let socket_path = uds_path.clone();
std::thread::spawn(move || {
let unix_stream = match UnixStream::connect(socket_path) {
Ok(stream) => stream,
Err(_) => return,
};
let _ = proxy_bidirectional(tcp_stream, unix_stream);
});
}
}
fn bind_local_loopback_listener() -> io::Result<TcpListener> {
match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) {
Ok(listener) => Ok(listener),
Err(bind_err) => {
let should_retry_after_lo_up = matches!(
bind_err.raw_os_error(),
Some(errno) if errno == libc::EADDRNOTAVAIL || errno == libc::ENETUNREACH
);
if !should_retry_after_lo_up {
return Err(bind_err);
}
ensure_loopback_interface_up()?;
TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
}
}
}
fn ensure_loopback_interface_up() -> io::Result<()> {
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
if fd < 0 {
return Err(io::Error::last_os_error());
}
let mut ifreq = unsafe { std::mem::zeroed::<libc::ifreq>() };
for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() {
ifreq.ifr_name[index] = byte as libc::c_char;
}
let read_flags_result =
unsafe { libc::ioctl(fd, libc::SIOCGIFFLAGS as libc::Ioctl, &mut ifreq) };
if read_flags_result < 0 {
let err = io::Error::last_os_error();
let _ = close_fd(fd);
return Err(err);
}
let current_flags = unsafe { ifreq.ifr_ifru.ifru_flags };
let up_flag = libc::IFF_UP as libc::c_short;
if (current_flags & up_flag) != up_flag {
ifreq.ifr_ifru.ifru_flags = current_flags | up_flag;
let set_flags_result =
unsafe { libc::ioctl(fd, libc::SIOCSIFFLAGS as libc::Ioctl, &ifreq) };
if set_flags_result < 0 {
let err = io::Error::last_os_error();
let _ = close_fd(fd);
return Err(err);
}
}
let mut addr_req = unsafe { std::mem::zeroed::<libc::ifreq>() };
for (index, byte) in LOOPBACK_INTERFACE_NAME.iter().copied().enumerate() {
addr_req.ifr_name[index] = byte as libc::c_char;
}
let loopback_addr = libc::sockaddr_in {
sin_family: libc::AF_INET as libc::sa_family_t,
sin_port: 0,
sin_addr: libc::in_addr {
s_addr: libc::htonl(libc::INADDR_LOOPBACK),
},
sin_zero: [0; 8],
};
unsafe {
addr_req.ifr_ifru.ifru_addr =
*(&loopback_addr as *const libc::sockaddr_in as *const libc::sockaddr);
}
let set_addr_result = unsafe { libc::ioctl(fd, libc::SIOCSIFADDR as libc::Ioctl, &addr_req) };
if set_addr_result < 0 {
let err = io::Error::last_os_error();
let allow_existing_or_immutable_addr =
matches!(err.raw_os_error(), Some(libc::EEXIST | libc::EPERM));
if !allow_existing_or_immutable_addr {
let _ = close_fd(fd);
return Err(err);
}
}
close_fd(fd)
}
fn set_parent_death_signal() -> io::Result<()> {
let res = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) };
if res != 0 {
Err(io::Error::last_os_error())
} else if unsafe { libc::getppid() } == 1 {
Err(io::Error::other("parent process already exited"))
} else {
Ok(())
}
}
fn harden_bridge_process() -> io::Result<()> {
set_parent_death_signal()?;
codex_process_hardening::disable_process_dumping()
}
fn proxy_bidirectional(mut tcp_stream: TcpStream, mut unix_stream: UnixStream) -> io::Result<()> {
let mut tcp_reader = tcp_stream.try_clone()?;
let mut unix_writer = unix_stream.try_clone()?;
let tcp_to_unix = std::thread::spawn(move || std::io::copy(&mut tcp_reader, &mut unix_writer));
let unix_to_tcp = std::io::copy(&mut unix_stream, &mut tcp_stream);
let tcp_to_unix = tcp_to_unix
.join()
.map_err(|_| io::Error::other("bridge thread panicked"))?;
tcp_to_unix?;
unix_to_tcp?;
Ok(())
}
fn create_ready_pipe() -> io::Result<(libc::c_int, libc::c_int)> {
let mut pipe_fds = [0; 2];
let res = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
if res != 0 {
return Err(io::Error::last_os_error());
}
Ok((pipe_fds[0], pipe_fds[1]))
}
fn close_fd(fd: libc::c_int) -> io::Result<()> {
let res = unsafe { libc::close(fd) };
if res < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
use super::PROXY_SOCKET_DIR_PREFIX;
use super::ProxyRouteEntry;
use super::ProxyRouteSpec;
use super::cleanup_proxy_socket_dir;
use super::cleanup_stale_proxy_socket_dirs_in;
use super::default_proxy_port;
use super::extract_attribution_token_and_plan;
use super::is_proxy_env_key;
use super::parse_loopback_proxy_endpoint;
use super::parse_proxy_socket_dir_owner_pid;
use super::plan_proxy_routes;
use super::proxy_socket_paths_fit;
use super::rewrite_proxy_env_value;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::PathBuf;
#[test]
fn recognizes_proxy_env_keys_case_insensitively() {
assert_eq!(is_proxy_env_key("HTTP_PROXY"), true);
assert_eq!(is_proxy_env_key("http_proxy"), true);
assert_eq!(is_proxy_env_key("PATH"), false);
}
#[test]
fn parses_loopback_proxy_endpoint() {
let endpoint = parse_loopback_proxy_endpoint("http://127.0.0.1:43128");
assert_eq!(
endpoint,
Some(
"127.0.0.1:43128"
.parse::<SocketAddr>()
.expect("valid socket")
)
);
}
#[test]
fn ignores_non_loopback_proxy_endpoint() {
assert_eq!(
parse_loopback_proxy_endpoint("http://example.com:3128"),
None
);
}
#[test]
fn plan_proxy_routes_only_includes_valid_loopback_endpoints() {
let mut env = HashMap::new();
env.insert(
"HTTP_PROXY".to_string(),
"http://127.0.0.1:43128".to_string(),
);
env.insert(
"HTTPS_PROXY".to_string(),
"http://example.com:3128".to_string(),
);
env.insert("PATH".to_string(), "/usr/bin".to_string());
let plan = plan_proxy_routes(&env);
assert_eq!(plan.has_proxy_config, true);
assert_eq!(plan.routes.len(), 1);
assert_eq!(plan.routes[0].env_key, "HTTP_PROXY");
assert_eq!(
plan.routes[0].endpoint,
"127.0.0.1:43128"
.parse::<SocketAddr>()
.expect("valid socket")
);
}
#[test]
fn attribution_token_is_extracted_before_proxy_route_planning() {
let mut env = HashMap::new();
env.insert(
"HTTP_PROXY".to_string(),
"http://127.0.0.1:43128".to_string(),
);
env.insert(
PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(),
"exec-token".to_string(),
);
let (attribution_token, plan) = extract_attribution_token_and_plan(env);
assert_eq!(attribution_token.as_deref(), Some("exec-token"));
assert_eq!(
plan,
super::ProxyRoutePlan {
routes: vec![super::PlannedProxyRoute {
env_key: "HTTP_PROXY".to_string(),
endpoint: "127.0.0.1:43128"
.parse::<SocketAddr>()
.expect("valid socket"),
}],
has_proxy_config: true,
}
);
}
#[test]
fn rewrites_proxy_url_to_local_loopback_port() {
let rewritten =
rewrite_proxy_env_value("socks5h://127.0.0.1:8081", /*local_port*/ 43210)
.expect("rewritten value");
assert_eq!(rewritten, "socks5h://127.0.0.1:43210");
}
#[test]
fn default_proxy_ports_match_expected_schemes() {
assert_eq!(default_proxy_port("http"), 80);
assert_eq!(default_proxy_port("https"), 443);
assert_eq!(default_proxy_port("socks5h"), 1080);
}
#[test]
fn proxy_socket_paths_enforce_linux_path_limit() {
assert_eq!(
proxy_socket_paths_fit(PathBuf::from("/tmp").as_path()),
true
);
assert_eq!(
proxy_socket_paths_fit(PathBuf::from(format!("/tmp/{}", "a".repeat(96))).as_path()),
false
);
}
#[test]
fn cleanup_proxy_socket_dir_removes_bridge_artifacts() {
let root = tempfile::tempdir().expect("tempdir should create");
let socket_dir = root.path().join("codex-linux-sandbox-proxy-test");
std::fs::create_dir(&socket_dir).expect("socket dir should create");
let marker = socket_dir.join("bridge.sock");
std::fs::write(&marker, b"test").expect("marker should write");
cleanup_proxy_socket_dir(socket_dir.as_path()).expect("cleanup should succeed");
assert_eq!(socket_dir.exists(), false);
}
#[test]
fn proxy_route_spec_serialization_omits_proxy_urls() {
let spec = ProxyRouteSpec {
routes: vec![ProxyRouteEntry {
env_key: "HTTP_PROXY".to_string(),
uds_path: PathBuf::from("/tmp/proxy-route-0.sock"),
}],
};
let serialized = serde_json::to_string(&spec).expect("proxy route spec should serialize");
assert_eq!(
serialized,
r#"{"routes":[{"env_key":"HTTP_PROXY","uds_path":"/tmp/proxy-route-0.sock"}]}"#
);
}
#[test]
fn parse_proxy_socket_dir_owner_pid_reads_owner_pid() {
assert_eq!(
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-0"),
Some(1234)
);
assert_eq!(
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-1000-0"),
Some(1234)
);
assert_eq!(
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-x"),
None
);
assert_eq!(parse_proxy_socket_dir_owner_pid("not-a-proxy-dir"), None);
}
#[test]
fn cleanup_stale_proxy_socket_dirs_removes_dead_pid_directories() {
let root = tempfile::tempdir().expect("tempdir should create");
let dead_dir = root
.path()
.join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-0", u32::MAX));
std::fs::create_dir(&dead_dir).expect("dead dir should create");
let alive_dir = root
.path()
.join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-1", std::process::id()));
std::fs::create_dir(&alive_dir).expect("alive dir should create");
let unrelated_dir = root.path().join("unrelated-proxy-dir");
std::fs::create_dir(&unrelated_dir).expect("unrelated dir should create");
cleanup_stale_proxy_socket_dirs_in(root.path()).expect("stale cleanup should succeed");
assert_eq!(dead_dir.exists(), false);
assert_eq!(alive_dir.exists(), true);
assert_eq!(unrelated_dir.exists(), true);
}
}