diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 745feb93b..bff08ef56 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3359,6 +3359,7 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "chrono", "clap", "codex-utils-absolute-path", @@ -3374,8 +3375,10 @@ dependencies = [ "rama-tcp", "rama-tls-rustls", "rama-unix", + "rustls-native-certs", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "time", diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 5a4944f50..6d59bf7d2 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -23,6 +23,7 @@ use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams; #[cfg(target_os = "macos")] use codex_sandboxing::seatbelt::create_seatbelt_command_args; +use codex_sandboxing::with_managed_mitm_ca_readable_root; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use tokio::process::Child; @@ -257,12 +258,21 @@ async fn run_command_under_sandbox( let network = network_proxy .as_ref() .map(codex_core::config::StartedNetworkProxy::proxy); + let managed_mitm_ca_trust_bundle_path = match network.as_ref() { + Some(network) => network.managed_mitm_ca_trust_bundle_path(), + None => None, + }; + let runtime_permission_profile = with_managed_mitm_ca_readable_root( + config.permissions.effective_permission_profile(), + managed_mitm_ca_trust_bundle_path.as_ref(), + sandbox_policy_cwd.as_path(), + ); let mut child = match sandbox_type { #[cfg(target_os = "macos")] SandboxType::Seatbelt => { - let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy(); - let network_sandbox_policy = config.permissions.network_sandbox_policy(); + let (file_system_sandbox_policy, network_sandbox_policy) = + runtime_permission_profile.to_runtime_permissions(); let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { command, file_system_sandbox_policy: &file_system_sandbox_policy, @@ -294,11 +304,11 @@ async fn run_command_under_sandbox( .codex_linux_sandbox_exe .expect("codex-linux-sandbox executable not found"); let use_legacy_landlock = config.features.use_legacy_landlock(); - let network_sandbox_policy = config.permissions.network_sandbox_policy(); + let network_sandbox_policy = runtime_permission_profile.network_sandbox_policy(); let args = create_linux_sandbox_command_args_for_permission_profile( command, cwd.as_path(), - &config.permissions.effective_permission_profile(), + &runtime_permission_profile, sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(managed_network_requirements_enabled), diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 1ae72b198..15778b64d 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -4,9 +4,6 @@ use std::time::Duration; use codex_async_utils::CancelErr; use codex_async_utils::OrCancelExt; use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; -use codex_network_proxy::PROXY_ENV_KEYS; -#[cfg(target_os = "macos")] -use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; use tokio_util::sync::CancellationToken; use tracing::error; use uuid::Uuid; @@ -21,6 +18,7 @@ use crate::session::turn_context::TurnContext; use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; +use crate::tools::runtimes::strip_managed_proxy_env; use crate::turn_timing::now_unix_timestamp_ms; use crate::user_shell_command::user_shell_command_record_item; use codex_protocol::exec_output::ExecToolCallOutput; @@ -131,18 +129,7 @@ pub(crate) async fn execute_user_shell_command( Some(session.conversation_id), ); if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) { - for key in PROXY_ENV_KEYS { - exec_env_map.remove(*key); - } - #[cfg(target_os = "macos")] - if exec_env_map - .get(PROXY_GIT_SSH_COMMAND_ENV_KEY) - .is_some_and(|value| { - value.starts_with(codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER) - }) - { - exec_env_map.remove(PROXY_GIT_SSH_COMMAND_ENV_KEY); - } + strip_managed_proxy_env(&mut exec_env_map); } let exec_command = maybe_wrap_shell_lc_with_snapshot( &display_command, diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 45e1754da..b237f44c4 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -12,10 +12,12 @@ use crate::shell::ShellType; use crate::tools::sandboxing::ToolError; #[cfg(target_os = "macos")] use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; +use codex_network_proxy::CUSTOM_CA_ENV_KEYS; use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; +use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_sandboxing::SandboxCommand; @@ -57,21 +59,33 @@ pub(crate) fn exec_env_for_sandbox_permissions( if sandbox_permissions.requires_escalated_permissions() && env.contains_key(PROXY_ACTIVE_ENV_KEY) { - for key in PROXY_ENV_KEYS { - env.remove(*key); - } - // Only macOS injects a Codex-owned SSH wrapper for the managed SOCKS proxy. - #[cfg(target_os = "macos")] - if env - .get(PROXY_GIT_SSH_COMMAND_ENV_KEY) - .is_some_and(|command| command.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER)) - { - env.remove(PROXY_GIT_SSH_COMMAND_ENV_KEY); - } + strip_managed_proxy_env(&mut env); } env } +pub(crate) fn strip_managed_proxy_env(env: &mut HashMap) { + for key in PROXY_ENV_KEYS { + env.remove(*key); + } + for key in CUSTOM_CA_ENV_KEYS { + if env + .get(key) + .is_some_and(|value| is_managed_mitm_ca_trust_bundle_path(value)) + { + env.remove(key); + } + } + // Only macOS injects a Codex-owned SSH wrapper for the managed SOCKS proxy. + #[cfg(target_os = "macos")] + if env + .get(PROXY_GIT_SSH_COMMAND_ENV_KEY) + .is_some_and(|command| command.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER)) + { + env.remove(PROXY_GIT_SSH_COMMAND_ENV_KEY); + } +} + #[cfg(unix)] fn prepend_path_entry(env: &mut HashMap, path_entry: &str) -> String { let updated_path = match env.get("PATH") { @@ -235,6 +249,7 @@ fn build_proxy_env_exports() -> (String, String) { let mut keys = PROXY_ENV_KEYS .iter() .copied() + .chain(CUSTOM_CA_ENV_KEYS) .filter(|key| is_valid_shell_variable_name(key)) .collect::>(); keys.sort_unstable(); diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 9a473cd25..369f301cd 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -8,6 +8,7 @@ use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::managed_network_for_sandbox_permissions; #[cfg(target_os = "macos")] use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; +use codex_network_proxy::CUSTOM_CA_ENV_KEYS; use codex_network_proxy::ConfigReloader; use codex_network_proxy::ConfigState; use codex_network_proxy::NetworkProxy; @@ -134,6 +135,9 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: for key in PROXY_ENV_KEYS { assert_eq!(exec_request.env.get(*key), None, "{key} should be unset"); } + for key in CUSTOM_CA_ENV_KEYS { + assert_eq!(exec_request.env.get(key), None, "{key} should be unset"); + } #[cfg(target_os = "macos")] assert_eq!(exec_request.env.get(PROXY_GIT_SSH_COMMAND_ENV_KEY), None); assert_eq!( @@ -144,6 +148,24 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: Ok(()) } +#[test] +fn explicit_escalation_preserves_user_ca_env() { + let env = HashMap::from([ + (PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string()), + ( + "SSL_CERT_FILE".to_string(), + "/tmp/custom-ca.pem".to_string(), + ), + ]); + + let env = exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated); + + assert_eq!( + env.get("SSL_CERT_FILE"), + Some(&"/tmp/custom-ca.pem".to_string()) + ); +} + #[cfg(unix)] #[test] fn apply_zsh_fork_path_prepend_uses_shell_parent() { diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index d3a19a41c..a40003838 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true } clap = { workspace = true, features = ["derive"] } chrono = { workspace = true } codex-utils-absolute-path = { workspace = true } @@ -35,6 +36,8 @@ rama-net = { version = "=0.3.0-alpha.4", features = ["http", "tls"] } rama-socks5 = { version = "=0.3.0-alpha.4" } rama-tcp = { version = "=0.3.0-alpha.4", features = ["http"] } rama-tls-rustls = { version = "=0.3.0-alpha.4", features = ["http"] } +rustls-native-certs = { workspace = true } +sha2 = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 8c2117b4d..300cd3fcb 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -35,6 +35,8 @@ dangerously_allow_non_loopback_proxy = false mode = "full" # default when unset; use "limited" for read-only mode # HTTPS MITM is enabled automatically when `mode = "limited"` or when MITM hooks are configured. # 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 +# immutable bundles under $CODEX_HOME/proxy/ so common HTTPS clients trust the managed CA. # 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/certs.rs b/codex-rs/network-proxy/src/certs.rs index 2699d0036..001469aa9 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -1,6 +1,7 @@ use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; +use base64::Engine as _; use codex_utils_home_dir::find_codex_home; use rama_net::tls::ApplicationProtocol; use rama_tls_rustls::dep::pki_types::CertificateDer; @@ -19,6 +20,9 @@ use rama_tls_rustls::dep::rcgen::PKCS_ECDSA_P256_SHA256; use rama_tls_rustls::dep::rcgen::SanType; use rama_tls_rustls::dep::rustls; use rama_tls_rustls::server::TlsAcceptorData; +use sha2::Digest as _; +use sha2::Sha256; +use std::collections::HashMap; use std::fs; use std::fs::File; use std::fs::OpenOptions; @@ -29,6 +33,7 @@ use std::path::PathBuf; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tracing::info; +use tracing::warn; pub(super) struct ManagedMitmCa { issuer: Issuer<'static, KeyPair>, @@ -95,6 +100,29 @@ fn issue_host_certificate_pem( const MANAGED_MITM_CA_DIR: &str = "proxy"; const MANAGED_MITM_CA_CERT: &str = "ca.pem"; const MANAGED_MITM_CA_KEY: &str = "ca.key"; +const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; + +// Best-effort compatibility set for common child toolchains that accept a CA bundle path. +// This is intentionally curated rather than pretending to cover every TLS client. +pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ + "CODEX_CA_CERTIFICATE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "GIT_SSL_CAINFO", + "PIP_CERT", + "BUNDLE_SSL_CA_CERT", + "npm_config_cafile", + "NPM_CONFIG_CAFILE", +]; + +/// Immutable managed MITM CA bundle path plus startup TLS env values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ManagedMitmCaTrustBundle { + pub(crate) path: PathBuf, + pub(crate) startup_env_values: HashMap<&'static str, String>, +} fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> { let codex_home = @@ -106,6 +134,135 @@ fn managed_ca_paths() -> Result<(PathBuf, PathBuf)> { )) } +pub(crate) fn managed_ca_trust_bundle( + env: &HashMap<&'static str, String>, +) -> Result { + load_or_create_ca()?; + let (cert_path, _) = managed_ca_paths()?; + managed_ca_trust_bundle_for_cert_path(&cert_path, env) +} + +fn managed_ca_trust_bundle_for_cert_path( + cert_path: &Path, + env: &HashMap<&'static str, String>, +) -> Result { + let startup_env_values = CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| { + env.get(key) + .filter(|value| !value.is_empty()) + .map(|value| (key, value.clone())) + }) + .collect(); + let trust_bundle = build_managed_ca_trust_bundle(cert_path)?; + let path = persist_managed_ca_trust_bundle(cert_path, &trust_bundle)?; + + Ok(ManagedMitmCaTrustBundle { + path, + startup_env_values, + }) +} + +fn build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result { + let mut trust_bundle = String::new(); + let rustls_native_certs::CertificateResult { certs, errors, .. } = + rustls_native_certs::load_native_certs(); + if !errors.is_empty() { + warn!( + native_root_error_count = errors.len(), + "encountered errors while loading native root certificates for MITM trust bundle" + ); + } + for cert in certs { + push_certificate_pem(&mut trust_bundle, cert.as_ref()); + } + append_pem_file(&mut trust_bundle, managed_ca_cert_path)?; + Ok(trust_bundle) +} + +fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Path) -> bool { + let Some(proxy_dir) = managed_ca_cert_path.parent() else { + return false; + }; + let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { + return false; + }; + if path.parent() != Some(proxy_dir) + || !file_name.starts_with(MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX) + || !file_name.ends_with(".pem") + { + return false; + } + let Ok(trust_bundle) = fs::read(path) else { + return false; + }; + let Ok(managed_ca_cert) = fs::read(managed_ca_cert_path) else { + return false; + }; + !managed_ca_cert.is_empty() + && trust_bundle + .windows(managed_ca_cert.len()) + .any(|window| window == managed_ca_cert) +} + +/// Returns whether `path` points at a current Codex-generated MITM CA bundle. +pub fn is_managed_mitm_ca_trust_bundle_path(path: &str) -> bool { + let Ok((managed_ca_cert_path, _)) = managed_ca_paths() else { + return false; + }; + is_current_generated_trust_bundle_path(Path::new(path), &managed_ca_cert_path) +} + +fn persist_managed_ca_trust_bundle( + managed_ca_cert_path: &Path, + trust_bundle: &str, +) -> Result { + let proxy_dir = managed_ca_cert_path + .parent() + .ok_or_else(|| anyhow!("managed MITM CA cert path is missing a parent"))?; + fs::create_dir_all(proxy_dir) + .with_context(|| format!("failed to create {}", proxy_dir.display()))?; + let hash = Sha256::digest(trust_bundle.as_bytes()); + let trust_bundle_path = proxy_dir.join(format!( + "{MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX}-{hash:x}.pem" + )); + write_atomic_create_new_or_reuse( + &trust_bundle_path, + trust_bundle.as_bytes(), + /*mode*/ 0o644, + ) + .with_context(|| { + format!( + "failed to persist managed MITM CA trust bundle {}", + trust_bundle_path.display() + ) + })?; + Ok(trust_bundle_path) +} + +fn append_pem_file(bundle: &mut String, path: &Path) -> Result<()> { + if !bundle.ends_with('\n') { + bundle.push('\n'); + } + let pem = fs::read_to_string(path) + .with_context(|| format!("failed to read CA bundle {}", path.display()))?; + bundle.push_str(&pem); + if !bundle.ends_with('\n') { + bundle.push('\n'); + } + Ok(()) +} + +fn push_certificate_pem(bundle: &mut String, der: &[u8]) { + bundle.push_str("-----BEGIN CERTIFICATE-----\n"); + let encoded = base64::engine::general_purpose::STANDARD.encode(der); + for chunk in encoded.as_bytes().chunks(64) { + bundle.push_str(&String::from_utf8_lossy(chunk)); + bundle.push('\n'); + } + bundle.push_str("-----END CERTIFICATE-----\n"); +} + fn load_or_create_ca() -> Result<(String, String)> { let (cert_path, key_path) = managed_ca_paths()?; @@ -230,14 +387,47 @@ fn write_atomic_create_new(path: &Path, contents: &[u8], mode: u32) -> Result<() } } + sync_parent_dir(parent)?; + + Ok(()) +} + +#[cfg(not(windows))] +fn sync_parent_dir(parent: &Path) -> Result<()> { // Best-effort durability: ensure the directory entry is persisted too. let dir = File::open(parent).with_context(|| format!("failed to open {}", parent.display()))?; dir.sync_all() - .with_context(|| format!("failed to fsync {}", parent.display()))?; + .with_context(|| format!("failed to fsync {}", parent.display())) +} +#[cfg(windows)] +fn sync_parent_dir(_parent: &Path) -> Result<()> { Ok(()) } +fn write_atomic_create_new_or_reuse(path: &Path, contents: &[u8], mode: u32) -> Result<()> { + if fs::symlink_metadata(path) + .ok() + .is_some_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(anyhow!("refusing to reuse symlink {}", path.display())); + } + if fs::read(path).ok().as_deref() == Some(contents) { + return Ok(()); + } + if path.exists() { + return Err(anyhow!( + "refusing to reuse existing mismatched file {}", + path.display() + )); + } + match write_atomic_create_new(path, contents, mode) { + Ok(()) => Ok(()), + Err(_err) if fs::read(path).ok().as_deref() == Some(contents) => Ok(()), + Err(err) => Err(err), + } +} + #[cfg(unix)] fn validate_existing_ca_key_file(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; @@ -294,13 +484,44 @@ fn open_create_new_with_mode(path: &Path, _mode: u32) -> Result { .with_context(|| format!("failed to create {}", path.display())) } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use pretty_assertions::assert_eq; + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; + #[test] + fn current_generated_trust_bundle_path_rejects_stale_bundle() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let trust_bundle_path = dir.path().join("ca-bundle-123.pem"); + fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); + fs::write(&trust_bundle_path, "stale managed bundle\n").unwrap(); + assert!(!is_current_generated_trust_bundle_path( + &trust_bundle_path, + &managed_ca_cert_path, + )); + } + + #[test] + fn managed_ca_trust_bundle_records_startup_ca_env_values() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); + let env = HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]); + let trust_bundle = + managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); + assert_eq!( + trust_bundle.startup_env_values, + HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]) + ); + } + + #[cfg(unix)] #[test] fn validate_existing_ca_key_file_rejects_group_world_permissions() { let dir = tempdir().unwrap(); @@ -315,6 +536,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn validate_existing_ca_key_file_rejects_symlink() { use std::os::unix::fs::symlink; @@ -332,6 +554,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn validate_existing_ca_key_file_allows_private_permissions() { let dir = tempdir().unwrap(); @@ -341,4 +564,23 @@ mod tests { validate_existing_ca_key_file(&key_path).unwrap(); } + + #[cfg(unix)] + #[test] + fn write_atomic_create_new_or_reuse_rejects_matching_symlink_target() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let target = dir.path().join("real-bundle.pem"); + let link = dir.path().join("ca-bundle.pem"); + fs::write(&target, "bundle").unwrap(); + symlink(&target, &link).unwrap(); + + let err = write_atomic_create_new_or_reuse(&link, b"bundle", /*mode*/ 0o644).unwrap_err(); + + assert_eq!( + err.to_string(), + format!("refusing to reuse symlink {}", link.display()) + ); + } } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 76316c835..9ad16f8ec 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -16,6 +16,8 @@ mod socks5; mod state; mod upstream; +pub use certs::CUSTOM_CA_ENV_KEYS; +pub use certs::is_managed_mitm_ca_trust_bundle_path; pub use config::NetworkDomainPermission; pub use config::NetworkDomainPermissionEntry; pub use config::NetworkDomainPermissions; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 1de1ed617..c3685a431 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -9,6 +9,7 @@ use crate::state::NetworkProxyState; use anyhow::Context; use anyhow::Result; use clap::Parser; +use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::net::SocketAddr; use std::net::TcpListener as StdTcpListener; @@ -223,7 +224,7 @@ impl NetworkProxyBuilder { socks_enabled: current_cfg.network.enable_socks5, runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config( ¤t_cfg, - ))), + )?)), reserved_listeners, policy_decider: self.policy_decider, }) @@ -299,15 +300,26 @@ struct NetworkProxyRuntimeSettings { allow_local_binding: bool, allow_unix_sockets: Arc<[String]>, dangerously_allow_all_unix_sockets: bool, + mitm_ca_trust_bundle: Option, } impl NetworkProxyRuntimeSettings { - fn from_config(config: &config::NetworkProxyConfig) -> Self { - Self { + fn from_config(config: &config::NetworkProxyConfig) -> Result { + let mitm_ca_trust_bundle = if config.network.mitm { + let env = crate::certs::CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) + .collect(); + Some(crate::certs::managed_ca_trust_bundle(&env)?) + } else { + None + }; + Ok(Self { allow_local_binding: config.network.allow_local_binding, allow_unix_sockets: config.network.allow_unix_sockets().into(), dangerously_allow_all_unix_sockets: config.network.dangerously_allow_all_unix_sockets, - } + mitm_ca_trust_bundle, + }) } } @@ -477,6 +489,7 @@ fn apply_proxy_env_overrides( socks_addr: SocketAddr, socks_enabled: bool, allow_local_binding: bool, + mitm_ca_trust_bundle: Option<&crate::certs::ManagedMitmCaTrustBundle>, ) { let http_proxy_url = format!("http://{http_addr}"); let socks_proxy_url = format!("socks5h://{socks_addr}"); @@ -556,6 +569,27 @@ fn apply_proxy_env_overrides( } } } + + if let Some(mitm_ca_trust_bundle) = mitm_ca_trust_bundle { + let managed_path = mitm_ca_trust_bundle.path.to_string_lossy().into_owned(); + for key in crate::certs::CUSTOM_CA_ENV_KEYS { + if env + .get(key) + .filter(|value| !value.is_empty()) + .is_some_and(|value| { + value != &managed_path + && mitm_ca_trust_bundle.startup_env_values.get(key) != Some(value) + }) + { + // TODO(winston): Materialize policy-checked per-child bundles for readable + // startup and command-scoped CA overrides. For now startup overrides are + // replaced with the default bundle and later command-scoped overrides are + // preserved, either of which can make intercepted TLS fail. + continue; + } + env.insert(key.to_string(), managed_path.clone()); + } + } } impl NetworkProxy { @@ -595,16 +629,28 @@ impl NetworkProxy { self.runtime_settings().dangerously_allow_all_unix_sockets } + /// Returns the generated MITM CA bundle path child sandboxes should expose to TLS clients. + pub fn managed_mitm_ca_trust_bundle_path(&self) -> Option { + self.runtime_settings() + .mitm_ca_trust_bundle + .and_then(|bundle| { + AbsolutePathBuf::from_absolute_path(bundle.path) + .map_err(|err| warn!("managed MITM CA trust bundle path is invalid: {err}")) + .ok() + }) + } + pub fn apply_to_env(&self, env: &mut HashMap) { - let allow_local_binding = self.allow_local_binding(); - // Enforce proxying for child processes. We intentionally override existing values so - // command-level environment cannot bypass the managed proxy endpoint. + let runtime_settings = self.runtime_settings(); + // Enforce proxying for child processes. Proxy endpoint values are always rewritten; + // managed MITM CA vars preserve child-scoped overrides after proxy startup. apply_proxy_env_overrides( env, self.http_addr, self.socks_addr, self.socks_enabled, - allow_local_binding, + runtime_settings.allow_local_binding, + runtime_settings.mitm_ca_trust_bundle.as_ref(), ); } @@ -631,7 +677,7 @@ impl NetworkProxy { "cannot update network.enable_socks5_udp on a running proxy" ); - let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config); + let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config)?; self.state.replace_config_state(new_state).await?; let mut guard = self .runtime_settings @@ -791,6 +837,7 @@ mod tests { use pretty_assertions::assert_eq; use std::net::IpAddr; use std::net::Ipv4Addr; + use std::path::Path; #[tokio::test] async fn managed_proxy_builder_uses_loopback_ports() { @@ -979,6 +1026,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle*/ None, ); assert_eq!( @@ -1042,6 +1090,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle*/ None, ); for key in env.keys() { @@ -1054,6 +1103,60 @@ mod tests { } } + #[test] + fn apply_proxy_env_overrides_sets_mitm_ca_trust_bundle_vars() { + let mut env = HashMap::new(); + let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem"); + let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { + path: mitm_ca_trust_bundle_path.to_path_buf(), + startup_env_values: HashMap::new(), + }; + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + /*socks_enabled*/ true, + /*allow_local_binding*/ false, + Some(&mitm_ca_trust_bundle), + ); + + for key in crate::certs::CUSTOM_CA_ENV_KEYS { + assert_eq!( + env.get(key), + Some(&mitm_ca_trust_bundle_path.display().to_string()) + ); + } + } + + #[test] + fn apply_proxy_env_overrides_preserves_command_scoped_mitm_ca_override() { + let command_ca_bundle_path = "/tmp/command-ca.pem".to_string(); + let mut env = HashMap::from([( + "REQUESTS_CA_BUNDLE".to_string(), + command_ca_bundle_path.clone(), + )]); + let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem"); + let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle { + path: mitm_ca_trust_bundle_path.to_path_buf(), + startup_env_values: HashMap::new(), + }; + + apply_proxy_env_overrides( + &mut env, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128), + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), + /*socks_enabled*/ true, + /*allow_local_binding*/ false, + Some(&mitm_ca_trust_bundle), + ); + + assert_eq!(env.get("REQUESTS_CA_BUNDLE"), Some(&command_ca_bundle_path)); + assert_eq!( + env.get("SSL_CERT_FILE"), + Some(&mitm_ca_trust_bundle_path.display().to_string()) + ); + } + #[test] fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() { let mut env = HashMap::new(); @@ -1063,6 +1166,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ false, /*allow_local_binding*/ true, + /*mitm_ca_trust_bundle*/ None, ); assert_eq!( @@ -1081,6 +1185,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle*/ None, ); assert_eq!( @@ -1129,6 +1234,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle*/ None, ); assert_eq!( @@ -1151,6 +1257,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle*/ None, ); assert_eq!( @@ -1174,6 +1281,7 @@ mod tests { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081), /*socks_enabled*/ true, /*allow_local_binding*/ false, + /*mitm_ca_trust_bundle*/ None, ); assert_eq!( diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs index 72b7290f1..3437b0d32 100644 --- a/codex-rs/network-proxy/src/upstream.rs +++ b/codex-rs/network-proxy/src/upstream.rs @@ -1,5 +1,6 @@ use crate::connect_policy::TargetCheckedTcpConnector; use crate::state::NetworkProxyState; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use rama_core::Layer; use rama_core::Service; use rama_core::error::BoxError; @@ -225,6 +226,7 @@ fn build_http_connector( EstablishedClientConnection, Request>, BoxError, > { + ensure_rustls_crypto_provider(); let proxy = HttpProxyConnectorLayer::optional().into_layer(transport); let tls_config = TlsConnectorDataBuilder::new() .with_alpn_protocols_http_auto() diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index c70393db8..2dda65f9d 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -19,6 +19,7 @@ pub use manager::SandboxType; pub use manager::SandboxablePreference; pub use manager::compatibility_sandbox_policy_for_permission_profile; pub use manager::get_platform_sandbox; +pub use manager::with_managed_mitm_ca_readable_root; use codex_protocol::error::CodexErr; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 82c49f790..bd8f6d9b9 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -61,6 +61,27 @@ pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option, + sandbox_policy_cwd: &Path, +) -> PermissionProfile { + let Some(managed_mitm_ca_trust_bundle_path) = managed_mitm_ca_trust_bundle_path else { + return permission_profile; + }; + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + let file_system_sandbox_policy = file_system_sandbox_policy.with_additional_readable_roots( + sandbox_policy_cwd, + std::slice::from_ref(managed_mitm_ca_trust_bundle_path), + ); + PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &file_system_sandbox_policy, + network_sandbox_policy, + ) +} + #[derive(Debug)] pub struct SandboxCommand { pub program: OsString, @@ -182,8 +203,15 @@ impl SandboxManager { windows_sandbox_private_desktop, } = request; let additional_permissions = command.additional_permissions.take(); + let managed_mitm_ca_trust_bundle_path = + network.and_then(NetworkProxy::managed_mitm_ca_trust_bundle_path); let effective_permission_profile = effective_permission_profile(permissions, additional_permissions.as_ref()); + let effective_permission_profile = with_managed_mitm_ca_readable_root( + effective_permission_profile, + managed_mitm_ca_trust_bundle_path.as_ref(), + sandbox_policy_cwd, + ); let (effective_file_system_policy, effective_network_policy) = effective_permission_profile.to_runtime_permissions(); let mut argv = Vec::with_capacity(1 + command.args.len()); diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index b00e9779b..fede00fe1 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -4,6 +4,7 @@ use super::SandboxTransformRequest; use super::SandboxType; use super::SandboxablePreference; use super::get_platform_sandbox; +use super::with_managed_mitm_ca_readable_root; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; @@ -242,6 +243,48 @@ fn transform_additional_permissions_preserves_denied_entries() { ); } +#[test] +fn managed_mitm_ca_bundle_becomes_readable_for_restricted_sandbox() { + let cwd = TempDir::new().expect("create cwd"); + let cwd = + AbsolutePathBuf::from_absolute_path(canonicalize(cwd.path()).expect("canonicalize cwd")) + .expect("absolute cwd"); + let managed_bundle_dir = TempDir::new().expect("create managed bundle dir"); + let managed_bundle_path = + AbsolutePathBuf::from_absolute_path(managed_bundle_dir.path().join("ca-bundle.pem")) + .expect("absolute managed bundle path"); + let permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { path: cwd.clone() }, + access: FileSystemAccessMode::Read, + }]), + NetworkSandboxPolicy::Restricted, + ); + + let permission_profile = with_managed_mitm_ca_readable_root( + permission_profile, + Some(&managed_bundle_path), + cwd.as_path(), + ); + let (file_system_sandbox_policy, _) = permission_profile.to_runtime_permissions(); + + assert_eq!( + file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: cwd }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: managed_bundle_path, + }, + access: FileSystemAccessMode::Read, + }, + ]) + ); +} + #[cfg(target_os = "linux")] fn transform_linux_seccomp_request( codex_linux_sandbox_exe: &std::path::Path,