feat(linux-sandbox): implement proxy-only egress via TCP-UDS-TCP bridge (#11293)

## Summary
- Implement Linux proxy-only routing in `codex-rs/linux-sandbox` with a
two-stage bridge: host namespace `loopback TCP proxy endpoint -> UDS`,
then bwrap netns `loopback TCP listener -> host UDS`.
- Add hidden `--proxy-route-spec` plumbing for outer-to-inner stage
handoff.
- Fail closed in proxy mode when no valid loopback proxy endpoints can
be routed.
- Introduce explicit network seccomp modes: `Restricted` (legacy
restricted networking) and `ProxyRouted` (allow INET/INET6 for routed
proxy access, deny `AF_UNIX` and `socketpair`).
- Enforce that proxy bridge/routing is bwrap-only by validating
`--apply-seccomp-then-exec` requires `--use-bwrap-sandbox`.
- Keep landlock-only flows unchanged (no proxy bridge behavior outside
bwrap).

---------

Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
This commit is contained in:
viyatb-oai 2026-02-21 10:16:34 -08:00 committed by GitHub
parent e7b6f38b58
commit b3202cbd58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1501 additions and 149 deletions

2
codex-rs/Cargo.lock generated
View file

@ -1921,9 +1921,11 @@ dependencies = [
"pkg-config",
"pretty_assertions",
"seccompiler",
"serde",
"serde_json",
"tempfile",
"tokio",
"url",
]
[[package]]

View file

@ -23,7 +23,9 @@ codex-utils-absolute-path = { workspace = true }
landlock = { workspace = true }
libc = { workspace = true }
seccompiler = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
url = { workspace = true }
[target.'cfg(target_os = "linux")'.dev-dependencies]
pretty_assertions = { workspace = true }

View file

@ -28,6 +28,11 @@ into this binary.
- When enabled, the helper isolates the PID namespace via `--unshare-pid`.
- When enabled and network is restricted without proxy routing, the helper also
isolates the network namespace via `--unshare-net`.
- In managed proxy mode, the helper uses `--unshare-net` plus an internal
TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy
endpoints.
- In managed proxy mode, after the bridge is live, seccomp blocks new
AF_UNIX/socketpair creation for the user command.
- When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but
you can skip this in restrictive container environments with `--no-proc`.

View file

@ -49,8 +49,8 @@ pub(crate) enum BwrapNetworkMode {
Isolated,
/// Intended proxy-only mode.
///
/// Bubblewrap does not currently enforce proxy-only egress, so this is
/// treated as isolated for fail-closed behavior.
/// Bubblewrap enforces this by unsharing the network namespace. The
/// proxy-routing bridge is established by the helper process after startup.
ProxyOnly,
}
@ -323,6 +323,7 @@ fn find_first_non_existent_component(target_path: &Path) -> Option<PathBuf> {
mod tests {
use super::*;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[test]
@ -373,4 +374,33 @@ mod tests {
]
);
}
#[test]
fn mounts_dev_before_writable_dev_binds() {
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![AbsolutePathBuf::try_from(Path::new("/dev")).expect("/dev path")],
read_only_access: Default::default(),
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let args = create_filesystem_args(&sandbox_policy, Path::new("/")).expect("bwrap fs args");
assert_eq!(
args,
vec![
"--ro-bind".to_string(),
"/".to_string(),
"/".to_string(),
"--dev".to_string(),
"/dev".to_string(),
"--bind".to_string(),
"/dev".to_string(),
"/dev".to_string(),
"--bind".to_string(),
"/".to_string(),
"/".to_string(),
]
);
}
}

View file

