diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index d63997d617..0d84b9f444 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -6,6 +6,10 @@ //! - sensitive subpaths such as `.git`, `.agents`, and `.codex` remain //! read-only even when their parent root is writable. //! +//! Restricted execution also hides WSLg's duplicate distro root so it cannot +//! expose filesystem contents outside the policy's path masks. Explicit grants and +//! command paths using that alias are rejected before constructing mounts. +//! //! The overall Linux sandbox is composed of: //! - seccomp + `PR_SET_NO_NEW_PRIVS` applied in-process, and //! - bubblewrap used to construct the filesystem view before exec. @@ -56,6 +60,7 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[ const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192; pub(crate) const WSL_INTEROP_DIR: &str = "/run/WSL"; +pub(crate) const WSLG_DISTRO_ROOT: &str = "/mnt/wslg/distro"; /// Options that control how bubblewrap is invoked. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -69,6 +74,9 @@ pub(crate) struct BwrapOptions { pub network_mode: BwrapNetworkMode, /// Hide the WSL Windows interop socket from commands with restricted filesystem access. pub mask_wsl_interop: bool, + /// Whether the host exposes WSLg's duplicate distro root, which must be hidden + /// when constructing a restricted filesystem view. + pub mask_wslg_distro: bool, /// Optional maximum depth for expanding unreadable glob patterns with ripgrep. /// /// Keep this uncapped by default so existing nested deny-read matches are @@ -82,6 +90,7 @@ impl Default for BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::FullAccess, mask_wsl_interop: false, + mask_wslg_distro: false, glob_scan_max_depth: None, } } @@ -316,18 +325,29 @@ fn create_bwrap_flags( command_cwd: &Path, options: BwrapOptions, ) -> Result { + if options.mask_wslg_distro { + let granted_paths = file_system_sandbox_policy.entries.iter().flat_map(|entry| { + FileSystemSandboxPolicy::restricted(vec![entry.clone()]) + .get_readable_roots_with_cwd(sandbox_policy_cwd) + }); + let executable = command + .first() + .filter(|executable| executable.contains('/')) + .map(|executable| AbsolutePathBuf::resolve_path_against_base(executable, command_cwd)); + for path in granted_paths + .map(AbsolutePathBuf::into_path_buf) + .chain([sandbox_policy_cwd.to_path_buf(), command_cwd.to_path_buf()]) + .chain(executable.map(AbsolutePathBuf::into_path_buf)) + { + crate::wslg::ensure_supported_path(&path)?; + } + } let BwrapArgs { args: filesystem_args, preserved_files, synthetic_mount_targets, protected_create_targets, - } = create_filesystem_args( - file_system_sandbox_policy, - sandbox_policy_cwd, - options - .glob_scan_max_depth - .or(file_system_sandbox_policy.glob_scan_max_depth), - )?; + } = create_filesystem_args(file_system_sandbox_policy, sandbox_policy_cwd, options)?; let normalized_command_cwd = normalize_command_cwd_for_bwrap(command_cwd); let mut args = Vec::new(); args.push("--new-session".to_string()); @@ -339,12 +359,12 @@ fn create_bwrap_flags( // the Linux filesystem policy by launching an unrestricted Windows process. args.push("--tmpfs".to_string()); args.push(WSL_INTEROP_DIR.to_string()); - if !options.mount_proc { - // Without a fresh procfs, the root bind retains the host procfs. - // Hide it so /proc//root cannot alias the interop sockets. - args.push("--tmpfs".to_string()); - args.push("/proc".to_string()); - } + } + if (options.mask_wsl_interop || options.mask_wslg_distro) && !options.mount_proc { + // Without a fresh procfs, the root bind retains the host procfs. + // Hide it so host process roots cannot restore the masked WSL views. + args.push("--tmpfs".to_string()); + args.push("/proc".to_string()); } // Request a user namespace explicitly rather than relying on bubblewrap's // auto-enable behavior, which is skipped when the caller runs as uid 0. @@ -398,7 +418,7 @@ fn create_bwrap_flags( fn create_filesystem_args( file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path, - glob_scan_max_depth: Option, + options: BwrapOptions, ) -> Result { let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd); // Bubblewrap requires bind mount targets to exist. Skip missing writable @@ -461,9 +481,15 @@ fn create_filesystem_args( // to the existing matches we can see before constructing the mount overlay; // core tool helpers still evaluate the original patterns directly at read time. unreadable_roots.extend( - expand_unreadable_globs_with_ripgrep(&unreadable_globs, cwd, glob_scan_max_depth)? - .into_iter() - .map(AbsolutePathBuf::into_path_buf), + expand_unreadable_globs_with_ripgrep( + &unreadable_globs, + cwd, + options + .glob_scan_max_depth + .or(file_system_sandbox_policy.glob_scan_max_depth), + )? + .into_iter() + .map(AbsolutePathBuf::into_path_buf), ); unreadable_roots.sort(); unreadable_roots.dedup(); @@ -673,6 +699,29 @@ fn create_filesystem_args( append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?; } + if options.mask_wslg_distro + && !unreadable_roots.iter().any(|denied| { + let distro = Path::new(WSLG_DISTRO_ROOT); + distro.starts_with(denied) + && !allowed_write_paths.iter().any(|root| { + root.starts_with(denied) + && (distro.starts_with(root) || root.starts_with(distro)) + }) + }) + { + // Use the expanded masks, including symlink targets from deny globs. + // An already hidden ancestor cannot accommodate a new child mount. + // Otherwise apply this after every grant to exclude the duplicate view. + bwrap_args.args.extend([ + "--perms".to_string(), + "000".to_string(), + "--tmpfs".to_string(), + WSLG_DISTRO_ROOT.to_string(), + "--remount-ro".to_string(), + WSLG_DISTRO_ROOT.to_string(), + ]); + } + Ok(bwrap_args) } @@ -1342,6 +1391,10 @@ fn find_first_non_existent_component(target_path: &Path) -> Option { None } +#[cfg(test)] +#[path = "bwrap_wslg_tests.rs"] +mod wslg_tests; + #[cfg(test)] mod tests { use super::*; @@ -1355,8 +1408,6 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; - const NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH: Option = None; - #[test] fn default_unreadable_glob_scan_has_no_depth_cap() { assert_eq!(BwrapOptions::default().glob_scan_max_depth, None); @@ -1583,9 +1634,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); assert!(args.args.windows(3).any(|window| { window == ["--bind", real_root_str.as_str(), real_root_str.as_str()] @@ -1627,9 +1677,8 @@ mod tests { missing_path_behavior: None, }]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); assert!(args.args.windows(3).any(|window| { window @@ -1667,9 +1716,8 @@ mod tests { missing_path_behavior: None, }]); - let err = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect_err("protected symlinked subpath should fail closed"); + let err = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect_err("protected symlinked subpath should fail closed"); let message = err.to_string(); assert!( @@ -1710,9 +1758,8 @@ mod tests { }, ]); - let err = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect_err("deny-read path crossing writable symlink should fail closed"); + let err = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect_err("deny-read path crossing writable symlink should fail closed"); let message = err.to_string(); assert!( @@ -1743,9 +1790,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, Path::new("/"), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, Path::new("/"), BwrapOptions::default()) + .expect("filesystem args"); let registry_path = path_to_string(®istry_root); let temp_path = path_to_string(temp_root); let registry_bind = args @@ -1785,9 +1831,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); assert_empty_file_bound_without_perms(&args.args, &blocked); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".git")); @@ -1825,9 +1870,8 @@ mod tests { missing_path_behavior: None, }]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let dot_git_str = path_to_string(&dot_git); assert_empty_file_bound_without_perms(&args.args, &dot_git); @@ -1873,7 +1917,7 @@ mod tests { missing_path_behavior: None, }]); - let args = create_filesystem_args(&policy, &workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + let args = create_filesystem_args(&policy, &workspace, BwrapOptions::default()) .expect("filesystem args"); assert_empty_directory_mounted_read_only(&args.args, &dot_git); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents")); @@ -1910,9 +1954,8 @@ mod tests { missing_path_behavior: None, }]); - let args = - create_filesystem_args(&policy, &link_workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, &link_workspace, BwrapOptions::default()) + .expect("filesystem args"); assert_empty_directory_mounted_read_only(&args.args, &dot_git); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents")); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex")); @@ -1942,9 +1985,8 @@ mod tests { /*exclude_slash_tmp*/ true, ); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let existing_root = path_to_string(&existing_root); let missing_root = path_to_string(&missing_root); @@ -2001,9 +2043,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let dot_git = path_to_string(&temp_dir.path().join(".git")); let dot_agents = path_to_string(&temp_dir.path().join(".agents")); let dot_codex = path_to_string(&temp_dir.path().join(".codex")); @@ -2057,9 +2098,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let dot_vscode = path_to_string(&temp_dir.path().join(".vscode")); let dot_secrets = path_to_string(&temp_dir.path().join(".secrets")); @@ -2075,12 +2115,8 @@ mod tests { /*exclude_slash_tmp*/ true, ); - let args = create_filesystem_args( - &sandbox_policy, - Path::new("/"), - NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH, - ) - .expect("bwrap fs args"); + let args = create_filesystem_args(&sandbox_policy, Path::new("/"), BwrapOptions::default()) + .expect("bwrap fs args"); assert!(args.preserved_files.is_empty()); assert_eq!( synthetic_mount_target_paths(&args), @@ -2174,9 +2210,8 @@ mod tests { missing_path_behavior: None, }]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); assert_eq!(args.args[0..4], ["--tmpfs", "/", "--dev", "/dev"]); @@ -2202,9 +2237,8 @@ mod tests { missing_path_behavior: None, }]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); assert!( args.args @@ -2244,9 +2278,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); assert!(args.args.windows(3).any(|window| { window @@ -2322,9 +2355,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let docs_str = path_to_string(docs.as_path()); let docs_public_str = path_to_string(docs_public.as_path()); let docs_ro_index = args @@ -2375,9 +2407,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let blocked_str = path_to_string(blocked.as_path()); let allowed_str = path_to_string(allowed.as_path()); let blocked_none_index = args @@ -2443,9 +2474,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let blocked_str = path_to_string(blocked.as_path()); let allowed_dir_str = path_to_string(allowed_dir.as_path()); let allowed_file_str = path_to_string(allowed_file.as_path()); @@ -2521,9 +2551,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let blocked_none_index = args .args .windows(4) @@ -2568,9 +2597,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let blocked_str = path_to_string(blocked.as_path()); assert!( @@ -2612,9 +2640,8 @@ mod tests { }, ]); - let args = - create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) - .expect("filesystem args"); + let args = create_filesystem_args(&policy, temp_dir.path(), BwrapOptions::default()) + .expect("filesystem args"); let blocked_file_str = path_to_string(blocked_file.as_path()); assert_eq!(args.preserved_files.len(), 1); @@ -2645,8 +2672,15 @@ mod tests { let policy = default_policy_with_unreadable_glob(format!("{}/**/*.env", temp_dir.path().display())); - let args = - create_filesystem_args(&policy, temp_dir.path(), Some(2)).expect("filesystem args"); + let args = create_filesystem_args( + &policy, + temp_dir.path(), + BwrapOptions { + glob_scan_max_depth: Some(2), + ..Default::default() + }, + ) + .expect("filesystem args"); assert_file_masked(&args.args, &root_env); assert_file_masked(&args.args, &nested_env); @@ -2677,8 +2711,15 @@ mod tests { let policy = default_policy_with_unreadable_glob(format!("{}/**/*.env", link_root.display())); - let args = - create_filesystem_args(&policy, temp_dir.path(), Some(2)).expect("filesystem args"); + let args = create_filesystem_args( + &policy, + temp_dir.path(), + BwrapOptions { + glob_scan_max_depth: Some(2), + ..Default::default() + }, + ) + .expect("filesystem args"); assert_file_masked(&args.args, &real_secret); } diff --git a/codex-rs/linux-sandbox/src/bwrap_wslg_tests.rs b/codex-rs/linux-sandbox/src/bwrap_wslg_tests.rs new file mode 100644 index 0000000000..b3cb688e77 --- /dev/null +++ b/codex-rs/linux-sandbox/src/bwrap_wslg_tests.rs @@ -0,0 +1,258 @@ +//! Regression coverage for excluding WSLg's duplicate filesystem view. + +use super::*; +use codex_protocol::protocol::FileSystemSandboxEntry; +use pretty_assertions::assert_eq; + +#[test_case::test_case(FileSystemAccessMode::Read; "read_root")] +#[test_case::test_case(FileSystemAccessMode::Write; "write_root")] +fn wslg_mask_follows_filesystem_grants_and_denials(root_access: FileSystemAccessMode) { + let temp_dir = tempfile::TempDir::new().expect("temp dir"); + let denied = temp_dir.path().join("denied.txt"); + fs::write(&denied, "fixture").expect("write fixture"); + let denied = AbsolutePathBuf::from_absolute_path(denied).expect("absolute fixture"); + let workspace = + AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute workspace"); + + for denied_path in [ + FileSystemPath::from(denied.clone()), + FileSystemPath::GlobPattern { + pattern: format!("{}/*.txt", temp_dir.path().display()), + }, + ] { + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: root_access, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: workspace.clone().into(), + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: denied_path, + access: FileSystemAccessMode::Deny, + missing_path_behavior: None, + }, + ]); + for mount_proc in [true, false] { + for network_mode in [BwrapNetworkMode::FullAccess, BwrapNetworkMode::Isolated] { + let args = create_bwrap_command_args( + vec!["/bin/true".to_string()], + &policy, + temp_dir.path(), + temp_dir.path(), + BwrapOptions { + mount_proc, + network_mode, + mask_wslg_distro: true, + ..Default::default() + }, + ) + .expect("create restricted bwrap args") + .args; + let mask = args + .windows(6) + .position(|args| { + args == [ + "--perms", + "000", + "--tmpfs", + WSLG_DISTRO_ROOT, + "--remount-ro", + WSLG_DISTRO_ROOT, + ] + }) + .expect("unreadable, read-only WSLg mask"); + let last_grant = args + .iter() + .rposition(|arg| arg == "--bind" || arg == "--ro-bind") + .expect("filesystem grant"); + let denial = args + .iter() + .rposition(|arg| arg == &path_to_string(denied.as_path())) + .expect("denied file mask"); + assert!(last_grant < mask && denial < mask); + assert!(args.windows(2).any(|args| { + args == [if mount_proc { "--proc" } else { "--tmpfs" }, "/proc"] + })); + } + } + } +} + +#[test] +fn wslg_mask_is_omitted_when_policy_already_hides_an_ancestor() { + for denied in ["/mnt", "/mnt/wslg", WSLG_DISTRO_ROOT] { + let mut policy = FileSystemSandboxPolicy::read_only(); + policy.entries.push(FileSystemSandboxEntry { + path: AbsolutePathBuf::from_absolute_path(denied) + .expect("absolute denied directory") + .into(), + access: FileSystemAccessMode::Deny, + missing_path_behavior: None, + }); + let mut commands = Vec::new(); + for mask_wslg_distro in [false, true] { + commands.push( + create_bwrap_command_args( + vec!["/bin/true".to_string()], + &policy, + Path::new("/"), + Path::new("/"), + BwrapOptions { + mask_wslg_distro, + ..Default::default() + }, + ) + .expect("create bwrap args with denied ancestor") + .args, + ); + } + assert_eq!(commands[0], commands[1]); + } +} + +#[test] +fn unrestricted_filesystem_preserves_wslg_with_or_without_network_isolation() { + for network_mode in [BwrapNetworkMode::FullAccess, BwrapNetworkMode::Isolated] { + let mut commands = Vec::new(); + for mask_wslg_distro in [false, true] { + commands.push( + create_bwrap_command_args( + vec!["/bin/true".to_string()], + &FileSystemSandboxPolicy::unrestricted(), + Path::new("/"), + Path::new("/"), + BwrapOptions { + network_mode, + mask_wslg_distro, + ..Default::default() + }, + ) + .expect("create unrestricted bwrap args") + .args, + ); + } + assert_eq!(commands[0], commands[1]); + } +} + +#[test_case::test_case("grant_read"; "read_grant")] +#[test_case::test_case("grant_write"; "write_grant")] +#[test_case::test_case("policy_cwd"; "policy_cwd")] +#[test_case::test_case("command_cwd"; "command_cwd")] +#[test_case::test_case("executable"; "executable")] +fn explicit_wslg_alias_paths_are_rejected(source: &str) { + let alias = Path::new(WSLG_DISTRO_ROOT).join("project"); + let mut policy = FileSystemSandboxPolicy::read_only(); + if source.starts_with("grant_") { + policy.entries.push(FileSystemSandboxEntry { + path: AbsolutePathBuf::from_absolute_path(&alias) + .expect("absolute alias") + .into(), + access: if source == "grant_write" { + FileSystemAccessMode::Write + } else { + FileSystemAccessMode::Read + }, + missing_path_behavior: None, + }); + } + let executable = if source == "executable" { + alias.as_path() + } else { + Path::new("/bin/true") + }; + let policy_cwd = if source == "policy_cwd" { + alias.as_path() + } else { + Path::new("/") + }; + let command_cwd = if source == "command_cwd" { + alias.as_path() + } else { + Path::new("/") + }; + let error = create_bwrap_command_args( + vec![path_to_string(executable)], + &policy, + policy_cwd, + command_cwd, + BwrapOptions { + mask_wslg_distro: true, + ..Default::default() + }, + ) + .expect_err("explicit alias must be rejected before mounts are built"); + assert_eq!( + error.to_string(), + format!( + "Fatal error: restricted sandboxes do not support paths under {WSLG_DISTRO_ROOT}: {}; use the corresponding path under the primary filesystem root instead", + alias.display() + ) + ); +} + +#[test] +fn glob_ancestor_mask_in_no_rg_fallback() { + // Isolate PATH in a child test process instead of mutating the test runner's + // environment. This exercises the supported missing-rg branch end to end. + const CHILD: &str = "CODEX_WSLG_GLOB_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let output = Command::new(std::env::current_exe().expect("test executable")) + .args([ + "--exact", + "bwrap::wslg_tests::glob_ancestor_mask_in_no_rg_fallback", + "--nocapture", + ]) + .env(CHILD, "1") + .env("PATH", "") + .output() + .expect("run isolated test"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + let temp = tempfile::tempdir().expect("temp directory"); + // '/' is an ancestor on every test host; no WSL mount is needed. Only + // inspect arguments: never launch this synthetic filesystem policy. + let relative_root: PathBuf = + std::iter::repeat_n("..", temp.path().components().count() - 1).collect(); + std::os::unix::fs::symlink(relative_root, temp.path().join("alias")).expect("relative symlink"); + let mut policy = FileSystemSandboxPolicy::read_only(); + policy.entries.push(FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: format!("{}/alias*", temp.path().display()), + }, + access: FileSystemAccessMode::Deny, + missing_path_behavior: None, + }); + let args = create_bwrap_command_args( + vec!["/bin/true".to_string()], + &policy, + Path::new("/"), + Path::new("/"), + BwrapOptions { + mask_wslg_distro: true, + mount_proc: false, + ..Default::default() + }, + ) + .expect("expanded ancestor mask should suffice") + .args; + assert!( + args.windows(4) + .any(|args| args == ["--tmpfs", "/", "--remount-ro", "/"]) + ); + assert!(!args.iter().any(|arg| arg == WSLG_DISTRO_ROOT)); + assert!(args.windows(2).any(|args| args == ["--tmpfs", "/proc"])); +} diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index ad44216822..0cf64a8647 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -23,6 +23,8 @@ mod linux_run_main; mod proxy_lifecycle; #[cfg(target_os = "linux")] mod proxy_routing; +#[cfg(target_os = "linux")] +mod wslg; /// Exit status returned when bundled bubblewrap fails digest verification. #[cfg(target_os = "linux")] diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 6ce369bc03..83da2e69c2 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -23,6 +23,7 @@ use std::time::Duration; use crate::bwrap::BwrapNetworkMode; use crate::bwrap::BwrapOptions; use crate::bwrap::WSL_INTEROP_DIR; +use crate::bwrap::WSLG_DISTRO_ROOT; use crate::bwrap::create_bwrap_command_args; use crate::landlock::apply_permission_profile_to_current_thread; use crate::launcher::exec_bwrap; @@ -303,6 +304,31 @@ pub fn run_main() -> ! { } else { (None, Vec::new()) }; + let options = BwrapOptions { + mount_proc: !no_proc, + network_mode: bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy), + mask_wsl_interop: !file_system_sandbox_policy.has_full_disk_write_access() + && Path::new(WSL_INTEROP_DIR).is_dir(), + mask_wslg_distro: (!file_system_sandbox_policy.has_full_disk_write_access() + || !file_system_sandbox_policy + .get_unreadable_globs_with_cwd(&sandbox_policy_cwd) + .is_empty()) + && crate::wslg::is_duplicate_root(Path::new(WSLG_DISTRO_ROOT)) + .unwrap_or_else(|err| exit_with_bwrap_build_error(err.into())), + ..Default::default() + }; + if options.mask_wslg_distro + && let Some(executable) = command + .first() + .filter(|executable| executable.contains('/')) + { + let executable = command_cwd + .as_deref() + .unwrap_or(&sandbox_policy_cwd) + .join(executable); + crate::wslg::ensure_supported_path(&executable) + .unwrap_or_else(|err| exit_with_bwrap_build_error(err)); + } let inner = build_inner_seccomp_command(InnerSeccompCommandArgs { sandbox_policy_cwd: &sandbox_policy_cwd, command_cwd: command_cwd.as_deref(), @@ -315,10 +341,9 @@ pub fn run_main() -> ! { &sandbox_policy_cwd, command_cwd.as_deref(), &file_system_sandbox_policy, - bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy), + options, inner, proxy_controls, - !no_proc, ); } @@ -418,30 +443,21 @@ fn run_bwrap_with_proc_fallback( sandbox_policy_cwd: &Path, command_cwd: Option<&Path>, file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_mode: BwrapNetworkMode, + mut options: BwrapOptions, inner: Vec, proxy_controls: Vec, - mount_proc: bool, ) -> ! { - let mut mount_proc = mount_proc; let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd); - if mount_proc - && !preflight_proc_mount_support(network_mode) + if options.mount_proc + && !preflight_proc_mount_support(options.network_mode) .unwrap_or_else(|err| exit_with_bwrap_build_error(err)) { // Keep the retry silent so sandbox-internal diagnostics do not leak into the // child process stderr stream. - mount_proc = false; + options.mount_proc = false; } - let options = BwrapOptions { - mount_proc, - network_mode, - mask_wsl_interop: !file_system_sandbox_policy.has_full_disk_write_access() - && Path::new(WSL_INTEROP_DIR).is_dir(), - ..Default::default() - }; let mut bwrap_args = build_bwrap_argv( inner, file_system_sandbox_policy, diff --git a/codex-rs/linux-sandbox/src/wslg.rs b/codex-rs/linux-sandbox/src/wslg.rs new file mode 100644 index 0000000000..01b2eca294 --- /dev/null +++ b/codex-rs/linux-sandbox/src/wslg.rs @@ -0,0 +1,66 @@ +//! Identify WSLg's duplicate root without relying on filtered environment variables +//! or kernel branding. An unrelated directory at the same path is left untouched. +//! Explicit alias paths are rejected before building the restricted filesystem. + +use std::fs; +use std::io; +use std::os::unix::fs::MetadataExt; +use std::path::Path; + +pub(crate) fn is_duplicate_root(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(metadata) => { + let root = fs::metadata("/")?; + Ok(metadata.is_dir() && (metadata.dev(), metadata.ino()) == (root.dev(), root.ino())) + } + Err(err) + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + Ok(false) + } + Err(_) => { + // Mount metadata remains readable when an ancestor is not searchable. + // If neither observation is available, fail rather than guess that + // an inaccessible duplicate can safely remain in the sandbox. + let mountinfo = fs::read_to_string("/proc/self/mountinfo")?; + let root = mount_identity(&mountinfo, "/")? + .ok_or_else(|| io::Error::other("root mount identity is unavailable"))?; + Ok(Some(root) == mount_identity(&mountinfo, &path.to_string_lossy())?) + } + } +} + +pub(crate) fn ensure_supported_path(path: &Path) -> codex_protocol::error::Result<()> { + let alias = crate::bwrap::WSLG_DISTRO_ROOT; + if path.starts_with(alias) + || fs::canonicalize(path).is_ok_and(|resolved| resolved.starts_with(alias)) + { + return Err(codex_protocol::error::CodexErr::Fatal(format!( + "restricted sandboxes do not support paths under {alias}: {}; use the corresponding path under the primary filesystem root instead", + path.display() + ))); + } + Ok(()) +} + +fn mount_identity<'a>(mountinfo: &'a str, path: &str) -> io::Result> { + let mut identities = mountinfo.lines().filter_map(|line| { + let mut fields = line.split_ascii_whitespace(); + let device = fields.nth(2)?; + let root = fields.next()?; + let mountpoint = fields.next()?; + (mountpoint == path).then_some((device, root)) + }); + let identity = identities.next(); + if identities.any(|other| Some(other) != identity) { + return Err(io::Error::other("mount identity is ambiguous")); + } + Ok(identity) +} + +#[cfg(test)] +#[path = "wslg_tests.rs"] +mod tests; diff --git a/codex-rs/linux-sandbox/src/wslg_tests.rs b/codex-rs/linux-sandbox/src/wslg_tests.rs new file mode 100644 index 0000000000..2128433f1c --- /dev/null +++ b/codex-rs/linux-sandbox/src/wslg_tests.rs @@ -0,0 +1,49 @@ +use super::is_duplicate_root; +use super::mount_identity; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; + +#[test] +fn ordinary_paths_do_not_identify_a_duplicate_root() { + let temp = tempfile::tempdir().expect("temporary directory"); + let file = temp.path().join("file"); + fs::write(&file, "fixture").expect("write file"); + let link = temp.path().join("link"); + std::os::unix::fs::symlink("/", &link).expect("create symlink"); + let missing = temp.path().join("missing"); + let not_a_directory = file.join("child"); + for path in [temp.path(), &file, &link, &missing, ¬_a_directory] { + assert!(!is_duplicate_root(path).expect("inspect path")); + } + assert!(is_duplicate_root(Path::new("/")).expect("inspect root")); +} + +#[test] +fn mount_identity_distinguishes_roots_on_the_same_device() { + let mounts = "36 25 8:16 / / rw - ext4 /dev/sdb rw\n\ + 39 25 8:16 / /mnt/wslg/distro rw - ext4 /dev/sdb rw\n\ + 40 25 8:16 /other /unrelated rw - ext4 /dev/sdb rw\n\ + 41 25 0:45 / /different-device rw - tmpfs tmpfs rw\n"; + assert_eq!( + mount_identity(mounts, "/").unwrap(), + mount_identity(mounts, "/mnt/wslg/distro").unwrap() + ); + assert_ne!( + mount_identity(mounts, "/").unwrap(), + mount_identity(mounts, "/unrelated").unwrap() + ); + assert_ne!( + mount_identity(mounts, "/").unwrap(), + mount_identity(mounts, "/different-device").unwrap() + ); + assert_eq!(mount_identity(mounts, "/missing").unwrap(), None); +} + +#[test] +fn conflicting_stacked_mounts_are_ambiguous() { + let mounts = "36 25 8:16 / / rw - ext4 /dev/sdb rw\n\ + 39 25 8:16 / /mnt/wslg/distro rw - ext4 /dev/sdb rw\n\ + 40 39 0:45 / /mnt/wslg/distro rw - tmpfs tmpfs rw\n"; + assert!(mount_identity(mounts, "/mnt/wslg/distro").is_err()); +} diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index c21f6c21cc..a6915ba2da 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -25,6 +25,9 @@ use std::process::Output; use std::time::Duration; use tempfile::NamedTempFile; +#[path = "wslg_tests.rs"] +mod wslg_tests; + // At least on GitHub CI, the arm64 tests appear to need longer timeouts. #[cfg(not(target_arch = "aarch64"))] diff --git a/codex-rs/linux-sandbox/tests/suite/wslg_tests.rs b/codex-rs/linux-sandbox/tests/suite/wslg_tests.rs new file mode 100644 index 0000000000..8eb5efd590 --- /dev/null +++ b/codex-rs/linux-sandbox/tests/suite/wslg_tests.rs @@ -0,0 +1,80 @@ +//! WSLg runtime coverage for the duplicate-view mask and procfs fallback. + +use super::NETWORK_TIMEOUT_MS; +use super::codex_linux_sandbox_exe; +use super::should_skip_bwrap_tests; +use codex_protocol::models::PermissionProfile; +use pretty_assertions::assert_eq; +use std::os::unix::fs::MetadataExt as _; +use std::time::Duration; + +#[tokio::test] +async fn wslg_duplicate_view_is_masked_with_and_without_fresh_procfs() { + let root = std::fs::metadata("/").expect("inspect root"); + let has_wslg_root = std::fs::symlink_metadata("/mnt/wslg/distro").is_ok_and(|metadata| { + metadata.is_dir() && (metadata.dev(), metadata.ino()) == (root.dev(), root.ino()) + }); + if !has_wslg_root || should_skip_bwrap_tests().await { + eprintln!("skipping WSLg test: WSLg or bwrap prerequisites are unavailable"); + return; + } + + let workspace = tempfile::tempdir().expect("create workspace"); + let allowed = workspace.path().join("allowed.txt"); + std::fs::write(&allowed, "allowed\n").expect("write allowed fixture"); + let profile = serde_json::to_string(&PermissionProfile::read_only()) + .expect("serialize read-only profile"); + + for no_proc in [false, true] { + let mut command = tokio::process::Command::new(codex_linux_sandbox_exe()); + command + .arg("--sandbox-policy-cwd") + .arg(workspace.path()) + .args(["--permission-profile", &profile]); + if no_proc { + command.arg("--no-proc"); + } + // Inspect the protection itself, without accessing data through the alias. + let script = if no_proc { + "stat -c %a /mnt/wslg/distro && stat -f -c %T /mnt/wslg/distro && cat \"$1\" && test ! -e /proc/1" + } else { + "stat -c %a /mnt/wslg/distro && stat -f -c %T /mnt/wslg/distro && cat \"$1\"" + }; + let output = tokio::time::timeout( + Duration::from_millis(NETWORK_TIMEOUT_MS), + command + .args(["--", "/bin/sh", "-c", script, "sh"]) + .arg(&allowed) + .kill_on_drop(true) + .output(), + ) + .await + .expect("WSLg sandbox check should finish") + .expect("sandbox helper should start"); + assert!( + output.status.success(), + "WSLg mask check failed (no_proc={no_proc}): {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output.stdout, b"0\ntmpfs\nallowed\n"); + } + // Exercise the public helper boundary: production wraps the requested + // executable in another helper command before constructing bwrap args. + let output = tokio::process::Command::new(codex_linux_sandbox_exe()) + .arg("--sandbox-policy-cwd") + .arg(workspace.path()) + .args([ + "--permission-profile", + &profile, + "--", + "/mnt/wslg/distro/bin/true", + ]) + .output() + .await + .expect("sandbox helper should start"); + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + String::from_utf8(output.stderr).expect("UTF-8 diagnostic"), + "error building bubblewrap command: Fatal error: restricted sandboxes do not support paths under /mnt/wslg/distro: /mnt/wslg/distro/bin/true; use the corresponding path under the primary filesystem root instead\n" + ); +}