diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 03276a7ad..31bc8d65a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1921,9 +1921,11 @@ dependencies = [ "pkg-config", "pretty_assertions", "seccompiler", + "serde", "serde_json", "tempfile", "tokio", + "url", ] [[package]] diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 63af5c81f..88da2d747 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -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 } diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 0abd64170..b03919c96 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -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`. diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 854774b78..d1fefc174 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -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 { 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(), + ] + ); + } } diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 621bfd5b6..f9477a126 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -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 { + 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>, nr: i64) { + rules.insert(nr, vec![]); // empty rule vec = unconditional match + } + // Build rule map. let mut rules: BTreeMap> = 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 + ); + } } diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 3347f3f92..e364c1925 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -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")] diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 84e7cb34f..c9fdd06a3 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -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, + /// 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 { 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, command: Vec, ) -> Vec { 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) -> ! { 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; diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs new file mode 100644 index 000000000..11471afbe --- /dev/null +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -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); +} diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs new file mode 100644 index 000000000..f57472c8a --- /dev/null +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -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, +} + +#[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, + has_proxy_config: bool, +} + +pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { + let env: HashMap = 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 = 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 = 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) -> 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 { + 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::().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 { + 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 { + 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 { + let suffix = file_name.strip_prefix(PROXY_SOCKET_DIR_PREFIX)?; + let (pid_raw, _) = suffix.split_once('-')?; + pid_raw.parse::().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, +) -> 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 { + 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 { + 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 { + 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::() }; + 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::() }; + 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::() + .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::() + .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); + } +} diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs new file mode 100644 index 000000000..e27930cdb --- /dev/null +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -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 { + let policy = ShellEnvironmentPolicy::default(); + create_env(&policy, None) +} + +fn strip_proxy_env(env: &mut HashMap) { + 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 { + 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, + 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) + ); +} diff --git a/codex-rs/linux-sandbox/tests/suite/mod.rs b/codex-rs/linux-sandbox/tests/suite/mod.rs index d2a6bfa14..8ef44a61a 100644 --- a/codex-rs/linux-sandbox/tests/suite/mod.rs +++ b/codex-rs/linux-sandbox/tests/suite/mod.rs @@ -1,2 +1,3 @@ // Aggregates all former standalone integration tests as modules. mod landlock; +mod managed_proxy;