@ -43,22 +43,26 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
cwd: &Path,
apply_landlock_fs: bool,
allow_network_for_proxy: bool,
proxy_routed_network: bool,
) -> Result<()> {
let install_network_seccomp =
should_install_network_seccomp(sandbox_policy, allow_network_for_proxy);
let network_seccomp_mode = network_seccomp_mode(
sandbox_policy,
allow_network_for_proxy,
proxy_routed_network,
);
// `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents
// setuid privilege elevation. Many `bwrap` deployments rely on setuid, so
// we avoid this unless we need seccomp or we are explicitly using the
// legacy Landlock filesystem pipeline.
if install_network_seccomp
if network_seccomp_mode.is_some()
|| (apply_landlock_fs && !sandbox_policy.has_full_disk_write_access())
{
set_no_new_privs()?;
}
if install_network_seccomp {
install_network_seccomp_filter_on_current_thread()?;
if let Some(mode) = network_seccomp_mode {
install_network_seccomp_filter_on_current_thread(mode)?;
}
if apply_landlock_fs && !sandbox_policy.has_full_disk_write_access() {
@ -80,6 +84,12 @@ pub(crate) fn apply_sandbox_policy_to_current_thread(
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NetworkSeccompMode {
Restricted,
ProxyRouted,
}
fn should_install_network_seccomp(
sandbox_policy: &SandboxPolicy,
allow_network_for_proxy: bool,
@ -89,6 +99,20 @@ fn should_install_network_seccomp(
!sandbox_policy.has_full_network_access() || allow_network_for_proxy
}
fn network_seccomp_mode(
sandbox_policy: &SandboxPolicy,
allow_network_for_proxy: bool,
proxy_routed_network: bool,
) -> Option<NetworkSeccompMode> {
if !should_install_network_seccomp(sandbox_policy, allow_network_for_proxy) {
None
} else if proxy_routed_network {
Some(NetworkSeccompMode::ProxyRouted)
} else {
Some(NetworkSeccompMode::Restricted)
}
}
/// Enable `PR_SET_NO_NEW_PRIVS` so seccomp can be applied safely.
fn set_no_new_privs() -> Result<()> {
let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
@ -135,51 +159,87 @@ fn install_filesystem_landlock_rules_on_current_thread(
Ok(())
}
/// Installs a seccomp filter that blocks outbound network access except for
/// AF_UNIX domain sockets.
/// Installs a seccomp filter for Linux network sandboxing.
///
/// The filter is applied to the current thread so only the sandboxed child
/// inherits it.
fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> {
fn install_network_seccomp_filter_on_current_thread(
mode: NetworkSeccompMode,
) -> std::result::Result<(), SandboxErr> {
fn deny_syscall(rules: &mut BTreeMap<i64, Vec<SeccompRule>>, nr: i64) {
rules.insert(nr, vec![]); // empty rule vec = unconditional match
}
// Build rule map.
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
// Helper insert unconditional deny rule for syscall number.
let mut deny_syscall = |nr: i64| {
rules.insert(nr, vec![]); // empty rule vec = unconditional match
};
deny_syscall(&mut rules, libc::SYS_ptrace);
deny_syscall(&mut rules, libc::SYS_io_uring_setup);
deny_syscall(&mut rules, libc::SYS_io_uring_enter);
deny_syscall(&mut rules, libc::SYS_io_uring_register);
deny_syscall(libc::SYS_connect);
deny_syscall(libc::SYS_accept);
deny_syscall(libc::SYS_accept4);
deny_syscall(libc::SYS_bind);
deny_syscall(libc::SYS_listen);
deny_syscall(libc::SYS_getpeername);
deny_syscall(libc::SYS_getsockname);
deny_syscall(libc::SYS_shutdown);
deny_syscall(libc::SYS_sendto);
deny_syscall(libc::SYS_sendmmsg);
// NOTE: allowing recvfrom allows some tools like: `cargo clippy` to run
// with their socketpair + child processes for sub-proc management
// deny_syscall(libc::SYS_recvfrom);
deny_syscall(libc::SYS_recvmmsg);
deny_syscall(libc::SYS_getsockopt);
deny_syscall(libc::SYS_setsockopt);
deny_syscall(libc::SYS_ptrace);
deny_syscall(libc::SYS_io_uring_setup);
deny_syscall(libc::SYS_io_uring_enter);
deny_syscall(libc::SYS_io_uring_register);
match mode {
NetworkSeccompMode::Restricted => {
deny_syscall(&mut rules, libc::SYS_connect);
deny_syscall(&mut rules, libc::SYS_accept);
deny_syscall(&mut rules, libc::SYS_accept4);
deny_syscall(&mut rules, libc::SYS_bind);
deny_syscall(&mut rules, libc::SYS_listen);
deny_syscall(&mut rules, libc::SYS_getpeername);
deny_syscall(&mut rules, libc::SYS_getsockname);
deny_syscall(&mut rules, libc::SYS_shutdown);
deny_syscall(&mut rules, libc::SYS_sendto);
deny_syscall(&mut rules, libc::SYS_sendmmsg);
// NOTE: allowing recvfrom allows some tools like: `cargo clippy`
// to run with their socketpair + child processes for sub-proc
// management.
// deny_syscall(&mut rules, libc::SYS_recvfrom);
deny_syscall(&mut rules, libc::SYS_recvmmsg);
deny_syscall(&mut rules, libc::SYS_getsockopt);
deny_syscall(&mut rules, libc::SYS_setsockopt);
// For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny everything else.
let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new(
0, // first argument (domain)
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::AF_UNIX as u64,
)?])?;
// For `socket` we allow AF_UNIX (arg0 == AF_UNIX) and deny
// everything else.
let unix_only_rule = SeccompRule::new(vec![SeccompCondition::new(
0, // first argument (domain)
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::AF_UNIX as u64,
)?])?;
rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]);
rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); // always deny (Unix can use socketpair but fine, keep open?)
rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]);
rules.insert(libc::SYS_socketpair, vec![unix_only_rule]);
}
NetworkSeccompMode::ProxyRouted => {
// In proxy-routed mode we allow IP sockets in the isolated
// namespace (used to reach the local TCP bridge) but deny all
// other socket families, including AF_UNIX. This prevents
// bypassing the routed bridge via new Unix sockets and narrows the
// socket surface in proxy-only mode.
let deny_non_ip_socket = SeccompRule::new(vec![
SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::AF_INET as u64,
)?,
SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Ne,
libc::AF_INET6 as u64,
)?,
])?;
let deny_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new(
0,
SeccompCmpArgLen::Dword,
SeccompCmpOp::Eq,
libc::AF_UNIX as u64,
)?])?;
rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]);
rules.insert(libc::SYS_socketpair, vec![deny_unix_socketpair]);
}
}
let filter = SeccompFilter::new(
rules,
@ -203,6 +263,8 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(),
#[cfg(test)]
mod tests {
use super::NetworkSeccompMode;
use super::network_seccomp_mode;
use super::should_install_network_seccomp;
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
@ -234,4 +296,28 @@ mod tests {
true
));
}
#[test]
fn managed_proxy_routes_use_proxy_routed_seccomp_mode() {
assert_eq!(
network_seccomp_mode(&SandboxPolicy::DangerFullAccess, true, true),
Some(NetworkSeccompMode::ProxyRouted)
);
}
#[test]
fn restricted_network_without_proxy_routing_uses_restricted_mode() {
assert_eq!(
network_seccomp_mode(&SandboxPolicy::new_read_only_policy(), false, false),
Some(NetworkSeccompMode::Restricted)
);
}
#[test]
fn full_network_without_managed_proxy_skips_network_seccomp_mode() {
assert_eq!(
network_seccomp_mode(&SandboxPolicy::DangerFullAccess, false, false),
None
);
}
}

