From 99914f49504532f551ff6cdceca4318afdbd3d9c Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 14 Sep 2026 22:23:29 +0000 Subject: [PATCH] Honor explicit Unix socket grants in the Linux managed sandbox (#45534) ## Why Linux proxy-routed sandboxing denied standalone Unix sockets even when the effective network policy enabled `dangerously_allow_all_unix_sockets`. ## What changed - Carry Unix socket permissions in `ManagedNetworkSandboxContext` and pass the prepared context through Linux sandbox launches with `--managed-network`. - Allow `AF_UNIX` socket creation in proxy-routed mode when `dangerously_allow_all_unix_sockets` is enabled, while preserving network namespace isolation and restrictions on other socket families. - Keep standalone Unix sockets denied by default and for path-only grants. Default missing fields in older serialized contexts to restrictive values. ## Testing Add coverage for policy preparation and transport, legacy deserialization, and malformed policy rejection. Add a Linux integration test covering default denial, path-only denial, and explicit allow-all access, while checking that direct TCP access and `AF_NETLINK`/`AF_VSOCK` sockets remain blocked. GitOrigin-RevId: 2695b945ad3e59fcb3faf7662d852a26650af16c --- codex-rs/cli/src/debug_sandbox.rs | 17 +- codex-rs/core/src/tools/sandboxing_tests.rs | 1 + .../src/unified_exec/process_manager_tests.rs | 1 + codex-rs/exec-server-protocol/src/protocol.rs | 50 +++++ .../exec-server/src/process_sandbox_tests.rs | 1 + codex-rs/linux-sandbox/src/landlock.rs | 33 ++-- codex-rs/linux-sandbox/src/linux_run_main.rs | 47 +++-- .../linux-sandbox/src/linux_run_main_tests.rs | 77 ++++++-- .../tests/suite/managed_proxy.rs | 6 +- .../suite/managed_proxy_unix_sockets_tests.rs | 187 ++++++++++++++++++ codex-rs/mxc-sandbox/src/policy_tests.rs | 2 + codex-rs/network-proxy/src/proxy.rs | 64 ++++++ .../src/proxy/managed_routing_tests.rs | 1 + codex-rs/sandboxing/src/landlock.rs | 20 +- codex-rs/sandboxing/src/landlock_tests.rs | 11 +- codex-rs/sandboxing/src/manager.rs | 23 ++- codex-rs/sandboxing/src/manager_tests.rs | 125 ++++++++++++ codex-rs/sandboxing/src/seatbelt_tests.rs | 1 + 18 files changed, 599 insertions(+), 68 deletions(-) create mode 100644 codex-rs/linux-sandbox/tests/suite/managed_proxy_unix_sockets_tests.rs diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 1ffc0eed0d..b17f7aef30 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -23,7 +23,6 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_sandboxing::landlock::allow_network_for_proxy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission_profile; #[cfg(target_os = "macos")] use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams; @@ -364,6 +363,7 @@ async fn run_command_under_sandbox( .map(codex_core::config::StartedNetworkProxy::proxy); // Proxy containment depends on whether a proxy is active, not whether its // policy came from managed requirements. + #[cfg(target_os = "macos")] let enforce_managed_network = network.is_some(); let managed_mitm_ca_trust_bundle_path = match network.as_ref() { Some(network) => network.managed_mitm_ca_trust_bundle_path(), @@ -423,13 +423,20 @@ async fn run_command_under_sandbox( .codex_linux_sandbox_exe .expect("codex-linux-sandbox executable not found"); let network_sandbox_policy = runtime_permission_profile.network_sandbox_policy(); + let (env, managed_network) = if let Some(network) = network.as_ref() { + let prepared = + network.prepare_for_optional_environment(env, /*environment_id*/ None)?; + (prepared.env, Some(prepared.sandbox_context)) + } else { + (env, None) + }; let args = create_linux_sandbox_command_args_for_permission_profile( command, cwd.as_path(), &runtime_permission_profile, sandbox_policy_cwd.as_path(), use_legacy_landlock, - allow_network_for_proxy(enforce_managed_network), + managed_network.as_ref(), ); spawn_debug_sandbox_child( codex_linux_sandbox_exe, @@ -438,11 +445,7 @@ async fn run_command_under_sandbox( cwd.to_path_buf(), network_sandbox_policy, env, - |env_map| { - if let Some(network) = network.as_ref() { - network.apply_to_env(env_map); - } - }, + |_| {}, ) .await? } diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 3f47ae13e6..f126685631 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -336,6 +336,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { let managed_network = ManagedNetworkSandboxContext { loopback_ports: vec![43123], allow_local_binding: false, + ..Default::default() }; let command = || SandboxCommand { program: "/bin/bash".into(), diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 29f9e8839c..8be6eda337 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -168,6 +168,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { let managed_network = ManagedNetworkSandboxContext { loopback_ports: vec![43123], allow_local_binding: false, + ..Default::default() }; let mut request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index 0dc0d51e74..bf6ea86335 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -962,6 +962,8 @@ mod tests { managed_network: Some(ManagedNetworkSandboxContext { loopback_ports: vec![43123, 48081], allow_local_binding: false, + allow_unix_sockets: vec!["/tmp/allowed.sock".to_string()], + dangerously_allow_all_unix_sockets: true, }), network_proxy: Some( RemoteNetworkProxyLaunchConfig::new( @@ -990,6 +992,8 @@ mod tests { serde_json::json!({ "loopbackPorts": [43123, 48081], "allowLocalBinding": false, + "allowUnixSockets": ["/tmp/allowed.sock"], + "dangerouslyAllowAllUnixSockets": true, }) ); assert_eq!( @@ -1023,6 +1027,52 @@ mod tests { assert!(legacy_serialized.get("metadata").is_none()); } + #[test] + fn exec_params_defaults_legacy_managed_network_unix_socket_policy() { + let cwd = + PathUri::from_host_native_path(std::env::current_dir().expect("current directory")) + .expect("cwd URI"); + let legacy: ExecParams = serde_json::from_value(serde_json::json!({ + "processId": "legacy-managed-network", + "argv": ["true"], + "cwd": cwd, + "env": {}, + "tty": false, + "arg0": null, + "enforceManagedNetwork": true, + "managedNetwork": { + "loopbackPorts": [43123], + "allowLocalBinding": true, + }, + })) + .expect("deserialize legacy managed network context"); + + assert_eq!( + legacy, + ExecParams { + process_id: ProcessId::from("legacy-managed-network"), + metadata: None, + argv: vec!["true".to_string()], + cwd, + env_policy: None, + shell_snapshot: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: None, + enforce_managed_network: true, + managed_network: Some(ManagedNetworkSandboxContext { + loopback_ports: vec![43123], + allow_local_binding: true, + allow_unix_sockets: Vec::new(), + dangerously_allow_all_unix_sockets: false, + }), + network_proxy: None, + } + ); + } + #[test] fn environment_info_accepts_legacy_response_without_cwd() { let info: EnvironmentInfo = serde_json::from_value(serde_json::json!({ diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index e110481e60..0e80e938dd 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -240,6 +240,7 @@ async fn sandbox_request_allows_prepared_managed_proxy_port() { managed_network: Some(ManagedNetworkSandboxContext { loopback_ports: vec![43123], allow_local_binding: false, + ..Default::default() }), network_proxy: None, }; diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 0d07441107..140daaf8af 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use std::path::Path; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::error::SandboxErr; @@ -43,15 +44,15 @@ pub(crate) fn apply_permission_profile_to_current_thread( permission_profile: &PermissionProfile, cwd: &Path, apply_landlock_fs: bool, - allow_network_for_proxy: bool, - proxy_routed_network: bool, + managed_network: Option<&ManagedNetworkSandboxContext>, + proxy_routing_active: bool, ) -> Result<()> { let (file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); let network_seccomp_mode = network_seccomp_mode( network_sandbox_policy, - allow_network_for_proxy, - proxy_routed_network, + managed_network.is_some(), + proxy_routing_active, ) .or_else(|| { // VM sockets can reach host services outside the filesystem sandbox. @@ -73,7 +74,7 @@ pub(crate) fn apply_permission_profile_to_current_thread( } if let Some(mode) = network_seccomp_mode { - install_network_seccomp_filter_on_current_thread(mode)?; + install_network_seccomp_filter_on_current_thread(mode, managed_network)?; } if apply_landlock_fs && !file_system_sandbox_policy.has_full_disk_write_access() { @@ -177,6 +178,7 @@ fn install_filesystem_landlock_rules_on_current_thread( /// inherits it. fn install_network_seccomp_filter_on_current_thread( mode: NetworkSeccompMode, + managed_network: Option<&ManagedNetworkSandboxContext>, ) -> std::result::Result<(), SandboxErr> { fn deny_syscall(rules: &mut BTreeMap>, nr: i64) { rules.insert(nr, vec![]); // empty rule vec = unconditional match @@ -230,12 +232,10 @@ fn install_network_seccomp_filter_on_current_thread( } NetworkSeccompMode::ProxyRouted => { // In proxy-routed mode we allow IP sockets in the isolated - // namespace (used to reach the local TCP bridge) but deny socket() - // for all other families, including AF_UNIX. Only AF_UNIX - // socketpair() remains available for process-local IPC because it - // cannot connect to a socket outside the sandbox or bypass the - // bridge. - let deny_non_ip_socket = SeccompRule::new(vec![ + // namespace (used to reach the local TCP bridge). Standalone Unix + // sockets require an explicit managed-policy grant; all other + // socket families remain denied. + let mut denied_socket_conditions = vec![ SeccompCondition::new( 0, SeccompCmpArgLen::Dword, @@ -248,7 +248,16 @@ fn install_network_seccomp_filter_on_current_thread( SeccompCmpOp::Ne, libc::AF_INET6 as u64, )?, - ])?; + ]; + if managed_network.is_some_and(|context| context.dangerously_allow_all_unix_sockets) { + denied_socket_conditions.push(SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_UNIX as u64, + )?); + } + let deny_non_ip_socket = SeccompRule::new(denied_socket_conditions)?; let deny_non_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new( 0, SeccompCmpArgLen::Dword, diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index bcdaa23ba0..6ce369bc03 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -29,6 +29,7 @@ use crate::launcher::exec_bwrap; use crate::launcher::preferred_bwrap_supports_argv0; use crate::proxy_routing::activate_proxy_routes_in_netns; use crate::proxy_routing::prepare_host_proxy_route_spec; +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::FileSystemAccessMode; @@ -123,13 +124,13 @@ pub struct LandlockCommand { #[arg(long = "apply-seccomp-then-exec", hide = true, default_value_t = false)] pub apply_seccomp_then_exec: bool, - /// Internal compatibility flag. - /// - /// By default, restricted-network sandboxing uses 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, + /// Effective managed-network policy prepared for this command launch. + #[arg( + long, + hide = true, + value_parser = parse_managed_network + )] + pub managed_network: Option, /// Internal route spec used for managed proxy routing in bwrap mode. #[arg(long = "proxy-route-spec", hide = true)] @@ -164,12 +165,13 @@ pub fn run_main() -> ! { permission_profile, use_legacy_landlock, apply_seccomp_then_exec, - allow_network_for_proxy, + managed_network, proxy_route_spec, verify_fd_mounts, no_proc, command, } = LandlockCommand::parse(); + let allow_network_for_proxy = managed_network.is_some(); if command.is_empty() { panic!("No command specified to execute."); @@ -236,7 +238,7 @@ pub fn run_main() -> ! { &permission_profile, &sandbox_policy_cwd, /*apply_landlock_fs*/ false, - allow_network_for_proxy, + managed_network.as_ref(), proxy_routing_active, ) { panic!("error applying Linux sandbox restrictions: {e:?}"); @@ -282,8 +284,8 @@ pub fn run_main() -> ! { &permission_profile, &sandbox_policy_cwd, /*apply_landlock_fs*/ false, - allow_network_for_proxy, - /*proxy_routed_network*/ false, + managed_network.as_ref(), + /*proxy_routing_active*/ false, ) { panic!("error applying Linux sandbox restrictions: {e:?}"); } @@ -305,7 +307,7 @@ pub fn run_main() -> ! { sandbox_policy_cwd: &sandbox_policy_cwd, command_cwd: command_cwd.as_deref(), permission_profile: &permission_profile, - allow_network_for_proxy, + managed_network, proxy_route_spec, command, }); @@ -325,8 +327,8 @@ pub fn run_main() -> ! { &permission_profile, &sandbox_policy_cwd, /*apply_landlock_fs*/ true, - allow_network_for_proxy, - /*proxy_routed_network*/ false, + managed_network.as_ref(), + /*proxy_routing_active*/ false, ) { panic!("error applying legacy Linux sandbox restrictions: {e:?}"); } @@ -357,6 +359,11 @@ fn parse_permission_profile(value: &str) -> std::result::Result std::result::Result { + serde_json::from_str(value) + .map_err(|err| format!("invalid managed network context JSON: {err}")) +} + fn resolve_permission_profile( permission_profile: Option, ) -> Result { @@ -1521,7 +1528,7 @@ struct InnerSeccompCommandArgs<'a> { sandbox_policy_cwd: &'a Path, command_cwd: Option<&'a Path>, permission_profile: &'a PermissionProfile, - allow_network_for_proxy: bool, + managed_network: Option, proxy_route_spec: Option, command: Vec, } @@ -1532,7 +1539,7 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec sandbox_policy_cwd, command_cwd, permission_profile, - allow_network_for_proxy, + managed_network, proxy_route_spec, command, } = args; @@ -1559,8 +1566,12 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec permission_profile_json, "--apply-seccomp-then-exec".to_string(), ]); - if allow_network_for_proxy { - inner.push("--allow-network-for-proxy".to_string()); + if let Some(managed_network) = managed_network { + inner.push("--managed-network".to_string()); + inner.push( + serde_json::to_string(&managed_network) + .unwrap_or_else(|err| panic!("failed to serialize managed network context: {err}")), + ); 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()); diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 77171a0289..0030dc7fd6 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -5,6 +5,8 @@ use crate::linux_run_main::install_bwrap_signal_forwarders; #[cfg(test)] use crate::linux_run_main::wait_for_bwrap_child; #[cfg(test)] +use codex_network_proxy::ManagedNetworkSandboxContext; +#[cfg(test)] use codex_protocol::models::PermissionProfile; #[cfg(test)] use codex_protocol::protocol::FileSystemSandboxPolicy; @@ -553,17 +555,63 @@ fn run_bwrap_signal_forwarder_test_supervisor() -> ! { #[test] fn managed_proxy_inner_command_includes_route_spec() { let permission_profile = read_only_permission_profile(); - let args = build_inner_seccomp_command(InnerSeccompCommandArgs { - sandbox_policy_cwd: Path::new("/tmp"), - command_cwd: Some(Path::new("/tmp/link")), - permission_profile: &permission_profile, - allow_network_for_proxy: true, - proxy_route_spec: Some("{\"routes\":[]}".to_string()), - command: vec!["/bin/true".to_string()], - }); + for managed_network in [ + ManagedNetworkSandboxContext::default(), + ManagedNetworkSandboxContext { + loopback_ports: vec![8080, 9090], + allow_local_binding: true, + allow_unix_sockets: vec!["/tmp/daemon.sock".to_string()], + dangerously_allow_all_unix_sockets: true, + }, + ] { + let args = build_inner_seccomp_command(InnerSeccompCommandArgs { + sandbox_policy_cwd: Path::new("/tmp"), + command_cwd: Some(Path::new("/tmp/link")), + permission_profile: &permission_profile, + managed_network: Some(managed_network.clone()), + proxy_route_spec: Some("{\"routes\":[]}".to_string()), + command: vec!["/bin/true".to_string()], + }); - assert!(args.iter().any(|arg| arg == "--proxy-route-spec")); - assert!(args.iter().any(|arg| arg == "{\"routes\":[]}")); + assert!(args.iter().any(|arg| arg == "--proxy-route-spec")); + assert!(args.iter().any(|arg| arg == "{\"routes\":[]}")); + let parsed = LandlockCommand::try_parse_from(args) + .expect("inner command should preserve the managed network policy"); + assert_eq!(parsed.managed_network, Some(managed_network)); + } +} + +#[test] +fn managed_network_policy_alone_enables_proxy_mode() { + let parsed = LandlockCommand::try_parse_from([ + "codex-linux-sandbox", + "--sandbox-policy-cwd", + "/tmp", + "--managed-network", + "{}", + "--", + "/bin/true", + ]) + .expect("managed network context should enable proxy mode on its own"); + assert_eq!( + parsed.managed_network, + Some(ManagedNetworkSandboxContext::default()) + ); +} + +#[test] +fn malformed_managed_network_policy_is_rejected() { + let error = LandlockCommand::try_parse_from([ + "codex-linux-sandbox", + "--sandbox-policy-cwd", + "/tmp", + "--managed-network", + "{\"dangerouslyAllowAllUnixSockets\":\"true\"}", + "--", + "/bin/true", + ]) + .expect_err("managed network policy should reject a non-boolean grant"); + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); } #[test] @@ -573,7 +621,7 @@ fn inner_command_includes_permission_profile_flag() { sandbox_policy_cwd: Path::new("/tmp"), command_cwd: Some(Path::new("/tmp/link")), permission_profile: &permission_profile, - allow_network_for_proxy: false, + managed_network: None, proxy_route_spec: None, command: vec!["/bin/true".to_string()], }); @@ -592,12 +640,15 @@ fn non_managed_inner_command_omits_route_spec() { sandbox_policy_cwd: Path::new("/tmp"), command_cwd: Some(Path::new("/tmp/link")), permission_profile: &permission_profile, - allow_network_for_proxy: false, + managed_network: None, proxy_route_spec: None, command: vec!["/bin/true".to_string()], }); assert!(!args.iter().any(|arg| arg == "--proxy-route-spec")); + let parsed = LandlockCommand::try_parse_from(args) + .expect("unmanaged inner command should preserve ordinary sandbox mode"); + assert_eq!(parsed.managed_network, None); } #[test] @@ -608,7 +659,7 @@ fn managed_proxy_inner_command_requires_route_spec() { sandbox_policy_cwd: Path::new("/tmp"), command_cwd: Some(Path::new("/tmp/link")), permission_profile: &permission_profile, - allow_network_for_proxy: true, + managed_network: Some(ManagedNetworkSandboxContext::default()), proxy_route_spec: None, command: vec!["/bin/true".to_string()], }) diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index 0493245d98..e0b321a219 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -36,6 +36,9 @@ use tempfile::NamedTempFile; use tokio::process::Command; use url::Url; +#[path = "managed_proxy_unix_sockets_tests.rs"] +mod unix_sockets; + const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found"; const NETWORK_TIMEOUT_MS: u64 = 4_000; const OPERATION_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 8); @@ -171,7 +174,8 @@ fn linux_sandbox_command( permission_profile_json, ]; if allow_network_for_proxy { - args.push("--allow-network-for-proxy".to_string()); + args.push("--managed-network".to_string()); + args.push("{}".to_string()); } args.push("--".to_string()); args.extend(command.iter().map(|entry| (*entry).to_string())); diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy_unix_sockets_tests.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy_unix_sockets_tests.rs new file mode 100644 index 0000000000..24b4b748fe --- /dev/null +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy_unix_sockets_tests.rs @@ -0,0 +1,187 @@ +//! Explicit Unix-socket grants preserve managed-proxy network isolation. + +use super::*; +use codex_network_proxy::ConfigReloader; +use codex_network_proxy::ConfigReloaderFuture; +use codex_network_proxy::ConfigState; +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkProxyConstraints; +use codex_network_proxy::NetworkProxyState; +use codex_network_proxy::build_config_state; +use pretty_assertions::assert_eq; +use std::os::unix::net::UnixListener; +use std::os::unix::net::UnixStream; +use std::sync::Arc; + +struct TestConfigReloader(ConfigState); + +impl ConfigReloader for TestConfigReloader { + fn source_label(&self) -> String { + "managed proxy Unix socket test".to_string() + } + + fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option> { + Box::pin(async { Ok(None) }) + } + + fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> { + Box::pin(async { Ok(self.0.clone()) }) + } +} + +#[tokio::test] +async fn managed_proxy_allow_all_unix_sockets_preserves_network_isolation() { + if let Some(reason) = managed_proxy_skip_reason().await { + eprintln!("skipping managed proxy Unix socket test: {reason}"); + return; + } + if !Command::new("python3") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()) + { + eprintln!("skipping managed proxy Unix socket test: python3 is unavailable"); + return; + } + + let temp = tempfile::tempdir().expect("socket directory"); + let socket_path = temp.path().join("daemon.sock"); + let listener = UnixListener::bind(&socket_path).expect("host Unix listener"); + let mut control = UnixStream::connect(&socket_path).expect("host Unix control connection"); + let (mut control_peer, _) = listener.accept().expect("accept host Unix control"); + control.write_all(b"ok").expect("write host control"); + let mut control_payload = [0; 2]; + control_peer + .read_exact(&mut control_payload) + .expect("read host control"); + assert_eq!(control_payload, *b"ok"); + + // Use another loopback address so the namespace's proxy listener cannot + // collide with this host-only endpoint even if it selects the same port. + let tcp_listener = + TcpListener::bind((Ipv4Addr::new(127, 0, 0, 2), 0)).expect("host TCP listener"); + let tcp_addr = tcp_listener.local_addr().expect("host TCP address"); + let _tcp_control = TcpStream::connect(tcp_addr).expect("host TCP control connection"); + let _tcp_peer = tcp_listener.accept().expect("accept host TCP control"); + + listener + .set_nonblocking(true) + .expect("nonblocking listener"); + let server = std::thread::spawn(move || { + let deadline = Instant::now() + OPERATION_TIMEOUT * 2; + loop { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(OPERATION_TIMEOUT)) + .expect("set Unix read timeout"); + let mut request = [0; 4]; + stream.read_exact(&mut request).expect("read Unix request"); + assert_eq!(request, *b"ping"); + stream.write_all(b"pong").expect("write Unix response"); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "Unix request timed out"); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept Unix request: {error}"), + } + } + }); + + let probe = r#" +import errno, socket, sys +path, port, mode = sys.argv[1:] +try: + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +except OSError as error: + assert mode == 'deny' and error.errno == errno.EPERM, error +else: + assert mode == 'allow', 'Unix socket unexpectedly allowed' + with client: + client.settimeout(2) + client.connect(path) + client.sendall(b'ping') + assert client.recv(4, socket.MSG_WAITALL) == b'pong' +for address in [('127.0.0.2', int(port)), ('192.0.2.1', 80)]: + try: + connection = socket.create_connection(address, timeout=0.5) + except OSError: + pass + else: + connection.close() + raise AssertionError(('direct network unexpectedly allowed', address)) +for family in (socket.AF_NETLINK, getattr(socket, 'AF_VSOCK', 40)): + try: + unexpected = socket.socket(family, socket.SOCK_STREAM) + except OSError as error: + assert error.errno == errno.EPERM, (family, error) + else: + unexpected.close() + raise AssertionError(('socket family unexpectedly allowed', family)) +"#; + let cwd = std::env::current_dir().expect("current directory"); + for (label, dangerously_allow_all_unix_sockets, allow_path) in [ + ("default denial", false, false), + ("path grant stays restricted", false, true), + ("explicit allow-all", true, false), + ] { + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + let mut config = NetworkProxyConfig { + enabled: true, + proxy_url: "http://127.0.0.1:9".to_string(), + dangerously_allow_all_unix_sockets, + ..Default::default() + }; + if allow_path { + config.set_allow_unix_sockets(vec![socket_path.to_string_lossy().into_owned()]); + } + let state = build_config_state(config, NetworkProxyConstraints::default()) + .expect("valid managed network configuration"); + let network = NetworkProxy::builder() + .state(Arc::new(NetworkProxyState::with_reloader( + state.clone(), + Arc::new(TestConfigReloader(state)), + ))) + .managed_by_codex(/*managed_by_codex*/ false) + .build() + .await + .expect("build managed network proxy"); + let prepared = network + .prepare_for_optional_environment(env, /*environment_id*/ None) + .expect("prepare managed network policy"); + let mut command = Command::new(env!("CARGO_BIN_EXE_codex-linux-sandbox")); + command + .arg("--sandbox-policy-cwd") + .arg(&cwd) + .arg("--permission-profile") + .arg(serde_json::to_string(&PermissionProfile::Disabled).unwrap()) + .arg("--managed-network") + .arg(serde_json::to_string(&prepared.sandbox_context).unwrap()) + .args(["--", "python3", "-c", probe]) + .arg(&socket_path) + .arg(tcp_addr.port().to_string()) + .arg(if dangerously_allow_all_unix_sockets { + "allow" + } else { + "deny" + }) + .env_clear() + .envs(prepared.env) + .kill_on_drop(true); + let output = tokio::time::timeout(OPERATION_TIMEOUT, command.output()) + .await + .expect("Unix socket probe timed out") + .expect("Unix socket probe should execute"); + assert!( + output.status.success(), + "{label}; stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + } + server.join().expect("Unix server should finish"); +} diff --git a/codex-rs/mxc-sandbox/src/policy_tests.rs b/codex-rs/mxc-sandbox/src/policy_tests.rs index f698b84f1c..2c6ece44ba 100644 --- a/codex-rs/mxc-sandbox/src/policy_tests.rs +++ b/codex-rs/mxc-sandbox/src/policy_tests.rs @@ -411,6 +411,7 @@ fn managed_network_allows_authorized_loopback_without_lan_or_dns_access() -> Res let proxy = ManagedNetworkSandboxContext { loopback_ports: vec![43123, 43124], allow_local_binding: true, + ..Default::default() }; let mut env = HashMap::new(); create_command_args(CreateMxcCommandArgsParams { @@ -466,6 +467,7 @@ fn invalid_managed_network_is_rejected_at_both_boundaries() -> Result<()> { let proxy = ManagedNetworkSandboxContext { loopback_ports, allow_local_binding, + ..Default::default() }; let mut env = HashMap::new(); assert!( diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index c49917a2f2..ec0eaa6088 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -483,6 +483,12 @@ pub struct ManagedNetworkSandboxContext { /// Whether the command may bind local sockets and exchange loopback traffic. #[serde(default)] pub allow_local_binding: bool, + /// Unix-domain socket paths allowed by the effective managed-network policy. + #[serde(default)] + pub allow_unix_sockets: Vec, + /// Whether the effective policy permits connections to all Unix-domain sockets. + #[serde(default)] + pub dangerously_allow_all_unix_sockets: bool, } /// Environment-specific managed-network settings prepared for one command launch. @@ -1133,6 +1139,9 @@ impl NetworkProxy { sandbox_context: ManagedNetworkSandboxContext { loopback_ports, allow_local_binding: runtime_settings.allow_local_binding, + allow_unix_sockets: runtime_settings.allow_unix_sockets.to_vec(), + dangerously_allow_all_unix_sockets: runtime_settings + .dangerously_allow_all_unix_sockets, }, } } @@ -2109,6 +2118,60 @@ mod tests { ); } + #[tokio::test] + async fn prepare_for_optional_environment_preserves_effective_unix_socket_permissions() + -> Result<()> { + let mut config = NetworkProxyConfig { + enabled: true, + proxy_url: "http://127.0.0.1:43128".to_string(), + socks_url: "http://127.0.0.1:48081".to_string(), + allow_local_binding: true, + unix_sockets: Some(crate::config::NetworkUnixSocketPermissions { + entries: [ + ( + "/tmp/allowed.sock".to_string(), + crate::config::NetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/denied.sock".to_string(), + crate::config::NetworkUnixSocketPermission::Deny, + ), + ] + .into_iter() + .collect(), + }), + ..NetworkProxyConfig::default() + }; + let proxy = NetworkProxy::builder() + .state(Arc::new(network_proxy_state_for_policy(config.clone()))) + .managed_by_codex(/*managed_by_codex*/ false) + .build() + .await?; + + for allow_all in [false, true] { + config.dangerously_allow_all_unix_sockets = allow_all; + let replacement = crate::state::build_config_state(config.clone(), Default::default())?; + proxy.replace_config_state(replacement).await?; + let prepared = proxy + .prepare_for_optional_environment(HashMap::new(), /*environment_id*/ None)?; + assert_eq!( + prepared.sandbox_context, + ManagedNetworkSandboxContext { + loopback_ports: vec![43128, 48081], + allow_local_binding: true, + allow_unix_sockets: if cfg!(target_os = "windows") { + Vec::new() + } else { + vec!["/tmp/allowed.sock".to_string()] + }, + dangerously_allow_all_unix_sockets: !cfg!(target_os = "windows") && allow_all, + } + ); + } + + Ok(()) + } + #[tokio::test] async fn prepare_for_environment_keeps_env_and_sandbox_ports_in_sync() -> Result<()> { #[cfg(target_os = "windows")] @@ -2173,6 +2236,7 @@ mod tests { ManagedNetworkSandboxContext { loopback_ports: expected_ports, allow_local_binding: false, + ..ManagedNetworkSandboxContext::default() } ); } diff --git a/codex-rs/network-proxy/src/proxy/managed_routing_tests.rs b/codex-rs/network-proxy/src/proxy/managed_routing_tests.rs index c13402a9f6..55ca34c390 100644 --- a/codex-rs/network-proxy/src/proxy/managed_routing_tests.rs +++ b/codex-rs/network-proxy/src/proxy/managed_routing_tests.rs @@ -68,6 +68,7 @@ async fn dedicated_listeners_preserve_http_and_socks_policy_without_restricted_t ManagedNetworkSandboxContext { loopback_ports: ports, allow_local_binding: false, + ..ManagedNetworkSandboxContext::default() } ); assert_eq!( diff --git a/codex-rs/sandboxing/src/landlock.rs b/codex-rs/sandboxing/src/landlock.rs index 819fc9365e..dad16e238c 100644 --- a/codex-rs/sandboxing/src/landlock.rs +++ b/codex-rs/sandboxing/src/landlock.rs @@ -1,3 +1,4 @@ +use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::models::PermissionProfile; use std::path::Path; @@ -15,18 +16,20 @@ pub fn allow_network_for_proxy(enforce_managed_network: bool) -> bool { /// Converts the permission profile into the CLI invocation for /// `codex-linux-sandbox`. /// +/// A managed context selects proxy isolation; a default context keeps its +/// restrictive policy when an older caller supplies no additional details. +/// /// The helper performs the actual sandboxing (bubblewrap by default + seccomp) /// after parsing these arguments. The profile JSON flag is emitted before /// helper feature flags so the argv order matches the helper's CLI shape. See /// `docs/linux_sandbox.md` for the Linux semantics. -#[allow(clippy::too_many_arguments)] pub fn create_linux_sandbox_command_args_for_permission_profile( command: Vec, command_cwd: &Path, permission_profile: &PermissionProfile, sandbox_policy_cwd: &Path, use_legacy_landlock: bool, - allow_network_for_proxy: bool, + managed_network: Option<&ManagedNetworkSandboxContext>, ) -> Vec { let permission_profile_json = serde_json::to_string(permission_profile) .unwrap_or_else(|err| panic!("failed to serialize permission profile: {err}")); @@ -48,11 +51,15 @@ pub fn create_linux_sandbox_command_args_for_permission_profile( permission_profile_json, ]; // Proxy-only networking requires bubblewrap's isolated network namespace. - if use_legacy_landlock && !allow_network_for_proxy { + if use_legacy_landlock && managed_network.is_none() { linux_cmd.push("--use-legacy-landlock".to_string()); } - if allow_network_for_proxy { - linux_cmd.push("--allow-network-for-proxy".to_string()); + if let Some(managed_network) = managed_network { + linux_cmd.push("--managed-network".to_string()); + linux_cmd.push( + serde_json::to_string(managed_network) + .unwrap_or_else(|err| panic!("failed to serialize managed network context: {err}")), + ); } linux_cmd.push("--".to_string()); linux_cmd.extend(command); @@ -89,7 +96,8 @@ fn create_linux_sandbox_command_args( linux_cmd.push("--use-legacy-landlock".to_string()); } if allow_network_for_proxy { - linux_cmd.push("--allow-network-for-proxy".to_string()); + linux_cmd.push("--managed-network".to_string()); + linux_cmd.push("{}".to_string()); } // Separator so that command arguments starting with `-` are not parsed as diff --git a/codex-rs/sandboxing/src/landlock_tests.rs b/codex-rs/sandboxing/src/landlock_tests.rs index d0bbab8400..b3a57c3e35 100644 --- a/codex-rs/sandboxing/src/landlock_tests.rs +++ b/codex-rs/sandboxing/src/landlock_tests.rs @@ -33,7 +33,7 @@ fn legacy_landlock_flag_is_included_when_requested() { } #[test] -fn proxy_flag_takes_precedence_over_legacy_landlock() { +fn managed_context_takes_precedence_over_legacy_landlock() { let command = vec!["/bin/true".to_string()]; let command_cwd = Path::new("/tmp/link"); let cwd = Path::new("/tmp"); @@ -45,12 +45,9 @@ fn proxy_flag_takes_precedence_over_legacy_landlock() { &permission_profile, cwd, /*use_legacy_landlock*/ true, - /*allow_network_for_proxy*/ true, - ); - assert_eq!( - args.contains(&"--allow-network-for-proxy".to_string()), - true + Some(&ManagedNetworkSandboxContext::default()), ); + assert_eq!(args.contains(&"--managed-network".to_string()), true); assert_eq!(args.contains(&"--use-legacy-landlock".to_string()), false); } @@ -67,7 +64,7 @@ fn permission_profile_flag_is_included() { &permission_profile, cwd, /*use_legacy_landlock*/ true, - /*allow_network_for_proxy*/ false, + /*managed_network*/ None, ); assert_eq!( diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 9d0beadc7c..f14f4851d5 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -3,7 +3,6 @@ use crate::bwrap::WSL1_BWRAP_WARNING; #[cfg(target_os = "linux")] use crate::bwrap::is_wsl1; use crate::landlock::CODEX_LINUX_SANDBOX_ARG0; -use crate::landlock::allow_network_for_proxy; use crate::landlock::create_linux_sandbox_command_args_for_permission_profile; use crate::policy_transforms::effective_permission_profile; use crate::policy_transforms::should_require_platform_sandbox; @@ -496,14 +495,30 @@ impl SandboxManager { let pending = pending_sandboxed_request?; let exe = sandbox_exe.ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; - let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); + if enforce_managed_network + && command.managed_network.is_none() + && let Some(network) = network + { + let prepared = network + .prepare_for_optional_environment( + std::mem::take(&mut command.env), + environment_id, + ) + .map_err(|err| { + SandboxTransformError::EnvironmentNetworkProxy(err.to_string()) + })?; + command.env = prepared.env; + command.managed_network = Some(prepared.sandbox_context); + } + let managed_network = + enforce_managed_network.then(|| command.managed_network.unwrap_or_default()); #[cfg(target_os = "linux")] ensure_linux_bubblewrap_is_supported( &pending .effective_permission_profile .file_system_sandbox_policy(), use_legacy_landlock, - allow_proxy_network, + managed_network.is_some(), is_wsl1(), )?; let mut args = create_linux_sandbox_command_args_for_permission_profile( @@ -512,7 +527,7 @@ impl SandboxManager { &pending.effective_permission_profile, pending.native_sandbox_policy_cwd.as_path(), use_legacy_landlock, - allow_proxy_network, + managed_network.as_ref(), ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(os_string_to_command_component(exe.as_os_str().to_owned())); diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index ba01f9a280..c6936ae162 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -530,6 +530,131 @@ fn transform_linux_seccomp_uses_helper_alias_when_launcher_is_not_helper_path() assert_eq!(exec_request.arg0, Some("codex-linux-sandbox".to_string())); } +#[cfg(unix)] +#[tokio::test] +async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Result<()> { + use codex_network_proxy::ConfigReloader; + use codex_network_proxy::ConfigReloaderFuture; + use codex_network_proxy::ConfigState; + use codex_network_proxy::ManagedNetworkSandboxContext; + use codex_network_proxy::NetworkProxy; + use codex_network_proxy::NetworkProxyConfig; + use codex_network_proxy::NetworkProxyConstraints; + use codex_network_proxy::NetworkProxyState; + use codex_network_proxy::build_config_state; + use std::sync::Arc; + + struct TestConfigReloader; + impl ConfigReloader for TestConfigReloader { + fn source_label(&self) -> String { + "sandbox manager test config".to_string() + } + + fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option> { + Box::pin(async { Ok(None) }) + } + + fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> { + Box::pin(async { Err(anyhow::anyhow!("test config cannot reload")) }) + } + } + + let state = build_config_state( + NetworkProxyConfig { + enabled: true, + dangerously_allow_all_unix_sockets: true, + ..Default::default() + }, + NetworkProxyConstraints::default(), + )?; + let network = NetworkProxy::builder() + .state(Arc::new(NetworkProxyState::with_reloader( + state, + Arc::new(TestConfigReloader), + ))) + .managed_by_codex(/*managed_by_codex*/ false) + .build() + .await?; + let prepared = + network.prepare_for_optional_environment(HashMap::new(), /*environment_id*/ None)?; + let allow_all = prepared.sandbox_context; + let path_only = ManagedNetworkSandboxContext { + allow_unix_sockets: vec!["/tmp/daemon.sock".to_string()], + ..Default::default() + }; + let denied = ManagedNetworkSandboxContext::default(); + let cwd = AbsolutePathBuf::current_dir()?; + let cwd_uri = PathUri::from_abs_path(&cwd); + let manager = SandboxManager::new(); + for (label, context, live_proxy, enforce_managed_network, expected) in [ + ( + "missing policy stays managed with restrictive defaults", + None, + false, + true, + Some(denied.clone()), + ), + ( + "path grant stays restricted", + Some(path_only.clone()), + false, + true, + Some(path_only), + ), + ( + "prepared allow-all", + Some(allow_all.clone()), + false, + true, + Some(allow_all.clone()), + ), + ( + "prepared denial overrides live allow-all", + Some(denied.clone()), + true, + true, + Some(denied), + ), + ( + "live proxy fallback", + None, + true, + true, + Some(allow_all.clone()), + ), + ("no managed network", Some(allow_all), true, false, None), + ] { + let request = manager.transform(SandboxTransformRequest { + command: SandboxCommand { + program: "true".into(), + args: Vec::new(), + cwd: cwd_uri.clone(), + env: HashMap::new(), + managed_network: context, + additional_permissions: None, + }, + permissions: &PermissionProfile::Disabled, + sandbox: SandboxType::LinuxSeccomp, + enforce_managed_network, + environment_id: None, + network: live_proxy.then_some(&network), + sandbox_policy_cwd: &cwd_uri, + sandbox_exe: Some(std::path::Path::new("/tmp/codex-linux-sandbox")), + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + })?; + let transported_context = request + .command + .windows(2) + .find(|args| args[0] == "--managed-network") + .map(|args| serde_json::from_str::(&args[1])) + .transpose()?; + assert_eq!(transported_context, expected, "{label}"); + } + Ok(()) +} + #[cfg(target_os = "windows")] #[test] fn transform_for_direct_spawn_windows_preserves_only_wrapper_setup_environment() { diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index 4c11be4c35..e46c5c921e 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -777,6 +777,7 @@ fn prepared_managed_network_context_allows_only_its_proxy_ports() { let managed_network = ManagedNetworkSandboxContext { loopback_ports: vec![43123, 48081], allow_local_binding: false, + ..Default::default() }; let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { command: vec!["/bin/true".to_string()],