diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 918d81aad2..d68f0f5215 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -300,6 +300,7 @@ async fn run_command_under_sandbox( sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(managed_network_requirements_enabled), + /*mitm_ca_trust_bundle_path*/ None, ); spawn_debug_sandbox_child( codex_linux_sandbox_exe, diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index c117f706e1..1beba39192 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -34,6 +34,7 @@ where P: AsRef, { let network_sandbox_policy = permission_profile.network_sandbox_policy(); + let mitm_ca_trust_bundle_path = network.and_then(NetworkProxy::mitm_ca_trust_bundle_path); let args = create_linux_sandbox_command_args_for_permission_profile( command, command_cwd.as_path(), @@ -41,6 +42,7 @@ where sandbox_policy_cwd, use_legacy_landlock, allow_network_for_proxy(/*enforce_managed_network*/ false), + mitm_ca_trust_bundle_path.as_deref(), ); let codex_linux_sandbox_exe = codex_linux_sandbox_exe.as_ref(); // Preserve the helper alias when we already have it; otherwise force argv0 diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 25a3814fb5..911c8cdfbd 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -54,9 +54,17 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[ ]; const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192; +// Common Linux trust bundle paths for clients that read system roots instead of +// honoring one of the CA bundle environment variables we export. +const LINUX_CA_BUNDLE_PATHS: [&str; 4] = [ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/ssl/cert.pem", +]; /// Options that control how bubblewrap is invoked. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct BwrapOptions { /// Whether to mount a fresh `/proc` inside the sandbox. /// @@ -70,6 +78,8 @@ pub(crate) struct BwrapOptions { /// Keep this uncapped by default so existing nested deny-read matches are /// masked before the sandboxed command starts. pub glob_scan_max_depth: Option, + /// Managed MITM CA bundle to materialize at common Linux trust-store paths. + pub mitm_ca_trust_bundle_path: Option, } impl Default for BwrapOptions { @@ -78,6 +88,7 @@ impl Default for BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::FullAccess, glob_scan_max_depth: None, + mitm_ca_trust_bundle_path: None, } } } @@ -243,7 +254,9 @@ pub(crate) fn create_bwrap_command_args( // Full disk write normally skips bwrap, but unreadable glob patterns still // need concrete bwrap masks for the matches expanded below. if file_system_sandbox_policy.has_full_disk_write_access() && unreadable_globs.is_empty() { - return if options.network_mode == BwrapNetworkMode::FullAccess { + return if options.network_mode == BwrapNetworkMode::FullAccess + && options.mitm_ca_trust_bundle_path.is_none() + { Ok(BwrapArgs { args: command, preserved_files: Vec::new(), @@ -251,7 +264,7 @@ pub(crate) fn create_bwrap_command_args( protected_create_targets: Vec::new(), }) } else { - Ok(create_bwrap_flags_full_filesystem(command, options)) + create_bwrap_flags_full_filesystem(command, options) }; } @@ -264,8 +277,11 @@ pub(crate) fn create_bwrap_command_args( ) } -fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOptions) -> BwrapArgs { - let mut args = vec![ +fn create_bwrap_flags_full_filesystem( + command: Vec, + options: BwrapOptions, +) -> Result { + let args = vec![ "--new-session".to_string(), "--die-with-parent".to_string(), "--bind".to_string(), @@ -276,21 +292,26 @@ fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOption "--unshare-user".to_string(), "--unshare-pid".to_string(), ]; - if options.network_mode.should_unshare_network() { - args.push("--unshare-net".to_string()); - } - if options.mount_proc { - args.push("--proc".to_string()); - args.push("/proc".to_string()); - } - args.push("--".to_string()); - args.extend(command); - BwrapArgs { + let mut bwrap_args = BwrapArgs { args, preserved_files: Vec::new(), synthetic_mount_targets: Vec::new(), protected_create_targets: Vec::new(), + }; + append_mitm_ca_trust_bundle_args( + &mut bwrap_args, + options.mitm_ca_trust_bundle_path.as_deref(), + )?; + if options.network_mode.should_unshare_network() { + bwrap_args.args.push("--unshare-net".to_string()); } + if options.mount_proc { + bwrap_args.args.push("--proc".to_string()); + bwrap_args.args.push("/proc".to_string()); + } + bwrap_args.args.push("--".to_string()); + bwrap_args.args.extend(command); + Ok(bwrap_args) } /// Build the bubblewrap flags (everything after `argv[0]`). @@ -318,34 +339,68 @@ fn create_bwrap_flags( args.push("--new-session".to_string()); args.push("--die-with-parent".to_string()); args.extend(filesystem_args); + let mut bwrap_args = BwrapArgs { + args, + preserved_files, + synthetic_mount_targets, + protected_create_targets, + }; + append_mitm_ca_trust_bundle_args( + &mut bwrap_args, + options.mitm_ca_trust_bundle_path.as_deref(), + )?; // Request a user namespace explicitly rather than relying on bubblewrap's // auto-enable behavior, which is skipped when the caller runs as uid 0. - args.push("--unshare-user".to_string()); - args.push("--unshare-pid".to_string()); + bwrap_args.args.push("--unshare-user".to_string()); + bwrap_args.args.push("--unshare-pid".to_string()); if options.network_mode.should_unshare_network() { - args.push("--unshare-net".to_string()); + bwrap_args.args.push("--unshare-net".to_string()); } // Mount a fresh /proc unless the caller explicitly disables it. if options.mount_proc { - args.push("--proc".to_string()); - args.push("/proc".to_string()); + bwrap_args.args.push("--proc".to_string()); + bwrap_args.args.push("/proc".to_string()); } if normalized_command_cwd.as_path() != command_cwd { // Bubblewrap otherwise inherits the helper's logical cwd, which can be // a symlink alias that disappears once the sandbox only mounts // canonical roots. Enter the canonical command cwd explicitly so // relative paths stay aligned with the mounted filesystem view. - args.push("--chdir".to_string()); - args.push(path_to_string(normalized_command_cwd.as_path())); + bwrap_args.args.push("--chdir".to_string()); + bwrap_args + .args + .push(path_to_string(normalized_command_cwd.as_path())); } - args.push("--".to_string()); - args.extend(command); - Ok(BwrapArgs { - args, - preserved_files, - synthetic_mount_targets, - protected_create_targets, - }) + bwrap_args.args.push("--".to_string()); + bwrap_args.args.extend(command); + Ok(bwrap_args) +} + +fn append_mitm_ca_trust_bundle_args( + bwrap_args: &mut BwrapArgs, + mitm_ca_trust_bundle_path: Option<&Path>, +) -> Result<()> { + let Some(mitm_ca_trust_bundle_path) = mitm_ca_trust_bundle_path else { + return Ok(()); + }; + + for bundle_path in LINUX_CA_BUNDLE_PATHS { + let bundle_file = File::open(mitm_ca_trust_bundle_path).map_err(|err| { + CodexErr::Fatal(format!( + "failed to open managed MITM CA trust bundle {}: {err}", + mitm_ca_trust_bundle_path.display() + )) + })?; + let bundle_fd = bundle_file.as_raw_fd().to_string(); + bwrap_args.preserved_files.push(bundle_file); + bwrap_args.args.push("--perms".to_string()); + bwrap_args.args.push("444".to_string()); + bwrap_args.args.push("--ro-bind-data".to_string()); + bwrap_args.args.push(bundle_fd); + bwrap_args.args.push(bundle_path.to_string()); + } + + Ok(()) } /// Build the bubblewrap filesystem mounts for a given filesystem policy. @@ -1344,6 +1399,11 @@ mod tests { assert_eq!(BwrapOptions::default().glob_scan_max_depth, None); } + #[test] + fn default_mitm_ca_trust_bundle_path_is_unset() { + assert_eq!(BwrapOptions::default().mitm_ca_trust_bundle_path, None); + } + fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry { FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern }, @@ -1411,6 +1471,35 @@ mod tests { ); } + #[test] + fn mitm_ca_trust_bundle_overlays_common_linux_bundle_paths() { + let temp_dir = TempDir::new().expect("tempdir"); + let mitm_ca_trust_bundle_path = temp_dir.path().join("ca-bundle.pem"); + fs::write(&mitm_ca_trust_bundle_path, "managed bundle").expect("write bundle"); + + let args = create_bwrap_command_args( + vec!["/bin/true".to_string()], + &FileSystemSandboxPolicy::unrestricted(), + Path::new("/"), + Path::new("/"), + BwrapOptions { + mitm_ca_trust_bundle_path: Some(mitm_ca_trust_bundle_path), + ..Default::default() + }, + ) + .expect("create bwrap args"); + + assert_eq!(args.preserved_files.len(), LINUX_CA_BUNDLE_PATHS.len()); + for bundle_path in LINUX_CA_BUNDLE_PATHS { + assert!(args.args.windows(5).any(|window| { + window[0] == "--perms" + && window[1] == "444" + && window[2] == "--ro-bind-data" + && window[4] == bundle_path + })); + } + } + #[test] fn full_disk_write_with_unreadable_glob_still_wraps_and_masks_match() { if !ripgrep_available() { diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 346b1f14c0..cf1ba221c8 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -126,6 +126,10 @@ pub struct LandlockCommand { #[arg(long = "proxy-route-spec", hide = true)] pub proxy_route_spec: Option, + /// Internal managed MITM CA bundle to materialize in bubblewrap sandboxes. + #[arg(long = "mitm-ca-trust-bundle", hide = true)] + pub mitm_ca_trust_bundle_path: 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`. @@ -153,6 +157,7 @@ pub fn run_main() -> ! { apply_seccomp_then_exec, allow_network_for_proxy, proxy_route_spec, + mitm_ca_trust_bundle_path, no_proc, command, } = LandlockCommand::parse(); @@ -197,7 +202,10 @@ pub fn run_main() -> ! { exec_or_panic(command); } - if file_system_sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy { + if file_system_sandbox_policy.has_full_disk_write_access() + && !allow_network_for_proxy + && mitm_ca_trust_bundle_path.is_none() + { if let Err(e) = apply_permission_profile_to_current_thread( &permission_profile, &sandbox_policy_cwd, @@ -238,6 +246,7 @@ pub fn run_main() -> ! { inner, !no_proc, allow_network_for_proxy, + mitm_ca_trust_bundle_path.as_deref(), ); } @@ -322,6 +331,7 @@ fn run_bwrap_with_proc_fallback( inner: Vec, mount_proc: bool, allow_network_for_proxy: bool, + mitm_ca_trust_bundle_path: Option<&Path>, ) -> ! { let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy); let mut mount_proc = mount_proc; @@ -333,6 +343,7 @@ fn run_bwrap_with_proc_fallback( command_cwd, file_system_sandbox_policy, network_mode, + mitm_ca_trust_bundle_path, ) .unwrap_or_else(|err| exit_with_bwrap_build_error(err)) { @@ -344,6 +355,7 @@ fn run_bwrap_with_proc_fallback( let options = BwrapOptions { mount_proc, network_mode, + mitm_ca_trust_bundle_path: mitm_ca_trust_bundle_path.map(Path::to_path_buf), ..Default::default() }; let mut bwrap_args = build_bwrap_argv( @@ -446,12 +458,14 @@ fn preflight_proc_mount_support( command_cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, + mitm_ca_trust_bundle_path: Option<&Path>, ) -> CodexResult { let preflight_argv = build_preflight_bwrap_argv( sandbox_policy_cwd, command_cwd, file_system_sandbox_policy, network_mode, + mitm_ca_trust_bundle_path, )?; let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); Ok(!is_proc_mount_failure(stderr.as_str())) @@ -462,6 +476,7 @@ fn build_preflight_bwrap_argv( command_cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, + mitm_ca_trust_bundle_path: Option<&Path>, ) -> CodexResult { let preflight_command = vec![resolve_true_command()]; build_bwrap_argv( @@ -472,6 +487,7 @@ fn build_preflight_bwrap_argv( BwrapOptions { mount_proc: true, network_mode, + mitm_ca_trust_bundle_path: mitm_ca_trust_bundle_path.map(Path::to_path_buf), ..Default::default() }, ) diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 3f531cefa4..cbe64f5dd2 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -37,6 +37,8 @@ mode = "full" # default when unset; use "limited" for read-only mode # CA cert/key are managed internally under $CODEX_HOME/proxy/ (ca.pem + ca.key). # When MITM is active, spawned commands receive CA bundle env vars pointing at # $CODEX_HOME/proxy/ca-bundle.pem so common HTTPS clients trust the managed CA. +# Linux bubblewrap sandboxes also overlay that bundle onto common system CA +# bundle paths inside the sandbox namespace. # If false, local/private networking is rejected. Explicit allowlisting of local IP literals # (or `localhost`) is required to permit them. diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 8a5237afaa..bb49d1f571 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -614,6 +614,11 @@ impl NetworkProxy { self.runtime_settings().dangerously_allow_all_unix_sockets } + /// Returns the managed CA bundle child sandboxes should trust while MITM is active. + pub fn mitm_ca_trust_bundle_path(&self) -> Option { + self.runtime_settings().mitm_ca_trust_bundle_path + } + pub fn apply_to_env(&self, env: &mut HashMap) { let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. We intentionally override existing values so diff --git a/codex-rs/sandboxing/src/landlock.rs b/codex-rs/sandboxing/src/landlock.rs index 0ff3f6977f..518af85ac4 100644 --- a/codex-rs/sandboxing/src/landlock.rs +++ b/codex-rs/sandboxing/src/landlock.rs @@ -27,6 +27,7 @@ pub fn create_linux_sandbox_command_args_for_permission_profile( sandbox_policy_cwd: &Path, use_legacy_landlock: bool, allow_network_for_proxy: bool, + mitm_ca_trust_bundle_path: Option<&Path>, ) -> Vec { let permission_profile_json = serde_json::to_string(permission_profile) .unwrap_or_else(|err| panic!("failed to serialize permission profile: {err}")); @@ -53,6 +54,15 @@ pub fn create_linux_sandbox_command_args_for_permission_profile( if allow_network_for_proxy { linux_cmd.push("--allow-network-for-proxy".to_string()); } + if let Some(mitm_ca_trust_bundle_path) = mitm_ca_trust_bundle_path { + linux_cmd.push("--mitm-ca-trust-bundle".to_string()); + linux_cmd.push( + mitm_ca_trust_bundle_path + .to_str() + .unwrap_or_else(|| panic!("MITM CA trust bundle path must be valid UTF-8")) + .to_string(), + ); + } linux_cmd.push("--".to_string()); linux_cmd.extend(command); linux_cmd @@ -67,6 +77,7 @@ fn create_linux_sandbox_command_args( sandbox_policy_cwd: &Path, use_legacy_landlock: bool, allow_network_for_proxy: bool, + mitm_ca_trust_bundle_path: Option<&Path>, ) -> Vec { let command_cwd = command_cwd .to_str() @@ -89,6 +100,15 @@ fn create_linux_sandbox_command_args( if allow_network_for_proxy { linux_cmd.push("--allow-network-for-proxy".to_string()); } + if let Some(mitm_ca_trust_bundle_path) = mitm_ca_trust_bundle_path { + linux_cmd.push("--mitm-ca-trust-bundle".to_string()); + linux_cmd.push( + mitm_ca_trust_bundle_path + .to_str() + .unwrap_or_else(|| panic!("MITM CA trust bundle path must be valid UTF-8")) + .to_string(), + ); + } // Separator so that command arguments starting with `-` are not parsed as // options of the helper itself. diff --git a/codex-rs/sandboxing/src/landlock_tests.rs b/codex-rs/sandboxing/src/landlock_tests.rs index 14b1c047eb..053bdd6e18 100644 --- a/codex-rs/sandboxing/src/landlock_tests.rs +++ b/codex-rs/sandboxing/src/landlock_tests.rs @@ -13,6 +13,7 @@ fn legacy_landlock_flag_is_included_when_requested() { cwd, /*use_legacy_landlock*/ false, /*allow_network_for_proxy*/ false, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( default_bwrap.contains(&"--use-legacy-landlock".to_string()), @@ -25,6 +26,7 @@ fn legacy_landlock_flag_is_included_when_requested() { cwd, /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ false, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( legacy_landlock.contains(&"--use-legacy-landlock".to_string()), @@ -44,6 +46,7 @@ fn proxy_flag_is_included_when_requested() { cwd, /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ true, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( args.contains(&"--allow-network-for-proxy".to_string()), @@ -65,6 +68,7 @@ fn permission_profile_flag_is_included() { cwd, /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ false, + /*mitm_ca_trust_bundle_path*/ None, ); assert_eq!( @@ -79,6 +83,27 @@ fn permission_profile_flag_is_included() { ); } +#[test] +fn mitm_ca_trust_bundle_flag_is_included_when_requested() { + let command = vec!["/bin/true".to_string()]; + let command_cwd = Path::new("/tmp/link"); + let cwd = Path::new("/tmp"); + let trust_bundle_path = Path::new("/tmp/ca-bundle.pem"); + + let args = create_linux_sandbox_command_args( + command, + command_cwd, + cwd, + /*use_legacy_landlock*/ false, + /*allow_network_for_proxy*/ true, + Some(trust_bundle_path), + ); + + assert!(args.windows(2).any(|window| { + window[0] == "--mitm-ca-trust-bundle" && window[1] == "/tmp/ca-bundle.pem" + })); +} + #[test] fn proxy_network_requires_managed_requirements() { assert_eq!( diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 82c49f7908..8a0749ca7f 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -218,6 +218,8 @@ impl SandboxManager { let exe = codex_linux_sandbox_exe .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); + let mitm_ca_trust_bundle_path = + network.and_then(NetworkProxy::mitm_ca_trust_bundle_path); #[cfg(target_os = "linux")] ensure_linux_bubblewrap_is_supported( &effective_file_system_policy, @@ -232,6 +234,7 @@ impl SandboxManager { sandbox_policy_cwd, use_legacy_landlock, allow_proxy_network, + mitm_ca_trust_bundle_path.as_deref(), ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(os_string_to_command_component(exe.as_os_str().to_owned()));