View file

@ -10,6 +10,8 @@ mod landlock;
#[cfg(target_os = "linux")]
mod linux_run_main;
#[cfg(target_os = "linux")]
mod proxy_routing;
#[cfg(target_os = "linux")]
mod vendored_bwrap;
#[cfg(target_os = "linux")]

View file

@ -10,6 +10,8 @@ use crate::bwrap::BwrapNetworkMode;
use crate::bwrap::BwrapOptions;
use crate::bwrap::create_bwrap_command_args;
use crate::landlock::apply_sandbox_policy_to_current_thread;
use crate::proxy_routing::activate_proxy_routes_in_netns;
use crate::proxy_routing::prepare_host_proxy_route_spec;
use crate::vendored_bwrap::exec_vendored_bwrap;
use crate::vendored_bwrap::run_vendored_bwrap_main;
@ -44,11 +46,15 @@ pub struct LandlockCommand {
/// Internal compatibility flag.
///
/// By default, restricted-network sandboxing uses isolated networking.
/// If set, sandbox setup switches to proxy-only network mode
/// (currently enforced the same as isolated networking).
/// If set, sandbox setup switches to proxy-only network mode with
/// managed routing bridges.
#[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)]
pub allow_network_for_proxy: bool,
/// Internal route spec used for managed proxy routing in bwrap mode.
#[arg(long = "proxy-route-spec", hide = true)]
pub proxy_route_spec: Option<String>,
/// When set, skip mounting a fresh `/proc` even though PID isolation is
/// still enabled. This is primarily intended for restrictive container
/// environments that deny `--proc /proc`.
@ -74,6 +80,7 @@ pub fn run_main() -> ! {
use_bwrap_sandbox,
apply_seccomp_then_exec,
allow_network_for_proxy,
proxy_route_spec,
no_proc,
command,
} = LandlockCommand::parse();
@ -81,15 +88,26 @@ pub fn run_main() -> ! {
if command.is_empty() {
panic!("No command specified to execute.");
}
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_bwrap_sandbox);
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
// established the filesystem view.
if apply_seccomp_then_exec {
if allow_network_for_proxy {
let spec = proxy_route_spec
.as_deref()
.unwrap_or_else(|| panic!("managed proxy mode requires --proxy-route-spec"));
if let Err(err) = activate_proxy_routes_in_netns(spec) {
panic!("error activating Linux proxy routing bridge: {err}");
}
}
let proxy_routing_active = allow_network_for_proxy;
if let Err(e) = apply_sandbox_policy_to_current_thread(
&sandbox_policy,
&sandbox_policy_cwd,
false,
allow_network_for_proxy,
proxy_routing_active,
) {
panic!("error applying Linux sandbox restrictions: {e:?}");
}
@ -102,6 +120,7 @@ pub fn run_main() -> ! {
&sandbox_policy_cwd,
false,
allow_network_for_proxy,
false,
) {
panic!("error applying Linux sandbox restrictions: {e:?}");
}
@ -112,11 +131,20 @@ pub fn run_main() -> ! {
// Outer stage: bubblewrap first, then re-enter this binary in the
// sandboxed environment to apply seccomp. This path never falls back
// to legacy Landlock on failure.
let proxy_route_spec =
if allow_network_for_proxy {
Some(prepare_host_proxy_route_spec().unwrap_or_else(|err| {
panic!("failed to prepare host proxy routing bridge: {err}")
}))
} else {
None
};
let inner = build_inner_seccomp_command(
&sandbox_policy_cwd,
&sandbox_policy,
use_bwrap_sandbox,
allow_network_for_proxy,
proxy_route_spec,
command,
);
run_bwrap_with_proc_fallback(
@ -134,12 +162,19 @@ pub fn run_main() -> ! {
&sandbox_policy_cwd,
true,
allow_network_for_proxy,
false,
) {
panic!("error applying legacy Linux sandbox restrictions: {e:?}");
}
exec_or_panic(command);
}
fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool) {
if apply_seccomp_then_exec && !use_bwrap_sandbox {
panic!("--apply-seccomp-then-exec requires --use-bwrap-sandbox");
}
}
fn run_bwrap_with_proc_fallback(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
@ -147,14 +182,15 @@ fn run_bwrap_with_proc_fallback(
mount_proc: bool,
allow_network_for_proxy: bool,
) -> ! {
let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy);
let mut mount_proc = mount_proc;
if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy) {
if mount_proc && !preflight_proc_mount_support(sandbox_policy_cwd, sandbox_policy, network_mode)
{
eprintln!("codex-linux-sandbox: bwrap could not mount /proc; retrying with --no-proc");
mount_proc = false;
}
let network_mode = bwrap_network_mode(sandbox_policy, allow_network_for_proxy);
let options = BwrapOptions {
mount_proc,
network_mode,
@ -202,19 +238,29 @@ fn build_bwrap_argv(
fn preflight_proc_mount_support(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
network_mode: BwrapNetworkMode,
) -> bool {
let preflight_argv =
build_preflight_bwrap_argv(sandbox_policy_cwd, sandbox_policy, network_mode);
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
!is_proc_mount_failure(stderr.as_str())
}
fn build_preflight_bwrap_argv(
sandbox_policy_cwd: &Path,
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
network_mode: BwrapNetworkMode,
) -> Vec<String> {
let preflight_command = vec![resolve_true_command()];
let preflight_argv = build_bwrap_argv(
build_bwrap_argv(
preflight_command,
sandbox_policy,
sandbox_policy_cwd,
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
network_mode,
},
);
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
!is_proc_mount_failure(stderr.as_str())
)
}
fn resolve_true_command() -> String {
@ -318,6 +364,7 @@ fn build_inner_seccomp_command(
sandbox_policy: &codex_protocol::protocol::SandboxPolicy,
use_bwrap_sandbox: bool,
allow_network_for_proxy: bool,
proxy_route_spec: Option<String>,
command: Vec<String>,
) -> Vec<String> {
let current_exe = match std::env::current_exe() {
@ -342,6 +389,10 @@ fn build_inner_seccomp_command(
}
if allow_network_for_proxy {
inner.push("--allow-network-for-proxy".to_string());
let proxy_route_spec = proxy_route_spec
.unwrap_or_else(|| panic!("managed proxy mode requires a proxy route spec"));
inner.push("--proxy-route-spec".to_string());
inner.push(proxy_route_spec);
}
inner.push("--".to_string());
inner.extend(command);
@ -371,100 +422,5 @@ fn exec_or_panic(command: Vec<String>) -> ! {
panic!("Failed to execvp {}: {err}", command[0].as_str());
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
#[test]
fn detects_proc_mount_invalid_argument_failure() {
let stderr = "bwrap: Can't mount proc on /newroot/proc: Invalid argument";
assert_eq!(is_proc_mount_failure(stderr), true);
}
#[test]
fn detects_proc_mount_operation_not_permitted_failure() {
let stderr = "bwrap: Can't mount proc on /newroot/proc: Operation not permitted";
assert_eq!(is_proc_mount_failure(stderr), true);
}
#[test]
fn detects_proc_mount_permission_denied_failure() {
let stderr = "bwrap: Can't mount proc on /newroot/proc: Permission denied";
assert_eq!(is_proc_mount_failure(stderr), true);
}
#[test]
fn ignores_non_proc_mount_errors() {
let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted";
assert_eq!(is_proc_mount_failure(stderr), false);
}
#[test]
fn inserts_bwrap_argv0_before_command_separator() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::new_read_only_policy(),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
},
);
assert_eq!(
argv,
vec![
"bwrap".to_string(),
"--new-session".to_string(),
"--die-with-parent".to_string(),
"--ro-bind".to_string(),
"/".to_string(),
"/".to_string(),
"--dev".to_string(),
"/dev".to_string(),
"--unshare-pid".to_string(),
"--proc".to_string(),
"/proc".to_string(),
"--argv0".to_string(),
"codex-linux-sandbox".to_string(),
"--".to_string(),
"/bin/true".to_string(),
]
);
}
#[test]
fn inserts_unshare_net_when_network_isolation_requested() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::new_read_only_policy(),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::Isolated,
},
);
assert_eq!(argv.contains(&"--unshare-net".to_string()), true);
}
#[test]
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::new_read_only_policy(),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::ProxyOnly,
},
);
assert_eq!(argv.contains(&"--unshare-net".to_string()), true);
}
#[test]
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
assert_eq!(mode, BwrapNetworkMode::ProxyOnly);
}
}
#[path = "linux_run_main_tests.rs"]
mod tests;

