Harden managed proxy listener handoff (#40999)

## Why

Linux managed networking routed proxy connections through filesystem-backed Unix sockets, requiring an extra readable sandbox path and stale socket cleanup.

## What changed

- Transfer loopback TCP listeners from the sandbox namespace to host bridge processes over anonymous Unix socket pairs.
- Validate transferred descriptors, keep bootstrap channels private to each sandbox launch, and close them before running untrusted commands.
- Tie proxy bridge lifetime to the sandbox so cancellation closes active upstream connections.

## Testing

- Cover descriptor validation and interrupted handoffs.
- Verify concurrent sandboxes keep proxy endpoints isolated and do not inherit privileged sockets.
- Exercise managed networking end to end through `app-server`, confirming proxied access succeeds while direct host access remains blocked.

GitOrigin-RevId: 6561e09c83b927c604951bf2aa00ff532d179da5
This commit is contained in:
andrewgu-oai authored and copyberry committed 2026-08-26 23:41:18 +00:00
1 parent 528fd7ace5
commit f3741880f6
8 files changed
+839 -462

No files matched your search

+2
View File
@@ -3514,11 +3514,13 @@ dependencies = [
"landlock", "landlock",
"libc", "libc",
"pretty_assertions", "pretty_assertions",
"rustix 1.1.4",
"seccompiler", "seccompiler",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"tempfile", "tempfile",
"test-case",
"tokio", "tokio",
"url", "url",
] ]
@@ -1662,6 +1662,227 @@ allow_local_binding = false
Ok(()) Ok(())
} }
#[cfg(target_os = "linux")]
#[tokio::test]
async fn turn_start_enforces_managed_network_sandbox() -> Result<()> {
skip_if_remote!(
Ok(()),
"network probe uses a host-local test executable and loopback server"
);
let server = responses::start_mock_server().await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri())
.enable_feature(Feature::NetworkProxy)
.enable_feature(Feature::UnifiedExec)
.write(codex_home.path())?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
default_permissions = "managed-network"
[allowed_permission_profiles]
managed-network = true
":read-only" = true
[permissions.managed-network]
extends = ":read-only"
[permissions.managed-network.network]
enabled = true
allow_local_binding = false
allow_upstream_proxy = false
[permissions.managed-network.network.domains]
"127.0.0.2" = "allow"
"#,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = app_server
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
// A different loopback address avoids confusing the host's target port with
// a coincidentally equal 127.0.0.1 listener port inside the sandbox namespace.
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await?;
let target = listener.local_addr()?;
let router = axum::Router::new().route(
"/sandbox",
axum::routing::get(|| async { "sandbox proxy response" }),
);
// Keep the real origin listening throughout both the proxied and direct
// requests, and clean it up even if an assertion fails.
let _origin = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
axum::serve(listener, router).await
}));
let test_executable = std::env::current_exe()?;
let test_executable = test_executable
.to_str()
.context("integration test executable path must be UTF-8")?;
// Libtest selectors omit the integration crate name.
let (_, test_module) = module_path!()
.split_once("::")
.context("network sandbox test must be in an integration test module")?;
let child_test = format!("{test_module}::managed_network_sandbox_probe");
let target_env = format!("CODEX_TEST_MANAGED_NETWORK_TARGET={target}");
let command = shlex::try_join([
"env",
&target_env,
test_executable,
"--exact",
&child_test,
"--ignored",
"--nocapture",
"--test-threads=1",
])?;
let call_id = "managed-network-probe";
let probe_timeout = std::time::Duration::from_secs(/*secs*/ 30);
let _command_response = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-network-command"),
responses::ev_exec_command_call_with_args(
call_id,
&json!({
"cmd": command,
"shell": "/bin/sh",
"login": false,
"yield_time_ms": probe_timeout.as_millis(),
}),
),
responses::ev_completed("resp-network-command"),
]),
)
.await;
let final_response = responses::mount_sse_once(
&server,
create_final_assistant_message_sse_response("Network probe complete")?,
)
.await;
let _: TurnStartResponse = app_server
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "Verify managed sandbox networking".to_string(),
text_elements: Vec::new(),
}],
permissions: Some("managed-network".to_string()),
..Default::default()
},
})
.await?;
let expected_output = "proxied request succeeded; direct access blocked\n";
timeout(probe_timeout, async {
loop {
let completed: ItemCompletedNotification =
app_server.read_notification("item/completed").await?;
if let ThreadItem::CommandExecution {
id,
status,
exit_code,
..
} = completed.item
&& id == call_id
{
assert_eq!(
(status, exit_code),
(CommandExecutionStatus::Completed, Some(0))
);
break;
}
}
anyhow::Ok(())
})
.await??;
let completed: TurnCompletedNotification = timeout(
probe_timeout,
app_server.read_notification("turn/completed"),
)
.await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
// Early stdout can reach the tool-result buffer before the separate event
// subscriber attaches, so verify it in the model continuation instead.
// The marker also proves the child ran: libtest succeeds if no selector matches.
let output = final_response
.single_request()
.function_call_output_text(call_id)
.context("model continuation should contain the sandboxed command output")?;
assert!(
output.contains(expected_output),
"unexpected tool output: {output}"
);
Ok(())
}
/// Runs as the model-controlled command inside app-server's managed network sandbox.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "invoked through app-server's managed network sandbox"]
fn managed_network_sandbox_probe() -> Result<()> {
use std::io::ErrorKind;
use std::io::Read;
use std::io::Write;
use std::net::SocketAddr;
use std::net::TcpStream;
use std::time::Duration;
let target: SocketAddr = std::env::var("CODEX_TEST_MANAGED_NETWORK_TARGET")?.parse()?;
let proxy = url::Url::parse(&std::env::var("HTTP_PROXY")?)?;
let proxy_address = SocketAddr::new(
proxy.host_str().context("missing proxy host")?.parse()?,
proxy
.port_or_known_default()
.context("missing proxy port")?,
);
let proxy_timeout = Duration::from_secs(/*secs*/ 5);
let mut connection = TcpStream::connect_timeout(&proxy_address, proxy_timeout)?;
connection.set_read_timeout(Some(proxy_timeout))?;
connection.set_write_timeout(Some(proxy_timeout))?;
// HTTP/1.0 keeps this small fixture response close-delimited, without chunked encoding.
write!(
connection,
"GET http://{target}/sandbox HTTP/1.0\r\nHost: {target}\r\nConnection: close\r\n\r\n"
)?;
let mut response = String::new();
connection
.take(/*limit*/ 4096)
.read_to_string(&mut response)?;
let (headers, body) = response
.split_once("\r\n\r\n")
.context("incomplete proxy HTTP response")?;
let status = headers
.split_whitespace()
.nth(/*n*/ 1)
.context("missing HTTP response status")?;
assert_eq!((status, body), ("200", "sandbox proxy response"));
let direct_error = TcpStream::connect_timeout(&target, Duration::from_secs(/*secs*/ 2))
.expect_err("sandbox allowed direct host access");
assert!(
matches!(
direct_error.kind(),
ErrorKind::ConnectionRefused
| ErrorKind::NetworkUnreachable
| ErrorKind::PermissionDenied
),
"unexpected direct connection error: {direct_error}"
);
println!("proxied request succeeded; direct access blocked");
Ok(())
}
#[tokio::test] #[tokio::test]
async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result<()> { async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await; let server = create_mock_responses_server_repeating_assistant("Done").await;
+2
View File
@@ -27,6 +27,7 @@ codex-utils-absolute-path = { workspace = true }
globset = { workspace = true } globset = { workspace = true }
landlock = { workspace = true } landlock = { workspace = true }
libc = { workspace = true } libc = { workspace = true }
rustix = { workspace = true }
seccompiler = { workspace = true } seccompiler = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
@@ -37,6 +38,7 @@ url = { workspace = true }
codex-core = { workspace = true } codex-core = { workspace = true }
pretty_assertions = { workspace = true } pretty_assertions = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
test-case = "3.3.1"
tokio = { workspace = true, features = [ tokio = { workspace = true, features = [
"io-std", "io-std",
"macros", "macros",
+11 -14
View File
@@ -179,7 +179,7 @@ pub fn run_main() -> ! {
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_legacy_landlock); ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_legacy_landlock);
let EffectivePermissions { let EffectivePermissions {
permission_profile, permission_profile,
mut file_system_sandbox_policy, file_system_sandbox_policy,
network_sandbox_policy, network_sandbox_policy,
} = resolve_permission_profile(permission_profile).unwrap_or_else(|err| panic!("{err}")); } = resolve_permission_profile(permission_profile).unwrap_or_else(|err| panic!("{err}"));
ensure_legacy_landlock_mode_supports_policy( ensure_legacy_landlock_mode_supports_policy(
@@ -291,16 +291,12 @@ pub fn run_main() -> ! {
// Outer stage: bubblewrap first, then re-enter this binary in the // Outer stage: bubblewrap first, then re-enter this binary in the
// sandboxed environment to apply seccomp. This path never falls back // sandboxed environment to apply seccomp. This path never falls back
// to legacy Landlock on failure. // to legacy Landlock on failure.
let proxy_route_spec = if allow_network_for_proxy { let (proxy_route_spec, proxy_controls) = if allow_network_for_proxy {
let (proxy_route_spec, socket_dir) = prepare_host_proxy_route_spec() let (proxy_route_spec, controls) = prepare_host_proxy_route_spec()
.unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}")); .unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}"));
file_system_sandbox_policy = file_system_sandbox_policy.with_additional_readable_roots( (Some(proxy_route_spec), controls)
&sandbox_policy_cwd,
std::slice::from_ref(&socket_dir),
);
Some(proxy_route_spec)
} else { } else {
None (None, Vec::new())
}; };
let inner = build_inner_seccomp_command(InnerSeccompCommandArgs { let inner = build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: &sandbox_policy_cwd, sandbox_policy_cwd: &sandbox_policy_cwd,
@@ -314,10 +310,10 @@ pub fn run_main() -> ! {
&sandbox_policy_cwd, &sandbox_policy_cwd,
command_cwd.as_deref(), command_cwd.as_deref(),
&file_system_sandbox_policy, &file_system_sandbox_policy,
network_sandbox_policy, bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy),
inner, inner,
proxy_controls,
!no_proc, !no_proc,
allow_network_for_proxy,
); );
} }
@@ -398,12 +394,11 @@ fn run_bwrap_with_proc_fallback(
sandbox_policy_cwd: &Path, sandbox_policy_cwd: &Path,
command_cwd: Option<&Path>, command_cwd: Option<&Path>,
file_system_sandbox_policy: &FileSystemSandboxPolicy, file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy, network_mode: BwrapNetworkMode,
inner: Vec<String>, inner: Vec<String>,
proxy_controls: Vec<File>,
mount_proc: bool, mount_proc: bool,
allow_network_for_proxy: bool,
) -> ! { ) -> ! {
let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy);
let mut mount_proc = mount_proc; let mut mount_proc = mount_proc;
let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd); let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd);
@@ -429,6 +424,7 @@ fn run_bwrap_with_proc_fallback(
options, options,
) )
.unwrap_or_else(|err| exit_with_bwrap_build_error(err)); .unwrap_or_else(|err| exit_with_bwrap_build_error(err));
bwrap_args.preserved_files.extend(proxy_controls);
apply_inner_command_argv0(&mut bwrap_args.args); apply_inner_command_argv0(&mut bwrap_args.args);
run_or_exec_bwrap(bwrap_args); run_or_exec_bwrap(bwrap_args);
} }
@@ -597,6 +593,7 @@ fn run_bwrap_in_child_with_synthetic_mount_cleanup(bwrap_args: crate::bwrap::Bwr
exec_bwrap(args, preserved_files); exec_bwrap(args, preserved_files);
} }
drop(preserved_files);
close_child_exec_start_read(exec_start_pipe[0]); close_child_exec_start_read(exec_start_pipe[0]);
let protected_create_monitor = ProtectedCreateMonitor::start(&protected_create_targets); let protected_create_monitor = ProtectedCreateMonitor::start(&protected_create_targets);
let signal_forwarders = install_bwrap_signal_forwarders(pid); let signal_forwarders = install_bwrap_signal_forwarders(pid);
+115 -143
View File
@@ -1,121 +1,116 @@
//! Manage privileged bridge processes and transfer their loopback TCP listeners.
//!
//! Received descriptors are CLOEXEC and owned even when validation rejects them.
//! Shape validation does not authenticate the sender: the caller must retain exclusive
//! control of the anonymous channel and close bootstrap descriptors before untrusted execution.
use rustix::io::retry_on_intr;
use rustix::net::RecvAncillaryBuffer;
use rustix::net::RecvAncillaryMessage;
use rustix::net::RecvFlags;
use rustix::net::ReturnFlags;
use rustix::net::SendAncillaryBuffer;
use rustix::net::SendAncillaryMessage;
use rustix::net::SendFlags;
use rustix::net::SocketType;
use rustix::net::ipproto;
use rustix::net::sockopt;
use std::io; use std::io;
use std::path::Path; use std::io::IoSlice;
use std::path::PathBuf; use std::io::IoSliceMut;
use std::time::Duration; use std::mem::MaybeUninit;
use std::net::Ipv4Addr;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::os::fd::AsFd;
use std::os::fd::AsRawFd;
use std::os::fd::FromRawFd;
use std::os::fd::OwnedFd;
use std::os::unix::net::UnixStream;
pub(crate) const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-"; const LISTENER_MESSAGE: u8 = 1;
pub(crate) fn cleanup_stale_proxy_socket_dirs_in(temp_dir: &Path) -> io::Result<()> { pub(crate) fn send_listener(channel: &UnixStream, listener: &TcpListener) -> io::Result<()> {
for entry in std::fs::read_dir(temp_dir)? { let descriptors = [listener.as_fd()];
let entry = match entry { let mut space = [MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))];
Ok(entry) => entry, let mut control = SendAncillaryBuffer::new(&mut space);
Err(_) => continue, if !control.push(SendAncillaryMessage::ScmRights(&descriptors)) {
}; return Err(io::Error::other("missing proxy listener control header"));
let file_type = match entry.file_type() { }
Ok(file_type) => file_type, // rustix surfaces EINTR on both its libc and raw-syscall backends.
Err(_) => continue, // Retry interrupted sends so a handled signal does not abort the handoff.
}; let sent = retry_on_intr(|| {
if !file_type.is_dir() { rustix::net::sendmsg(
continue; channel,
} &[IoSlice::new(&[LISTENER_MESSAGE])],
&mut control,
let file_name = entry.file_name(); SendFlags::NOSIGNAL,
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 sent != 1 {
}; return Err(io::Error::other("failed to transfer proxy listener"));
if is_pid_alive(owner_pid) {
continue;
}
let _ = cleanup_proxy_socket_dir(entry.path().as_path());
} }
Ok(()) Ok(())
} }
fn parse_proxy_socket_dir_owner_pid(file_name: &str) -> Option<u32> { pub(crate) fn receive_listener(channel: &UnixStream) -> io::Result<TcpListener> {
let suffix = file_name.strip_prefix(PROXY_SOCKET_DIR_PREFIX)?; let mut byte = [0_u8];
let (pid_raw, _) = suffix.split_once('-')?; let mut space = [MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))];
pid_raw.parse::<u32>().ok().filter(|pid| *pid != 0) let mut control = RecvAncillaryBuffer::new(&mut space);
} // rustix surfaces EINTR on both its libc and raw-syscall backends.
// Retry interrupted receives so a handled signal does not abort the handoff.
let message = retry_on_intr(|| {
rustix::net::recvmsg(
channel,
&mut [IoSliceMut::new(&mut byte)],
&mut control,
RecvFlags::CMSG_CLOEXEC,
)
})?;
fn is_pid_alive(pid: u32) -> bool { // Alignment headroom can fit extra descriptors. Own all of them before checking
let Ok(pid) = libc::pid_t::try_from(pid) else { // the count; rustix also closes any descriptors left undrained on error paths.
return false; let mut descriptors = Vec::new();
}; let mut unexpected_message = false;
is_pid_alive_raw(pid) for ancillary in control.drain() {
} match ancillary {
RecvAncillaryMessage::ScmRights(received) => descriptors.extend(received),
fn is_pid_alive_raw(pid: libc::pid_t) -> bool { _ => unexpected_message = true,
is_pid_alive_in_proc_root(pid, Path::new("/proc"))
}
fn is_pid_alive_in_proc_root(pid: libc::pid_t, proc_root: &Path) -> bool {
let status = unsafe { libc::kill(pid, 0) };
if status != 0 {
let err = io::Error::last_os_error();
return !matches!(err.raw_os_error(), Some(libc::ESRCH));
}
match std::fs::read_to_string(proc_root.join(format!("{pid}/stat"))) {
Ok(status) => !matches!(
status
.rsplit_once(") ")
.and_then(|(_, fields)| fields.chars().next()),
Some('Z')
),
Err(err) if err.kind() == io::ErrorKind::NotFound => !proc_root.is_dir(),
Err(_) => true,
}
}
pub(crate) 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 {
if detach_bridge_stdio().is_err() {
unsafe { libc::_exit(1) };
}
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)),
} }
} }
if message.bytes != 1
match std::fs::remove_dir_all(socket_dir) { || byte != [LISTENER_MESSAGE]
Ok(()) => Ok(()), || message
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), .flags
Err(err) => Err(err), .intersects(ReturnFlags::CTRUNC | ReturnFlags::TRUNC)
|| unexpected_message
|| descriptors.len() != 1
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid proxy listener handoff",
));
} }
let listener = TcpListener::from(descriptors.remove(/*index*/ 0));
if sockopt::socket_type(&listener)? != SocketType::STREAM
|| sockopt::socket_protocol(&listener)? != Some(ipproto::TCP)
|| !sockopt::socket_acceptconn(&listener)?
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"proxy handoff requires a TCP listener",
));
}
if !matches!(listener.local_addr()?, SocketAddr::V4(address)
if *address.ip() == Ipv4Addr::LOCALHOST && address.port() != 0)
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"proxy listener must be bound to IPv4 loopback",
));
}
Ok(listener)
} }
pub(crate) fn harden_bridge_process(expected_parent_pid: libc::pid_t) -> io::Result<()> { pub(crate) fn harden_bridge_process(expected_parent_pid: libc::pid_t) -> io::Result<()> {
@@ -197,49 +192,26 @@ fn redirect_bridge_output(source_fd: libc::c_int) -> io::Result<()> {
Ok(()) Ok(())
} }
pub(crate) fn create_ready_pipe() -> io::Result<(libc::c_int, libc::c_int)> { pub(crate) fn move_fd_above_stdio(fd: OwnedFd) -> io::Result<OwnedFd> {
let mut pipe_fds = [0; 2]; if fd.as_raw_fd() > libc::STDERR_FILENO {
let res = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) };
if res != 0 {
return Err(io::Error::last_os_error());
}
let read_fd = match move_fd_above_stdio(pipe_fds[0]) {
Ok(fd) => fd,
Err(err) => {
let _ = close_fd(pipe_fds[0]);
let _ = close_fd(pipe_fds[1]);
return Err(err);
}
};
let write_fd = match move_fd_above_stdio(pipe_fds[1]) {
Ok(fd) => fd,
Err(err) => {
let _ = close_fd(read_fd);
let _ = close_fd(pipe_fds[1]);
return Err(err);
}
};
Ok((read_fd, write_fd))
}
fn move_fd_above_stdio(fd: libc::c_int) -> io::Result<libc::c_int> {
if fd > libc::STDERR_FILENO {
return Ok(fd); return Ok(fd);
} }
let relocated_fd = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, libc::STDERR_FILENO + 1) }; // SAFETY: F_DUPFD_CLOEXEC takes an integer lower bound, not a pointer;
// `fd` owns the live descriptor throughout the call.
let relocated_fd = unsafe {
libc::fcntl(
fd.as_raw_fd(),
libc::F_DUPFD_CLOEXEC,
libc::STDERR_FILENO + 1,
)
};
if relocated_fd < 0 { if relocated_fd < 0 {
return Err(io::Error::last_os_error()); return Err(io::Error::last_os_error());
} }
if let Err(err) = close_fd(fd) { // SAFETY: F_DUPFD_CLOEXEC returned a new descriptor; the original owner drops here.
let _ = close_fd(relocated_fd); Ok(unsafe { OwnedFd::from_raw_fd(relocated_fd) })
return Err(err);
}
Ok(relocated_fd)
} }
pub(crate) fn close_fd(fd: libc::c_int) -> io::Result<()> { pub(crate) fn close_fd(fd: libc::c_int) -> io::Result<()> {
@@ -1,91 +1,124 @@
use super::PROXY_SOCKET_DIR_PREFIX; //! Verify listener ownership, handoff validation, and descriptor cleanup on rejection.
use super::cleanup_proxy_socket_dir;
use super::cleanup_stale_proxy_socket_dirs_in; use super::LISTENER_MESSAGE;
use super::is_pid_alive_in_proc_root; use super::receive_listener;
use super::is_pid_alive_raw; use super::send_listener;
use super::parse_proxy_socket_dir_owner_pid;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::process::Command; use rustix::io::FdFlags;
use rustix::io::fcntl_getfd;
use rustix::net::SendAncillaryBuffer;
use rustix::net::SendAncillaryMessage;
use rustix::net::SendFlags;
use std::io;
use std::io::IoSlice;
use std::io::Read;
use std::io::Write;
use std::mem::MaybeUninit;
use std::net::Ipv4Addr;
use std::net::TcpListener;
use std::net::TcpStream;
use std::os::fd::AsFd;
use std::os::fd::BorrowedFd;
use std::os::unix::net::UnixStream;
use std::time::Duration; use std::time::Duration;
use std::time::Instant; use test_case::test_case;
#[test] #[test]
fn cleanup_proxy_socket_dir_removes_bridge_artifacts() { fn transferred_listener_survives_sender_close_and_is_cloexec() -> io::Result<()> {
let root = tempfile::tempdir().expect("tempdir should create"); let (sender, receiver) = UnixStream::pair()?;
let socket_dir = root.path().join("codex-linux-sandbox-proxy-test"); let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
std::fs::create_dir(&socket_dir).expect("socket dir should create"); let address = listener.local_addr()?;
let marker = socket_dir.join("bridge.sock"); send_listener(&sender, &listener)?;
std::fs::write(&marker, b"test").expect("marker should write"); drop(listener);
drop(sender);
cleanup_proxy_socket_dir(socket_dir.as_path()).expect("cleanup should succeed"); let listener = receive_listener(&receiver)?;
let flags = fcntl_getfd(&listener)?;
assert_eq!(socket_dir.exists(), false);
}
#[test]
fn parse_proxy_socket_dir_owner_pid_reads_owner_pid() {
assert_eq!( assert_eq!(
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-0"), (listener.local_addr()?, flags & FdFlags::CLOEXEC),
Some(1234) (address, FdFlags::CLOEXEC)
); );
assert_eq!( let mut client = TcpStream::connect(address)?;
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-1000-0"), client.write_all(b"proxy")?;
Some(1234) let (mut accepted, _) = listener.accept()?;
); let mut bytes = [0; 5];
assert_eq!( accepted.read_exact(&mut bytes)?;
parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-x"), assert_eq!(bytes, *b"proxy");
None Ok(())
);
assert_eq!(parse_proxy_socket_dir_owner_pid("not-a-proxy-dir"), None);
} }
#[test] #[test_case(1; "non_listener")]
fn cleanup_stale_proxy_socket_dirs_removes_dead_pid_directories() { #[test_case(2; "extra_descriptors")]
let root = tempfile::tempdir().expect("tempdir should create"); // Eight descriptors exceed the receive buffer even with alignment headroom.
let dead_dir = root #[test_case(8; "truncated_control_message")]
.path() fn malformed_handoff_closes_all_received_descriptors(count: usize) -> io::Result<()> {
.join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-0", u32::MAX)); let (sender, receiver) = UnixStream::pair()?;
std::fs::create_dir(&dead_dir).expect("dead dir should create"); let mut peers = Vec::new();
let mut passed = Vec::new();
let alive_dir = root for _ in 0..count {
.path() let (peer, descriptor) = UnixStream::pair()?;
.join(format!("{PROXY_SOCKET_DIR_PREFIX}{}-1", std::process::id())); peer.set_read_timeout(Some(Duration::from_secs(/*secs*/ 1)))?;
std::fs::create_dir(&alive_dir).expect("alive dir should create"); peers.push(peer);
passed.push(descriptor);
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);
}
#[test]
fn missing_procfs_keeps_live_process_alive() {
let root = tempfile::tempdir().expect("tempdir should create");
let missing_proc_root = root.path().join("missing-proc");
let pid = libc::pid_t::try_from(std::process::id()).expect("current pid should fit");
assert_eq!(is_pid_alive_in_proc_root(pid, &missing_proc_root), true);
}
#[test]
fn zombie_process_is_not_alive() {
let mut child = Command::new("sh")
.arg("-c")
.arg("exit 0")
.spawn()
.expect("child should spawn");
let pid = libc::pid_t::try_from(child.id()).expect("child pid should fit");
let deadline = Instant::now() + Duration::from_secs(5);
while is_pid_alive_raw(pid) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
} }
let zombie_was_recognized = !is_pid_alive_raw(pid); let descriptors = passed.iter().map(AsFd::as_fd).collect::<Vec<_>>();
child.wait().expect("child should be reaped"); send_descriptors(&sender, LISTENER_MESSAGE, &descriptors)?;
drop(passed);
assert!(receive_listener(&receiver).is_err());
for mut peer in peers {
assert_eq!(peer.read(&mut [0])?, 0);
}
Ok(())
}
assert_eq!(zombie_was_recognized, true); #[test]
fn rejects_missing_descriptors_and_invalid_payloads() -> io::Result<()> {
let (mut sender, receiver) = UnixStream::pair()?;
sender.write_all(&[LISTENER_MESSAGE])?;
assert!(receive_listener(&receiver).is_err());
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
send_descriptors(&sender, /*payload*/ 0, &[listener.as_fd()])?;
assert!(receive_listener(&receiver).is_err());
Ok(())
}
#[test_case(2; "extra_descriptors")]
#[test_case(8; "truncated_control_message")]
fn rejects_extra_descriptors_even_when_first_is_a_listener(count: usize) -> io::Result<()> {
let (sender, receiver) = UnixStream::pair()?;
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
send_descriptors(&sender, LISTENER_MESSAGE, &vec![listener.as_fd(); count])?;
assert!(receive_listener(&receiver).is_err());
Ok(())
}
#[test]
fn rejects_listener_bound_to_all_interfaces() -> io::Result<()> {
let (sender, receiver) = UnixStream::pair()?;
let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0))?;
send_listener(&sender, &listener)?;
assert!(receive_listener(&receiver).is_err());
Ok(())
}
fn send_descriptors(
channel: &UnixStream,
payload: u8,
descriptors: &[BorrowedFd<'_>],
) -> io::Result<()> {
let message = SendAncillaryMessage::ScmRights(descriptors);
let mut space = vec![MaybeUninit::uninit(); message.size()];
let mut control = SendAncillaryBuffer::new(&mut space);
assert!(control.push(message));
let sent = rustix::io::retry_on_intr(|| {
rustix::net::sendmsg(
channel,
&[IoSlice::new(&[payload])],
&mut control,
SendFlags::NOSIGNAL,
)
})?;
assert_eq!(sent, 1);
Ok(())
} }
+97 -226
View File
@@ -1,19 +1,15 @@
use crate::proxy_lifecycle::PROXY_SOCKET_DIR_PREFIX;
use crate::proxy_lifecycle::cleanup_stale_proxy_socket_dirs_in;
use crate::proxy_lifecycle::close_fd; use crate::proxy_lifecycle::close_fd;
use crate::proxy_lifecycle::create_ready_pipe;
use crate::proxy_lifecycle::harden_bridge_process; use crate::proxy_lifecycle::harden_bridge_process;
use crate::proxy_lifecycle::spawn_proxy_socket_dir_cleanup_worker; use crate::proxy_lifecycle::move_fd_above_stdio;
use crate::proxy_lifecycle::receive_listener;
use crate::proxy_lifecycle::send_listener;
use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
use codex_network_proxy::write_attribution_frame; use codex_network_proxy::write_attribution_frame;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::DirBuilder;
use std::fs::File; use std::fs::File;
use std::fs::Permissions;
use std::io; use std::io;
use std::io::Read; use std::io::Read;
use std::io::Write; use std::io::Write;
@@ -23,14 +19,11 @@ use std::net::Shutdown;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::net::TcpListener; use std::net::TcpListener;
use std::net::TcpStream; use std::net::TcpStream;
use std::os::fd::AsRawFd;
use std::os::fd::FromRawFd; use std::os::fd::FromRawFd;
use std::os::unix::ffi::OsStrExt; use std::os::fd::OwnedFd;
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::os::unix::net::UnixStream;
use std::path::Path; use std::time::Duration;
use std::path::PathBuf;
use url::Url; use url::Url;
const PROXY_ENV_KEYS: &[&str] = &[ const PROXY_ENV_KEYS: &[&str] = &[
@@ -54,8 +47,7 @@ const PROXY_ENV_KEYS: &[&str] = &[
const HOST_BRIDGE_READY: u8 = 1; const HOST_BRIDGE_READY: u8 = 1;
const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo"; const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo";
// Linux sockaddr_un.sun_path allows 108 bytes, including the trailing NUL. const HANDOFF_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
const UNIX_SOCKET_PATH_MAX_BYTES: usize = 107;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ProxyRouteSpec { pub(crate) struct ProxyRouteSpec {
@@ -65,7 +57,7 @@ pub(crate) struct ProxyRouteSpec {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct ProxyRouteEntry { struct ProxyRouteEntry {
env_key: String, env_key: String,
uds_path: PathBuf, control_fd: libc::c_int,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -80,7 +72,7 @@ struct ProxyRoutePlan {
has_proxy_config: bool, has_proxy_config: bool,
} }
pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<(String, AbsolutePathBuf)> { pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<(String, Vec<File>)> {
let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect()); 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 // SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or
// executes the user command. // executes the user command.
@@ -97,48 +89,35 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<(String, AbsolutePat
return Err(io::Error::new(io::ErrorKind::InvalidInput, message)); return Err(io::Error::new(io::ErrorKind::InvalidInput, message));
} }
let socket_parent_dir = proxy_socket_parent_dir(); let mut control_by_endpoint = BTreeMap::new();
let _ = cleanup_stale_proxy_socket_dirs_in(socket_parent_dir.as_path());
let socket_dir = create_proxy_socket_dir()?;
let readable_socket_dir = AbsolutePathBuf::relative_to_current_dir(&socket_dir)?;
let mut socket_by_endpoint: BTreeMap<SocketAddr, PathBuf> = BTreeMap::new();
let mut next_index = 0usize;
for route in &plan.routes { for route in &plan.routes {
if socket_by_endpoint.contains_key(&route.endpoint) { if control_by_endpoint.contains_key(&route.endpoint) {
continue; continue;
} }
let socket_path = socket_dir.join(format!("proxy-route-{next_index}.sock")); let control = spawn_host_bridge(
next_index += 1; route.endpoint,
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(), attribution_token.as_deref(),
)?); &mut control_by_endpoint,
)?;
control_by_endpoint.insert(route.endpoint, control);
} }
spawn_proxy_socket_dir_cleanup_worker(socket_dir, host_bridge_pids)?;
let mut routes = Vec::with_capacity(plan.routes.len()); let mut routes = Vec::with_capacity(plan.routes.len());
for route in plan.routes { for route in plan.routes {
let Some(uds_path) = socket_by_endpoint.get(&route.endpoint) else { let Some(control) = control_by_endpoint.get(&route.endpoint) else {
return Err(io::Error::other(format!( return Err(io::Error::other(format!(
"missing UDS path for endpoint {}", "missing bootstrap channel for endpoint {}",
route.endpoint route.endpoint
))); )));
}; };
routes.push(ProxyRouteEntry { routes.push(ProxyRouteEntry {
env_key: route.env_key, env_key: route.env_key,
uds_path: uds_path.clone(), control_fd: control.as_raw_fd(),
}); });
} }
let spec = serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other)?; let spec = serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other)?;
Ok((spec, readable_socket_dir)) Ok((spec, control_by_endpoint.into_values().collect()))
} }
fn extract_attribution_token_and_plan( fn extract_attribution_token_and_plan(
@@ -159,21 +138,50 @@ pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Resul
)); ));
} }
let mut local_port_by_uds_path: BTreeMap<PathBuf, u16> = BTreeMap::new(); let mut local_port_by_control = BTreeMap::new();
for route in &spec.routes { for route in &spec.routes {
if local_port_by_uds_path.contains_key(&route.uds_path) { if local_port_by_control.contains_key(&route.control_fd) {
continue; continue;
} }
let local_port = spawn_local_bridge(route.uds_path.as_path())?; if route.control_fd <= libc::STDERR_FILENO {
local_port_by_uds_path.insert(route.uds_path.clone(), local_port); return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"proxy bootstrap channel must not use standard descriptors",
));
}
// SAFETY: F_GETFD takes only a descriptor, with no pointer arguments;
// an invalid inherited descriptor returns EBADF.
let flags = unsafe { libc::fcntl(route.control_fd, libc::F_GETFD) };
if flags < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: the outer helper left this descriptor open across exec, which
// discarded its Rust owner. F_GETFD above confirmed it is still valid.
// The map ensures each descriptor is claimed once, even for proxy aliases.
let mut control = unsafe { UnixStream::from_raw_fd(route.control_fd) };
// SAFETY: F_SETFD takes integer flags, not a pointer; `control` owns the
// live descriptor throughout the call.
if unsafe { libc::fcntl(control.as_raw_fd(), libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0
{
return Err(io::Error::last_os_error());
}
control.set_read_timeout(Some(HANDOFF_TIMEOUT))?;
let listener = bind_local_loopback_listener()?;
let local_port = listener.local_addr()?.port();
send_listener(&control, &listener)?;
let mut ready = [0_u8; 1];
control.read_exact(&mut ready)?;
if ready != [HOST_BRIDGE_READY] {
return Err(io::Error::other("host bridge did not accept its listener"));
}
// Loop scope closes both descriptors, leaving only the host's listener.
// Neither can reach the namespace reaper or the untrusted command.
local_port_by_control.insert(route.control_fd, local_port);
} }
for route in spec.routes { for route in spec.routes {
let Some(local_port) = local_port_by_uds_path.get(&route.uds_path) else { let Some(local_port) = local_port_by_control.get(&route.control_fd) else {
return Err(io::Error::other(format!( return Err(io::Error::other("missing proxy listener port"));
"missing local bridge port for UDS path {}",
route.uds_path.display()
)));
}; };
let original_value = std::env::var(&route.env_key).map_err(|_| { let original_value = std::env::var(&route.env_key).map_err(|_| {
io::Error::new( io::Error::new(
@@ -306,129 +314,62 @@ fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option<String> {
Some(rewritten) 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 spawn_host_bridge( fn spawn_host_bridge(
endpoint: SocketAddr, endpoint: SocketAddr,
uds_path: &Path,
attribution_token: Option<&str>, attribution_token: Option<&str>,
) -> io::Result<libc::pid_t> { inherited_controls: &mut BTreeMap<SocketAddr, File>,
let (read_fd, write_fd) = create_ready_pipe()?; ) -> io::Result<File> {
let (host_control, control) = UnixStream::pair()?;
let host_control = UnixStream::from(move_fd_above_stdio(host_control.into())?);
let mut control = UnixStream::from(move_fd_above_stdio(control.into())?);
control.set_read_timeout(Some(HANDOFF_TIMEOUT))?;
let parent_pid = unsafe { libc::getpid() }; let parent_pid = unsafe { libc::getpid() };
let pid = unsafe { libc::fork() }; let pid = unsafe { libc::fork() };
if pid < 0 { if pid < 0 {
let err = io::Error::last_os_error(); return Err(io::Error::last_os_error());
close_fd(read_fd)?;
close_fd(write_fd)?;
return Err(err);
} }
if pid == 0 { if pid == 0 {
if close_fd(read_fd).is_err() { drop(control);
unsafe { libc::_exit(1) }; // A worker must not retain another route's inner bootstrap endpoint.
} // Drop this child's inherited owners; the parent's copies are unaffected by fork.
let result = run_host_bridge(endpoint, uds_path, write_fd, attribution_token, parent_pid); inherited_controls.clear();
let result = run_host_bridge(endpoint, host_control, attribution_token, parent_pid);
if result.is_err() { if result.is_err() {
unsafe { libc::_exit(1) }; unsafe { libc::_exit(1) };
} }
unsafe { libc::_exit(0) }; unsafe { libc::_exit(0) };
} }
close_fd(write_fd)?; drop(host_control);
let mut ready = [0_u8; 1]; let mut ready = [0_u8; 1];
let mut read_file = unsafe { File::from_raw_fd(read_fd) }; control.read_exact(&mut ready)?;
read_file.read_exact(&mut ready)?; if ready != [HOST_BRIDGE_READY] {
if ready[0] != HOST_BRIDGE_READY {
return Err(io::Error::other( return Err(io::Error::other(
"host bridge did not acknowledge readiness", "host bridge did not acknowledge readiness",
)); ));
} }
Ok(pid) Ok(File::from(OwnedFd::from(control)))
} }
fn run_host_bridge( fn run_host_bridge(
endpoint: SocketAddr, endpoint: SocketAddr,
uds_path: &Path, mut control: UnixStream,
ready_fd: libc::c_int,
attribution_token: Option<&str>, attribution_token: Option<&str>,
parent_pid: libc::pid_t, parent_pid: libc::pid_t,
) -> io::Result<()> { ) -> io::Result<()> {
harden_bridge_process(parent_pid)?; harden_bridge_process(parent_pid)?;
if uds_path.exists() { control.write_all(&[HOST_BRIDGE_READY])?;
std::fs::remove_file(uds_path)?; // The outer helper must launch bubblewrap before this listener can arrive.
} // The socket keeps its isolated network namespace even though we accept in
let listener = UnixListener::bind(uds_path)?; // the host process; newly opened upstream sockets use our host namespace.
let listener = receive_listener(&control)?;
let mut ready_file = unsafe { File::from_raw_fd(ready_fd) }; control.write_all(&[HOST_BRIDGE_READY])?;
ready_file.write_all(&[HOST_BRIDGE_READY])?; drop(control);
drop(ready_file);
let attribution_token = attribution_token.map(str::to_owned); let attribution_token = attribution_token.map(str::to_owned);
loop { loop {
let (unix_stream, _) = listener.accept()?; let (client_stream, _) = listener.accept()?;
let attribution_token = attribution_token.clone(); let attribution_token = attribution_token.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let mut tcp_stream = match TcpStream::connect(endpoint) { let mut tcp_stream = match TcpStream::connect(endpoint) {
@@ -442,63 +383,7 @@ fn run_host_bridge(
// application bytes if this bridge cannot prove the exec attribution first. // application bytes if this bridge cannot prove the exec attribution first.
return; return;
} }
let _ = proxy_bidirectional(tcp_stream, unix_stream); let _ = proxy_bidirectional(tcp_stream, client_stream);
});
}
}
fn spawn_local_bridge(uds_path: &Path) -> io::Result<u16> {
let (read_fd, write_fd) = create_ready_pipe()?;
let parent_pid = unsafe { libc::getpid() };
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, parent_pid);
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,
parent_pid: libc::pid_t,
) -> io::Result<()> {
harden_bridge_process(parent_pid)?;
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);
}); });
} }
} }
@@ -583,21 +468,21 @@ fn ensure_loopback_interface_up() -> io::Result<()> {
close_fd(fd) close_fd(fd)
} }
fn proxy_bidirectional(mut tcp_stream: TcpStream, mut unix_stream: UnixStream) -> io::Result<()> { fn proxy_bidirectional(mut upstream: TcpStream, mut client: TcpStream) -> io::Result<()> {
let mut tcp_reader = tcp_stream.try_clone()?; let mut upstream_reader = upstream.try_clone()?;
let mut unix_writer = unix_stream.try_clone()?; let mut client_writer = client.try_clone()?;
let tcp_to_unix = std::thread::spawn(move || { let upstream_to_client = std::thread::spawn(move || {
let result = std::io::copy(&mut tcp_reader, &mut unix_writer); let result = std::io::copy(&mut upstream_reader, &mut client_writer);
let _ = unix_writer.shutdown(Shutdown::Write); let _ = client_writer.shutdown(Shutdown::Write);
result result
}); });
let unix_to_tcp = std::io::copy(&mut unix_stream, &mut tcp_stream); let client_to_upstream = std::io::copy(&mut client, &mut upstream);
let _ = tcp_stream.shutdown(Shutdown::Write); let _ = upstream.shutdown(Shutdown::Write);
let tcp_to_unix = tcp_to_unix let upstream_to_client = upstream_to_client
.join() .join()
.map_err(|_| io::Error::other("bridge thread panicked"))?; .map_err(|_| io::Error::other("bridge thread panicked"))?;
tcp_to_unix?; upstream_to_client?;
unix_to_tcp?; client_to_upstream?;
Ok(()) Ok(())
} }
@@ -611,12 +496,10 @@ mod tests {
use super::is_proxy_env_key; use super::is_proxy_env_key;
use super::parse_loopback_proxy_endpoint; use super::parse_loopback_proxy_endpoint;
use super::plan_proxy_routes; use super::plan_proxy_routes;
use super::proxy_socket_paths_fit;
use super::rewrite_proxy_env_value; use super::rewrite_proxy_env_value;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::collections::HashMap; use std::collections::HashMap;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf;
#[test] #[test]
fn recognizes_proxy_env_keys_case_insensitively() { fn recognizes_proxy_env_keys_case_insensitively() {
@@ -717,31 +600,19 @@ mod tests {
assert_eq!(default_proxy_port("socks5h"), 1080); 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] #[test]
fn proxy_route_spec_serialization_omits_proxy_urls() { fn proxy_route_spec_serialization_omits_proxy_urls() {
let spec = ProxyRouteSpec { let spec = ProxyRouteSpec {
routes: vec![ProxyRouteEntry { routes: vec![ProxyRouteEntry {
env_key: "HTTP_PROXY".to_string(), env_key: "HTTP_PROXY".to_string(),
uds_path: PathBuf::from("/tmp/proxy-route-0.sock"), control_fd: 3,
}], }],
}; };
let serialized = serde_json::to_string(&spec).expect("proxy route spec should serialize"); let serialized = serde_json::to_string(&spec).expect("proxy route spec should serialize");
assert_eq!( assert_eq!(
serialized, serialized,
r#"{"routes":[{"env_key":"HTTP_PROXY","uds_path":"/tmp/proxy-route-0.sock"}]}"# r#"{"routes":[{"env_key":"HTTP_PROXY","control_fd":3}]}"#
); );
} }
} }
@@ -1,7 +1,13 @@
//! Linux managed-proxy integration tests exercise the real sandbox with isolated environments.
//! Handoff must preserve routing and attribution without leaking privileged sockets; cancellation
//! must close active routes while filesystem and network restrictions remain enforced.
#![cfg(target_os = "linux")] #![cfg(target_os = "linux")]
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]
use codex_core::exec_env::create_env; use codex_core::exec_env::create_env;
use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
use codex_network_proxy::write_attribution_frame;
use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemAccessMode;
@@ -16,17 +22,24 @@ use std::collections::HashMap;
use std::io::Read; use std::io::Read;
use std::io::Write; use std::io::Write;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::net::Shutdown;
use std::net::TcpListener; use std::net::TcpListener;
use std::net::TcpStream;
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Output; use std::process::Output;
use std::process::Stdio; use std::process::Stdio;
use std::time::Duration; use std::time::Duration;
use std::time::Instant;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tokio::process::Command; use tokio::process::Command;
use url::Url;
const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found"; const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found";
const NETWORK_TIMEOUT_MS: u64 = 4_000; const NETWORK_TIMEOUT_MS: u64 = 4_000;
const OPERATION_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 8);
const SANDBOX_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60);
const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[ const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[
"loopback: Failed RTM_NEWADDR", "loopback: Failed RTM_NEWADDR",
"loopback: Failed RTM_NEWLINK", "loopback: Failed RTM_NEWLINK",
@@ -691,3 +704,269 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() {
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
); );
} }
/// Runs as the model-controlled command, after the trusted listener handoff.
#[test]
#[ignore = "invoked inside the managed proxy sandbox"]
fn handoff_client() {
let Ok(label) = std::env::var("CODEX_TEST_HANDOFF_LABEL") else {
return;
};
assert!(std::env::var_os(PROXY_ATTRIBUTION_TOKEN_ENV_KEY).is_none());
let inherited_sockets = std::fs::read_dir("/proc/self/fd")
.expect("enumerate inherited descriptors")
.filter_map(Result::ok)
.filter_map(|entry| {
let target = std::fs::read_link(entry.path()).ok()?;
target
.to_string_lossy()
.starts_with("socket:")
.then(|| (entry.file_name(), target))
})
.collect::<Vec<_>>();
assert_eq!(inherited_sockets, Vec::new(), "privileged socket leaked");
let shared = std::env::var("CODEX_TEST_HANDOFF_SHARED").expect("shared directory");
let shared = Path::new(&shared);
let peer = std::env::var("CODEX_TEST_HANDOFF_PEER").expect("peer label");
std::fs::write(shared.join(&label), b"ready").expect("announce sandbox readiness");
let deadline = Instant::now() + OPERATION_TIMEOUT;
while !shared.join(&peer).exists() {
assert!(Instant::now() < deadline, "peer sandbox did not start");
std::thread::sleep(Duration::from_millis(/*millis*/ 10));
}
let http =
Url::parse(&std::env::var("HTTP_PROXY").expect("HTTP proxy")).expect("parse HTTP proxy");
let https =
Url::parse(&std::env::var("HTTPS_PROXY").expect("HTTPS proxy")).expect("parse HTTPS proxy");
let other =
Url::parse(&std::env::var("ALL_PROXY").expect("other proxy")).expect("parse other proxy");
for (route, proxy) in [("http", http), ("https", https), ("all", other)] {
let endpoint = (
proxy.host_str().expect("proxy host"),
proxy.port().expect("proxy port"),
);
let mut stream = TcpStream::connect(endpoint).expect("connect through handoff listener");
stream
.set_read_timeout(Some(OPERATION_TIMEOUT))
.expect("read timeout");
stream
.set_write_timeout(Some(OPERATION_TIMEOUT))
.expect("write timeout");
let expected = format!("{label}/{route}");
stream
.write_all(expected.as_bytes())
.expect("send route marker");
stream.shutdown(Shutdown::Write).expect("finish request");
let mut response = String::new();
stream
.read_to_string(&mut response)
.expect("read routed reply");
assert_eq!(response, expected);
}
println!("handoff routes verified for {label}");
}
/// Both filesystem policies must preserve route identity across concurrent launches.
#[tokio::test]
async fn handoff_isolates_concurrent_endpoints_and_closes_privileged_descriptors() {
if let Some(reason) = managed_proxy_skip_reason().await {
eprintln!("skipping managed proxy test: {reason}");
return;
}
let shared = tempfile::tempdir_in("/tmp").expect("shared writable test directory");
let test_executable = std::env::current_exe().expect("integration test executable");
let test_executable = test_executable.to_str().expect("UTF-8 executable path");
// Libtest selectors include the module path but omit the integration crate name.
let (_, test_module) = module_path!()
.split_once("::")
.expect("handoff tests live in an integration test module");
let client_test = format!("{test_module}::handoff_client");
let mut commands = Vec::new();
let mut servers = Vec::new();
for (label, peer, profile) in [
("first", "second", PermissionProfile::workspace_write()),
("second", "first", PermissionProfile::Disabled),
] {
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
env.insert(
PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(),
format!("handoff-{label}"),
);
env.insert("CODEX_TEST_HANDOFF_LABEL".to_string(), label.to_string());
env.insert("CODEX_TEST_HANDOFF_PEER".to_string(), peer.to_string());
env.insert(
"CODEX_TEST_HANDOFF_SHARED".to_string(),
shared.path().to_string_lossy().into_owned(),
);
for (routes, keys) in [
(&["http", "https"][..], &["HTTP_PROXY", "HTTPS_PROXY"][..]),
(&["all"][..], &["ALL_PROXY"][..]),
] {
let listener =
TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("local proxy listener");
let endpoint = listener.local_addr().expect("proxy endpoint");
for key in keys {
env.insert((*key).to_string(), format!("http://{endpoint}"));
}
listener
.set_nonblocking(/*nonblocking*/ true)
.expect("bounded accept");
servers.push(std::thread::spawn(move || {
for route in routes {
let deadline = Instant::now() + OPERATION_TIMEOUT;
let (mut stream, _) = loop {
match listener.accept() {
Ok(connection) => break connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(Instant::now() < deadline, "proxy request timed out");
std::thread::sleep(Duration::from_millis(/*millis*/ 10));
}
Err(error) => panic!("accept proxy request: {error}"),
}
};
stream
.set_read_timeout(Some(OPERATION_TIMEOUT))
.expect("read timeout");
stream
.set_write_timeout(Some(OPERATION_TIMEOUT))
.expect("write timeout");
let marker = format!("{label}/{route}");
let mut expected = Vec::new();
write_attribution_frame(&mut expected, &format!("handoff-{label}"))
.expect("expected attribution frame");
expected.extend_from_slice(marker.as_bytes());
let mut request = Vec::new();
stream
.read_to_end(&mut request)
.expect("read attributed marker");
assert_eq!(request, expected);
stream
.write_all(marker.as_bytes())
.expect("reply with route marker");
}
}));
}
let mut command = linux_sandbox_command(
&[
test_executable,
"--exact",
&client_test,
"--ignored",
"--nocapture",
"--test-threads=1",
],
&profile,
/*allow_network_for_proxy*/ true,
env,
);
command.kill_on_drop(true);
commands.push(command);
}
let mut second = commands.pop().expect("second sandbox command");
let mut first = commands.pop().expect("first sandbox command");
let outputs = tokio::time::timeout(SANDBOX_TIMEOUT, async {
tokio::join!(first.output(), second.output())
})
.await
.expect("concurrent sandboxes should finish and release output");
for (label, output) in [("first", outputs.0), ("second", outputs.1)] {
let output = output.expect("run sandbox");
assert_eq!(
output.status.success(),
true,
"{label} sandbox failed; stdout={}; stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8_lossy(&output.stdout)
.contains(&format!("handoff routes verified for {label}"))
);
}
for server in servers {
server.join().expect("proxy responder should succeed");
}
}
#[tokio::test]
async fn cancelling_sandbox_closes_active_proxy_connection() {
if let Some(reason) = managed_proxy_skip_reason().await {
eprintln!("skipping managed proxy test: {reason}");
return;
}
for profile in [
PermissionProfile::workspace_write(),
PermissionProfile::Disabled,
] {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("local proxy listener");
listener
.set_nonblocking(/*nonblocking*/ true)
.expect("bounded accept");
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
env.remove(PROXY_ATTRIBUTION_TOKEN_ENV_KEY);
env.insert(
"HTTP_PROXY".to_string(),
format!("http://{}", listener.local_addr().expect("proxy endpoint")),
);
let mut command = linux_sandbox_command(
&[
"bash",
"-c",
concat!(
"set -eu; proxy=${HTTP_PROXY#*://}; ",
"exec 3<>/dev/tcp/${proxy%:*}/${proxy##*:}; ",
"printf ready >&3; IFS= read -r response <&3",
),
],
&profile,
/*allow_network_for_proxy*/ true,
env,
);
command.kill_on_drop(true);
let mut child = command.spawn().expect("launch sandbox");
let (mut upstream, _) = tokio::time::timeout(OPERATION_TIMEOUT, async {
loop {
match listener.accept() {
Ok(connection) => break connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await;
}
Err(error) => panic!("accept proxy connection: {error}"),
}
}
})
.await
.expect("sandbox should establish its proxy connection");
upstream
.set_read_timeout(Some(OPERATION_TIMEOUT))
.expect("bounded read");
let mut ready = [0; 5];
upstream
.read_exact(&mut ready)
.expect("read client readiness");
assert_eq!(ready, *b"ready");
child.start_kill().expect("cancel sandbox");
let output = tokio::time::timeout(OPERATION_TIMEOUT, child.wait_with_output())
.await
.expect("cancelled sandbox should release output")
.expect("wait for cancelled sandbox");
assert_eq!(output.status.success(), false);
assert_eq!(
upstream
.read(&mut [0])
.expect("host bridge should close its active connection"),
0,
"cancelling the sandbox must stop its host bridge"
);
}
}