Trust managed MITM CA in Linux sandboxes

This commit is contained in:
Winston Howes
2026-05-26 15:44:40 -07:00
parent e910c8c788
commit 595d035fbc
9 changed files with 194 additions and 31 deletions

View File

@@ -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,

View File

@@ -34,6 +34,7 @@ where
P: AsRef<Path>,
{
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

View File

@@ -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<usize>,
/// Managed MITM CA bundle to materialize at common Linux trust-store paths.
pub mitm_ca_trust_bundle_path: Option<PathBuf>,
}
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<String>, options: BwrapOptions) -> BwrapArgs {
let mut args = vec![
fn create_bwrap_flags_full_filesystem(
command: Vec<String>,
options: BwrapOptions,
) -> Result<BwrapArgs> {
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<String>, 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() {

View File

@@ -126,6 +126,10 @@ pub struct LandlockCommand {
#[arg(long = "proxy-route-spec", hide = true)]
pub proxy_route_spec: Option<String>,
/// 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<PathBuf>,
/// 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<String>,
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<bool> {
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<crate::bwrap::BwrapArgs> {
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()
},
)

View File

@@ -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.

View File

@@ -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<PathBuf> {
self.runtime_settings().mitm_ca_trust_bundle_path
}
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
let runtime_settings = self.runtime_settings();
// Enforce proxying for child processes. We intentionally override existing values so

View File

@@ -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<String> {
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<String> {
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.

View File

@@ -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!(

View File

@@ -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()));