View file

@ -0,0 +1,159 @@
#[cfg(test)]
use super::*;
#[cfg(test)]
use codex_protocol::protocol::SandboxPolicy;
#[test]
fn detects_proc_mount_invalid_argument_failure() {
let stderr = "bwrap: Can't mount proc on /newroot/proc: Invalid argument";
assert!(is_proc_mount_failure(stderr));
}
#[test]
fn detects_proc_mount_operation_not_permitted_failure() {
let stderr = "bwrap: Can't mount proc on /newroot/proc: Operation not permitted";
assert!(is_proc_mount_failure(stderr));
}
#[test]
fn detects_proc_mount_permission_denied_failure() {
let stderr = "bwrap: Can't mount proc on /newroot/proc: Permission denied";
assert!(is_proc_mount_failure(stderr));
}
#[test]
fn ignores_non_proc_mount_errors() {
let stderr = "bwrap: Can't bind mount /dev/null: Operation not permitted";
assert!(!is_proc_mount_failure(stderr));
}
#[test]
fn inserts_bwrap_argv0_before_command_separator() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::new_read_only_policy(),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::FullAccess,
},
);
assert_eq!(
argv,
vec![
"bwrap".to_string(),
"--new-session".to_string(),
"--die-with-parent".to_string(),
"--ro-bind".to_string(),
"/".to_string(),
"/".to_string(),
"--dev".to_string(),
"/dev".to_string(),
"--unshare-pid".to_string(),
"--proc".to_string(),
"/proc".to_string(),
"--argv0".to_string(),
"codex-linux-sandbox".to_string(),
"--".to_string(),
"/bin/true".to_string(),
]
);
}
#[test]
fn inserts_unshare_net_when_network_isolation_requested() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::new_read_only_policy(),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::Isolated,
},
);
assert!(argv.contains(&"--unshare-net".to_string()));
}
#[test]
fn inserts_unshare_net_when_proxy_only_network_mode_requested() {
let argv = build_bwrap_argv(
vec!["/bin/true".to_string()],
&SandboxPolicy::new_read_only_policy(),
Path::new("/"),
BwrapOptions {
mount_proc: true,
network_mode: BwrapNetworkMode::ProxyOnly,
},
);
assert!(argv.contains(&"--unshare-net".to_string()));
}
#[test]
fn proxy_only_mode_takes_precedence_over_full_network_policy() {
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
assert_eq!(mode, BwrapNetworkMode::ProxyOnly);
}
#[test]
fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() {
let mode = bwrap_network_mode(&SandboxPolicy::DangerFullAccess, true);
let argv = build_preflight_bwrap_argv(Path::new("/"), &SandboxPolicy::DangerFullAccess, mode);
assert!(argv.iter().any(|arg| arg == "--"));
}
#[test]
fn managed_proxy_inner_command_includes_route_spec() {
let args = build_inner_seccomp_command(
Path::new("/tmp"),
&SandboxPolicy::new_read_only_policy(),
true,
true,
Some("{\"routes\":[]}".to_string()),
vec!["/bin/true".to_string()],
);
assert!(args.iter().any(|arg| arg == "--proxy-route-spec"));
assert!(args.iter().any(|arg| arg == "{\"routes\":[]}"));
}
#[test]
fn non_managed_inner_command_omits_route_spec() {
let args = build_inner_seccomp_command(
Path::new("/tmp"),
&SandboxPolicy::new_read_only_policy(),
true,
false,
None,
vec!["/bin/true".to_string()],
);
assert!(!args.iter().any(|arg| arg == "--proxy-route-spec"));
}
#[test]
fn managed_proxy_inner_command_requires_route_spec() {
let result = std::panic::catch_unwind(|| {
build_inner_seccomp_command(
Path::new("/tmp"),
&SandboxPolicy::new_read_only_policy(),
true,
true,
None,
vec!["/bin/true".to_string()],
)
});
assert!(result.is_err());
}
#[test]
fn apply_seccomp_then_exec_without_bwrap_panics() {
let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false));
assert!(result.is_err());
}
#[test]
fn valid_inner_stage_modes_do_not_panic() {
ensure_inner_stage_mode_is_valid(false, false);
ensure_inner_stage_mode_is_valid(false, true);
ensure_inner_stage_mode_is_valid(true, true);
}

View file

@ -0,0 +1,796 @@
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::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";
#[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 env: HashMap<String, String> = std::env::vars().collect();
let plan = plan_proxy_routes(&env);
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)?);
}
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)
}
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();
for attempt in 0..128 {
let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{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 ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() {
return candidate;
}
}
std::env::temp_dir()
}
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) -> 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);
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) -> io::Result<()> {
set_parent_death_signal()?;
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);
loop {
let (unix_stream, _) = listener.accept()?;
std::thread::spawn(move || {
let tcp_stream = match TcpStream::connect(endpoint) {
Ok(stream) => stream,
Err(_) => 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<()> {
set_parent_death_signal()?;
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 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_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::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::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 rewrites_proxy_url_to_local_loopback_port() {
let rewritten =
rewrite_proxy_env_value("socks5h://127.0.0.1:8081", 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 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-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);
}
}

View file

@ -0,0 +1,313 @@
#![cfg(target_os = "linux")]
#![allow(clippy::unwrap_used)]
use codex_core::config::types::ShellEnvironmentPolicy;
use codex_core::exec_env::create_env;
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::io::Read;
use std::io::Write;
use std::net::Ipv4Addr;
use std::net::TcpListener;
use std::process::Output;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build.";
const NETWORK_TIMEOUT_MS: u64 = 4_000;
const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[
"loopback: Failed RTM_NEWADDR",
"loopback: Failed RTM_NEWLINK",
"setting up uid map: Permission denied",
"No permissions to create a new namespace",
"error isolating Linux network namespace for proxy mode",
];
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",
];
fn create_env_from_core_vars() -> HashMap<String, String> {
let policy = ShellEnvironmentPolicy::default();
create_env(&policy, None)
}
fn strip_proxy_env(env: &mut HashMap<String, String>) {
for key in PROXY_ENV_KEYS {
env.remove(*key);
let lower = key.to_ascii_lowercase();
env.remove(lower.as_str());
}
}
fn is_bwrap_unavailable_output(output: &Output) -> bool {
String::from_utf8_lossy(&output.stderr).contains(BWRAP_UNAVAILABLE_ERR)
}
async fn should_skip_bwrap_tests() -> bool {
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
let output = run_linux_sandbox_direct(
&["bash", "-c", "true"],
&SandboxPolicy::new_read_only_policy(),
false,
env,
NETWORK_TIMEOUT_MS,
)
.await;
is_bwrap_unavailable_output(&output)
}
fn is_managed_proxy_permission_error(stderr: &str) -> bool {
MANAGED_PROXY_PERMISSION_ERR_SNIPPETS
.iter()
.any(|snippet| stderr.contains(snippet))
}
async fn managed_proxy_skip_reason() -> Option<String> {
if should_skip_bwrap_tests().await {
return Some("vendored bwrap was not built in this environment".to_string());
}
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
let output = run_linux_sandbox_direct(
&["bash", "-c", "true"],
&SandboxPolicy::DangerFullAccess,
true,
env,
NETWORK_TIMEOUT_MS,
)
.await;
if output.status.success() {
return None;
}
let stderr = String::from_utf8_lossy(&output.stderr);
if is_managed_proxy_permission_error(stderr.as_ref()) {
return Some(format!(
"managed proxy requires kernel namespace privileges unavailable here: {}",
stderr.trim()
));
}
None
}
async fn run_linux_sandbox_direct(
command: &[&str],
sandbox_policy: &SandboxPolicy,
allow_network_for_proxy: bool,
env: HashMap<String, String>,
timeout_ms: u64,
) -> Output {
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(err) => panic!("cwd should exist: {err}"),
};
let policy_json = match serde_json::to_string(sandbox_policy) {
Ok(policy_json) => policy_json,
Err(err) => panic!("policy should serialize: {err}"),
};
let mut args = vec![
"--sandbox-policy-cwd".to_string(),
cwd.to_string_lossy().to_string(),
"--sandbox-policy".to_string(),
policy_json,
"--use-bwrap-sandbox".to_string(),
];
if allow_network_for_proxy {
args.push("--allow-network-for-proxy".to_string());
}
args.push("--".to_string());
args.extend(command.iter().map(|entry| (*entry).to_string()));
let mut cmd = Command::new(env!("CARGO_BIN_EXE_codex-linux-sandbox"));
cmd.args(args)
.current_dir(cwd)
.env_clear()
.envs(env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output()).await {
Ok(output) => output,
Err(err) => panic!("sandbox command should not time out: {err}"),
};
match output {
Ok(output) => output,
Err(err) => panic!("sandbox command should execute: {err}"),
}
}
#[tokio::test]
async fn managed_proxy_mode_fails_closed_without_proxy_env() {
if let Some(skip_reason) = managed_proxy_skip_reason().await {
eprintln!("skipping managed proxy test: {skip_reason}");
return;
}
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
let output = run_linux_sandbox_direct(
&["bash", "-c", "true"],
&SandboxPolicy::DangerFullAccess,
true,
env,
NETWORK_TIMEOUT_MS,
)
.await;
assert_eq!(output.status.success(), false);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("managed proxy mode requires proxy environment variables"),
"expected fail-closed managed-proxy message, got stderr: {stderr}"
);
}
#[tokio::test]
async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() {
if let Some(skip_reason) = managed_proxy_skip_reason().await {
eprintln!("skipping managed proxy test: {skip_reason}");
return;
}
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind proxy listener");
let proxy_port = listener
.local_addr()
.expect("proxy listener local addr")
.port();
let (request_tx, request_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept proxy connection");
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.expect("set read timeout");
let mut buf = [0_u8; 4096];
let read = stream.read(&mut buf).expect("read proxy request");
let request = String::from_utf8_lossy(&buf[..read]).to_string();
request_tx.send(request).expect("send proxy request");
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
.expect("write proxy response");
});
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
env.insert(
"HTTP_PROXY".to_string(),
format!("http://127.0.0.1:{proxy_port}"),
);
let routed_output = run_linux_sandbox_direct(
&[
"bash",
"-c",
"proxy=\"${HTTP_PROXY#*://}\"; host=\"${proxy%%:*}\"; port=\"${proxy##*:}\"; exec 3<>/dev/tcp/${host}/${port}; printf 'GET http://example.com/ HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n' >&3; IFS= read -r line <&3; printf '%s\\n' \"$line\"",
],
&SandboxPolicy::DangerFullAccess,
true,
env.clone(),
NETWORK_TIMEOUT_MS,
)
.await;
assert_eq!(
routed_output.status.success(),
true,
"expected routed command to execute successfully; status={:?}; stdout={}; stderr={}",
routed_output.status.code(),
String::from_utf8_lossy(&routed_output.stdout),
String::from_utf8_lossy(&routed_output.stderr)
);
let stdout = String::from_utf8_lossy(&routed_output.stdout);
assert!(
stdout.contains("HTTP/1.1 200 OK"),
"expected bridge-routed proxy response, got stdout: {stdout}"
);
let request = request_rx
.recv_timeout(Duration::from_secs(3))
.expect("expected proxy request");
assert!(
request.contains("GET http://example.com/ HTTP/1.1"),
"expected HTTP proxy absolute-form request, got request: {request}"
);
let direct_egress_output = run_linux_sandbox_direct(
&["bash", "-c", "echo hi > /dev/tcp/192.0.2.1/80"],
&SandboxPolicy::DangerFullAccess,
true,
env,
NETWORK_TIMEOUT_MS,
)
.await;
assert_eq!(direct_egress_output.status.success(), false);
}
#[tokio::test]
async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() {
if let Some(skip_reason) = managed_proxy_skip_reason().await {
eprintln!("skipping managed proxy test: {skip_reason}");
return;
}
let python_available = Command::new("bash")
.arg("-c")
.arg("command -v python3 >/dev/null")
.status()
.await
.expect("python3 probe should execute")
.success();
if !python_available {
eprintln!("skipping managed proxy AF_UNIX test: python3 is unavailable");
return;
}
let mut env = create_env_from_core_vars();
strip_proxy_env(&mut env);
env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string());
let output = run_linux_sandbox_direct(
&[
"python3",
"-c",
"import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n sys.exit(0)\nexcept OSError:\n sys.exit(2)\nsys.exit(1)\n",
],
&SandboxPolicy::DangerFullAccess,
true,
env,
NETWORK_TIMEOUT_MS,
)
.await;
assert_eq!(
output.status.code(),
Some(0),
"expected AF_UNIX creation to be denied cleanly for user command; status={:?}; stdout={}; stderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}

View file

@ -1,2 +1,3 @@
// Aggregates all former standalone integration tests as modules.
mod landlock;
mod managed_proxy;