diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 20c4f663bff9..08d9dce2ded2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3214,6 +3214,7 @@ dependencies = [ "codex-file-system", "codex-file-watcher", "codex-http-client", + "codex-linux-sandbox", "codex-mxc-sandbox", "codex-network-proxy", "codex-otel", diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index f5448fce2682..0d1f1f5209ca 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -73,6 +73,9 @@ tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true, features = ["v4"] } +[target.'cfg(target_os = "linux")'.dependencies] +codex-linux-sandbox = { workspace = true } + [target.'cfg(unix)'.dependencies] codex-uds = { workspace = true } libc = { workspace = true } diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index f58a4c59a437..8b64a8f8fd7a 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -41,7 +41,7 @@ mod rpc; mod rpc_server_requests; mod rpc_timing; mod runtime_options; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "linux", target_os = "windows"))] #[expect( dead_code, reason = "The integrity runner is added later in this stack." diff --git a/codex-rs/exec-server/src/sandbox_integrity/AGENTS.md b/codex-rs/exec-server/src/sandbox_integrity/AGENTS.md index 378eab822033..46b7d92cdd17 100644 --- a/codex-rs/exec-server/src/sandbox_integrity/AGENTS.md +++ b/codex-rs/exec-server/src/sandbox_integrity/AGENTS.md @@ -30,7 +30,7 @@ negatives. Backend glob/symlink nuances and writable hard-link aliases are not fully modeled. The separate glob scan can differ from enforcement's snapshot. Metrics describe observed checker outcomes, not accuracy or missed violations. -Windows scanner discovery currently uses `which`, while enforcement uses +MXC scanner discovery currently uses `which`, while enforcement uses `Command`. Their search-order and environment differences can select different executables or cause only one scan to use the internal walker. This is an accepted telemetry limitation for this phase; changing production scanner selection is deferred. diff --git a/codex-rs/exec-server/src/sandbox_integrity/backend_tests.rs b/codex-rs/exec-server/src/sandbox_integrity/backend_tests.rs index 8d71e1e0aca3..998d0507790d 100644 --- a/codex-rs/exec-server/src/sandbox_integrity/backend_tests.rs +++ b/codex-rs/exec-server/src/sandbox_integrity/backend_tests.rs @@ -7,6 +7,8 @@ use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +#[cfg(target_os = "linux")] +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::sandbox::SandboxOverride; use codex_sandboxing::FileContentsChecker; @@ -65,3 +67,106 @@ pub(super) fn sandbox_request( arg0: None, } } +#[cfg(target_os = "linux")] +#[test] +fn dependency_inventory_uses_launcher_location_and_command_path() -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir()?; + let root = AbsolutePathBuf::from_absolute_path(temp.path())?.canonicalize()?; + let cwd = root.join("command"); + let tools = root.join("tools"); + let bundled = tools.join("codex-resources/bwrap"); + fs::create_dir(&cwd)?; + fs::create_dir(&tools)?; + fs::create_dir(tools.join("codex-resources"))?; + for path in [ + tools.join("bwrap"), + tools.join("rg"), + cwd.join("rg"), + tools.join("codex"), + bundled.clone(), + ] { + fs::write(&path, "fixture")?; + fs::set_permissions(&path, fs::Permissions::from_mode(/*mode*/ 0o755))?; + } + let launcher = cwd.join("sandbox-helper"); + std::os::unix::fs::symlink(tools.join("codex"), &launcher)?; + let policy = FileSystemSandboxPolicy::restricted(vec![deny_glob("*.key")]); + let mut request = sandbox_request(&cwd, &policy); + request.command[0] = launcher.to_string_lossy().into_owned(); + for (path, scanner) in [ + ("../tools", tools.join("rg")), + (":../tools", tools.join("rg")), + ] { + request.env.insert("PATH".to_owned(), path.to_owned()); + let dependencies = backend::critical_dependencies(&request, &policy, &cwd)? + .into_iter() + .filter(|(name, _)| { + matches!( + *name, + "bundled_bwrap_candidate" | "system_bwrap_candidate" | "glob_scanner" + ) + }) + .map(|(name, path)| path.and_then(fs::canonicalize).map(|path| (name, path))) + .collect::>>()?; + assert_eq!( + dependencies, + vec![ + ("bundled_bwrap_candidate", bundled.to_path_buf()), + ( + "system_bwrap_candidate", + tools.join("bwrap").into_path_buf() + ), + ("glob_scanner", scanner.into_path_buf()), + ] + ); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +#[test] +fn policy_preparation_uses_command_tmpdir_and_home() -> io::Result<()> { + let temp = tempfile::tempdir()?; + let root = AbsolutePathBuf::from_absolute_path(temp.path())?.canonicalize()?; + let cwd = root.join("command"); + let home = root.join("home"); + fs::create_dir(&cwd)?; + fs::create_dir(&home)?; + let denied = home.join("secret.key"); + fs::write(&denied, "fixture")?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }, + FileSystemAccessMode::Write, + ), + deny_glob("~/secret.*"), + ]); + let mut request = sandbox_request(&cwd, &policy); + request + .env + .insert("HOME".to_owned(), home.to_string_lossy().into_owned()); + for (tmpdir, expected) in [ + ("", None), + ("scratch", Some(cwd.join("scratch"))), + ("~/scratch", Some(home.join("scratch"))), + ] { + request.env.insert("TMPDIR".to_owned(), tmpdir.to_owned()); + let mut entries = expected + .into_iter() + .map(|path| FileSystemSandboxEntry::new(path.into(), FileSystemAccessMode::Write)) + .collect::>(); + entries.push(FileSystemSandboxEntry::new( + denied.clone().into(), + FileSystemAccessMode::Deny, + )); + assert_eq!( + backend::prepare_policy(&request, policy.clone(), &cwd)?, + FileSystemSandboxPolicy::restricted(entries), + ); + } + Ok(()) +} diff --git a/codex-rs/exec-server/src/sandbox_integrity/bubblewrap.rs b/codex-rs/exec-server/src/sandbox_integrity/bubblewrap.rs new file mode 100644 index 000000000000..337a79120e32 --- /dev/null +++ b/codex-rs/exec-server/src/sandbox_integrity/bubblewrap.rs @@ -0,0 +1,134 @@ +//! Discover bubblewrap dependencies separately from resolving configured policy rules. + +use super::dependencies::Dependency; +use super::dependencies::codex_and_launcher_dependencies; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_sandboxing::SandboxExecRequest; +use codex_sandboxing::SandboxType; +use codex_shell_command::shell_detect::get_user_home_path; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use std::ffi::OsString; +use std::io; +use std::path::PathBuf; + +pub(super) const SANDBOX_TYPE: SandboxType = SandboxType::LinuxSeccomp; +pub(super) const NAME: &str = "bubblewrap"; + +pub(super) fn critical_dependencies( + request: &SandboxExecRequest, + policy: &FileSystemSandboxPolicy, + cwd: &AbsolutePathBuf, +) -> io::Result> { + let command_cwd = request.cwd.to_abs_path()?; + let path = request + .env + .get("PATH") + .map(|path| { + std::env::join_paths( + std::env::split_paths(path).map(|entry| command_cwd.as_path().join(entry)), + ) + }) + .transpose() + .map_err(io::Error::other)?; + let mut dependencies = codex_and_launcher_dependencies(request); + if let Some(exe) = request.command.first() + && let Some(bwrap) = codex_linux_sandbox::find_bundled_bwrap_for_exe( + &command_cwd.as_path().join(exe), + command_cwd.as_path(), + |key| request.env.get(key).map(OsString::from), + ) + { + dependencies.push(("bundled_bwrap_candidate", Ok(bwrap.into_path_buf()))); + } + if let Some(path) = &path + && let Some(bwrap) = codex_sandboxing::find_executable_in_search_paths( + "bwrap", + std::env::split_paths(path), + command_cwd.as_path(), + policy, + cwd.as_path(), + ) + { + // The launcher probes this executable even if it later uses its bundled copy. + dependencies.push(("system_bwrap_candidate", Ok(bwrap))); + } + if !policy + .get_unreadable_globs_with_cwd(cwd.as_path()) + .is_empty() + && let Some(path) = &path + && let Some(scanner) = codex_sandboxing::find_executable_in_search_paths( + codex_linux_sandbox::GLOB_SCAN_PROGRAM, + std::env::split_paths(path), + command_cwd.as_path(), + policy, + cwd.as_path(), + ) + { + dependencies.push(("glob_scanner", Ok(scanner))); + } + Ok(dependencies) +} + +pub(super) fn prepare_policy( + request: &SandboxExecRequest, + mut policy: FileSystemSandboxPolicy, + cwd: &AbsolutePathBuf, +) -> io::Result { + let command_cwd = request.cwd.to_abs_path()?; + let needs_home = policy.entries.iter().any(|entry| match &entry.path { + FileSystemPath::GlobPattern { pattern } => pattern.starts_with('~'), + FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + } => request + .env + .get("TMPDIR") + .is_some_and(|path| path.starts_with('~')), + FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => false, + }); + let prepare = || { + // The helper resolves :tmpdir from its filtered environment, not the executor's. + policy.entries.retain_mut(|entry| { + if matches!( + entry.path, + FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir + } + ) { + let Some(tmpdir) = request.env.get("TMPDIR").filter(|path| !path.is_empty()) else { + return false; + }; + entry.path = + AbsolutePathBuf::resolve_path_against_base(tmpdir, &command_cwd).into(); + } + true + }); + let patterns = policy.get_unreadable_globs_with_cwd(cwd.as_path()); + if !patterns.is_empty() { + let paths = codex_linux_sandbox::expand_unreadable_globs_in_environment( + &patterns, + cwd.as_path(), + &policy, + policy.glob_scan_max_depth, + &request.env, + command_cwd.as_path(), + ) + .map_err(io::Error::other)?; + policy = policy.with_expanded_deny_globs(paths); + } + Ok(policy) + }; + if !needs_home { + return prepare(); + } + let home = request + .env + .get("HOME") + .filter(|home| !home.is_empty()) + .map(PathBuf::from) + .or_else(get_user_home_path) + .ok_or_else(|| io::Error::other("could not resolve the command's home directory"))?; + AbsolutePathBufGuard::with_home_directory(&home, prepare) +} diff --git a/codex-rs/exec-server/src/sandbox_integrity/mod.rs b/codex-rs/exec-server/src/sandbox_integrity/mod.rs index 8ce7cd316f6b..6da90d810f99 100644 --- a/codex-rs/exec-server/src/sandbox_integrity/mod.rs +++ b/codex-rs/exec-server/src/sandbox_integrity/mod.rs @@ -1,4 +1,5 @@ -#[path = "mxc.rs"] +#[cfg_attr(target_os = "linux", path = "bubblewrap.rs")] +#[cfg_attr(target_os = "windows", path = "mxc.rs")] mod backend; mod dependencies; diff --git a/codex-rs/linux-sandbox/src/bazel_bwrap.rs b/codex-rs/linux-sandbox/src/bazel_bwrap.rs index 90e41c38496a..39dc69eda472 100644 --- a/codex-rs/linux-sandbox/src/bazel_bwrap.rs +++ b/codex-rs/linux-sandbox/src/bazel_bwrap.rs @@ -1,51 +1,57 @@ +use std::ffi::OsString; #[cfg(debug_assertions)] use std::fs::File; #[cfg(debug_assertions)] use std::io::BufRead; +use std::path::Path; use std::path::PathBuf; #[cfg(debug_assertions)] const BAZEL_BWRAP_ENV_VAR: &str = "CARGO_BIN_EXE_bwrap"; #[cfg(debug_assertions)] -pub(crate) fn candidate() -> Option { - if option_env!("BAZEL_PACKAGE").is_none() || !runfiles_env_present() { +pub(crate) fn candidate(cwd: &Path, env: impl Fn(&str) -> Option) -> Option { + if option_env!("BAZEL_PACKAGE").is_none() || !runfiles_env_present(&env) { return None; } - let raw = PathBuf::from(std::env::var_os(BAZEL_BWRAP_ENV_VAR)?); + let raw = PathBuf::from(env(BAZEL_BWRAP_ENV_VAR)?); if raw.is_absolute() { return Some(raw); } - resolve_runfile(raw.to_str()?) + resolve_runfile(raw.to_str()?, cwd, &env) } #[cfg(not(debug_assertions))] -pub(crate) fn candidate() -> Option { +pub(crate) fn candidate(_cwd: &Path, _env: impl Fn(&str) -> Option) -> Option { None } #[cfg(debug_assertions)] -fn runfiles_env_present() -> bool { - std::env::var_os("RUNFILES_DIR").is_some() - || std::env::var_os("TEST_SRCDIR").is_some() - || std::env::var_os("RUNFILES_MANIFEST_FILE").is_some() +fn runfiles_env_present(env: &impl Fn(&str) -> Option) -> bool { + env("RUNFILES_DIR").is_some() + || env("TEST_SRCDIR").is_some() + || env("RUNFILES_MANIFEST_FILE").is_some() } #[cfg(debug_assertions)] -fn resolve_runfile(logical_path: &str) -> Option { +fn resolve_runfile( + logical_path: &str, + cwd: &Path, + env: &impl Fn(&str) -> Option, +) -> Option { let mut logical_paths = vec![logical_path.to_string()]; - if let Ok(workspace) = std::env::var("TEST_WORKSPACE") + if let Some(workspace) = env("TEST_WORKSPACE").and_then(|value| value.into_string().ok()) && !workspace.is_empty() { logical_paths.push(format!("{workspace}/{logical_path}")); } for root_env in ["RUNFILES_DIR", "TEST_SRCDIR"] { - let Some(root) = std::env::var_os(root_env) else { + let Some(root) = env(root_env) else { continue; }; - let root = PathBuf::from(root); + let root = cwd.join(root); for logical_path in &logical_paths { let candidate = root.join(logical_path); if candidate.exists() { @@ -54,14 +60,14 @@ fn resolve_runfile(logical_path: &str) -> Option { } } - let manifest = PathBuf::from(std::env::var_os("RUNFILES_MANIFEST_FILE")?); + let manifest = cwd.join(env("RUNFILES_MANIFEST_FILE")?); let file = File::open(manifest).ok()?; for line in std::io::BufReader::new(file).lines().map_while(Result::ok) { let Some((key, value)) = line.split_once(' ') else { continue; }; if logical_paths.iter().any(|logical_path| logical_path == key) { - return Some(PathBuf::from(value)); + return Some(cwd.join(value)); } } None diff --git a/codex-rs/linux-sandbox/src/bundled_bwrap.rs b/codex-rs/linux-sandbox/src/bundled_bwrap.rs index b267b2b27751..7cf48734b720 100644 --- a/codex-rs/linux-sandbox/src/bundled_bwrap.rs +++ b/codex-rs/linux-sandbox/src/bundled_bwrap.rs @@ -1,5 +1,6 @@ use std::ffi::CStr; use std::ffi::CString; +use std::ffi::OsString; use std::fs::File; use std::io::Read; use std::os::fd::AsRawFd; @@ -27,9 +28,38 @@ pub(crate) struct BundledBwrapLauncher { pub(crate) fn launcher() -> Option { let current_exe = std::env::current_exe().ok()?; - find_for_install_context(InstallContext::current()) - .or_else(|| find_legacy_for_exe(¤t_exe)) - .map(|program| BundledBwrapLauncher { program }) + find_program( + InstallContext::current(), + ¤t_exe, + Path::new("."), + |key| std::env::var_os(key), + ) + .map(|program| BundledBwrapLauncher { program }) +} + +/// Discover the bundled fallback for an explicit launcher and its environment. +pub fn find_bundled_bwrap_for_exe( + exe: &Path, + command_cwd: &Path, + env: impl Fn(&str) -> Option, +) -> Option { + let exe = std::fs::canonicalize(exe).ok()?; + let context = InstallContext::from_exe( + /*is_macos*/ false, + Some(&exe), + /*method_override*/ None, + ); + find_program(&context, &exe, command_cwd, env) +} + +fn find_program( + context: &InstallContext, + exe: &Path, + cwd: &Path, + env: impl Fn(&str) -> Option, +) -> Option { + find_for_install_context(context) + .or_else(|| find_legacy_for_exe(exe, bazel_bwrap::candidate(cwd, env))) } impl BundledBwrapLauncher { @@ -77,8 +107,8 @@ fn find_for_install_context(context: &InstallContext) -> Option .filter(|path| is_executable_file(path)) } -fn find_legacy_for_exe(exe: &Path) -> Option { - legacy_candidates_for_exe(exe) +fn find_legacy_for_exe(exe: &Path, bazel_candidate: Option) -> Option { + legacy_candidates_for_exe(exe, bazel_candidate) .into_iter() .find(|candidate| is_executable_file(candidate)) .map(|path| { @@ -91,7 +121,7 @@ fn find_legacy_for_exe(exe: &Path) -> Option { }) } -fn legacy_candidates_for_exe(exe: &Path) -> Vec { +fn legacy_candidates_for_exe(exe: &Path, bazel_candidate: Option) -> Vec { let Some(exe_dir) = exe.parent() else { return Vec::new(); }; @@ -102,7 +132,7 @@ fn legacy_candidates_for_exe(exe: &Path) -> Vec { candidates.push(package_target_dir.join("codex-resources").join("bwrap")); } candidates.push(exe_dir.join("bwrap")); - if let Some(path) = bazel_bwrap::candidate() { + if let Some(path) = bazel_candidate { candidates.push(path); } candidates @@ -236,7 +266,7 @@ mod tests { write_executable(&expected_bwrap); assert_eq!( - find_legacy_for_exe(&exe), + find_legacy_for_exe(&exe, /*bazel_candidate*/ None), Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) ); } @@ -251,7 +281,7 @@ mod tests { write_executable(&expected_bwrap); assert_eq!( - find_legacy_for_exe(&exe), + find_legacy_for_exe(&exe, /*bazel_candidate*/ None), Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) ); } @@ -265,7 +295,7 @@ mod tests { write_executable(&expected_bwrap); assert_eq!( - find_legacy_for_exe(&exe), + find_legacy_for_exe(&exe, /*bazel_candidate*/ None), Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) ); } diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index f4a428b20b1b..51200dc4bda3 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -15,6 +15,7 @@ //! - bubblewrap used to construct the filesystem view before exec. use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsString; use std::fs; @@ -37,6 +38,7 @@ use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::WritableRoot; +use codex_sandboxing::find_executable_in_search_paths; use codex_sandboxing::find_pre_sandbox_executable_in_path; use codex_utils_absolute_path::AbsolutePathBuf; use globset::GlobBuilder; @@ -59,6 +61,9 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[ "/run/current-system/sw", ]; +/// External scanner used by deny-glob expansion and its dependency inventory. +pub const GLOB_SCAN_PROGRAM: &str = "rg"; + 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"; @@ -499,10 +504,17 @@ fn create_filesystem_args( expand_unreadable_globs_with_ripgrep( &unreadable_globs, cwd, - file_system_sandbox_policy, + || { + find_pre_sandbox_executable_in_path( + GLOB_SCAN_PROGRAM, + file_system_sandbox_policy, + cwd, + ) + }, options .glob_scan_max_depth .or(file_system_sandbox_policy.glob_scan_max_depth), + |_| {}, )? .into_iter() .map(AbsolutePathBuf::into_path_buf), @@ -888,17 +900,47 @@ fn append_daemon_socket_masks( Ok(()) } -fn expand_unreadable_globs_with_ripgrep( +/// Expand deny globs with the launcher's scanner in an explicit command environment. +/// The supplied environment replaces inheritance, including when it is empty. +pub fn expand_unreadable_globs_in_environment( patterns: &[String], cwd: &Path, file_system_policy: &FileSystemSandboxPolicy, max_depth: Option, + env: &HashMap, + command_cwd: &Path, +) -> Result> { + expand_unreadable_globs_with_ripgrep( + patterns, + cwd, + || { + find_executable_in_search_paths( + GLOB_SCAN_PROGRAM, + std::env::split_paths(env.get("PATH")?), + command_cwd, + file_system_policy, + cwd, + ) + }, + max_depth, + |command| { + command.env_clear().envs(env).current_dir(command_cwd); + }, + ) +} + +fn expand_unreadable_globs_with_ripgrep( + patterns: &[String], + cwd: &Path, + resolve_scanner: impl FnOnce() -> Option, + max_depth: Option, + configure_command: impl Fn(&mut Command), ) -> Result> { if patterns.is_empty() || max_depth == Some(0) { return Ok(Vec::new()); } - let rg_path = find_pre_sandbox_executable_in_path("rg", file_system_policy, cwd); + let rg_path = resolve_scanner(); // Group each pattern by the static path prefix before its first glob // metacharacter. That keeps scans narrow, avoids searching from `/`, and @@ -924,7 +966,13 @@ fn expand_unreadable_globs_with_ripgrep( let mut expanded_paths = BTreeSet::new(); for (search_root, globs) in patterns_by_search_root { let paths = match &rg_path { - Some(rg_path) => ripgrep_files(rg_path, search_root.as_path(), &globs, max_depth)?, + Some(rg_path) => ripgrep_files( + rg_path, + search_root.as_path(), + &globs, + max_depth, + &configure_command, + )?, None => glob_files(search_root.as_path(), &globs, max_depth)?, }; for path in paths { @@ -1013,11 +1061,13 @@ fn ripgrep_files( search_root: &Path, globs: &[String], max_depth: Option, + configure_command: &impl Fn(&mut Command), ) -> Result> { + let mut command = Command::new(rg_path); + configure_command(&mut command); // Use `rg --files` rather than shell expansion so dotfiles and ignored files // are still considered. A status 1 with no stderr is ripgrep's "no matches" // case, not a sandbox construction error. - let mut command = Command::new(rg_path); command .arg("--no-config") .arg("--files") @@ -3028,7 +3078,7 @@ mod tests { } fn ripgrep_available() -> bool { - Command::new("rg") + Command::new(GLOB_SCAN_PROGRAM) .arg("--version") .output() .is_ok_and(|output| output.status.success()) diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index e4056e1589e6..549527de2103 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -28,6 +28,13 @@ mod proxy_routing; #[cfg(target_os = "linux")] mod wslg; +#[cfg(target_os = "linux")] +pub use bundled_bwrap::find_bundled_bwrap_for_exe; +#[cfg(target_os = "linux")] +pub use bwrap::GLOB_SCAN_PROGRAM; +#[cfg(target_os = "linux")] +pub use bwrap::expand_unreadable_globs_in_environment; + /// Exit status returned when bundled bubblewrap fails digest verification. #[cfg(target_os = "linux")] pub const BUNDLED_BWRAP_DIGEST_VERIFICATION_FAILURE_EXIT_CODE: i32 = 8; diff --git a/codex-rs/sandboxing/src/bwrap.rs b/codex-rs/sandboxing/src/bwrap.rs index ebf7f8d60e02..876323ce074f 100644 --- a/codex-rs/sandboxing/src/bwrap.rs +++ b/codex-rs/sandboxing/src/bwrap.rs @@ -206,7 +206,8 @@ pub fn find_pre_sandbox_executable_in_path( ) } -fn find_executable_in_search_paths( +/// Apply the pre-sandbox executable selection rules to an explicit command context. +pub fn find_executable_in_search_paths( program: &str, search_paths: impl IntoIterator, cwd: &Path, diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index cad86f14674b..5db90bba1ed5 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -15,6 +15,8 @@ mod windows; #[cfg(windows)] mod windows_mxc; +#[cfg(target_os = "linux")] +pub use bwrap::find_executable_in_search_paths; #[cfg(target_os = "linux")] pub use bwrap::find_pre_sandbox_executable_in_path; #[cfg(target_os = "linux")] diff --git a/codex-rs/shell-command/src/shell_detect.rs b/codex-rs/shell-command/src/shell_detect.rs index c82d2634f6b3..3aecd03d9344 100644 --- a/codex-rs/shell-command/src/shell_detect.rs +++ b/codex-rs/shell-command/src/shell_detect.rs @@ -60,9 +60,22 @@ pub fn detect_shell_type(shell_path: impl AsRef) -> Option Option { + get_user_path(|passwd| passwd.pw_shell) + .map(|path| PathBuf::from(path.to_string_lossy().into_owned())) +} + +/// Read the current account's home directory without consulting the environment. +#[cfg(unix)] +pub fn get_user_home_path() -> Option { + get_user_path(|passwd| passwd.pw_dir).filter(|path| !path.as_os_str().is_empty()) +} + +#[cfg(unix)] +fn get_user_path(field: fn(&libc::passwd) -> *mut libc::c_char) -> Option { let uid = unsafe { libc::getuid() }; use std::ffi::CStr; use std::mem::MaybeUninit; + use std::os::unix::ffi::OsStrExt; use std::ptr; let mut passwd = MaybeUninit::::uninit(); @@ -96,14 +109,14 @@ fn get_user_shell_path() -> Option { } let passwd = unsafe { passwd.assume_init_ref() }; - if passwd.pw_shell.is_null() { + let path = field(passwd); + if path.is_null() { return None; } - let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } - .to_string_lossy() - .into_owned(); - return Some(PathBuf::from(shell_path)); + // Both callers select a field stored in the still-live passwd buffer. + let path = unsafe { CStr::from_ptr(path) }; + return Some(PathBuf::from(std::ffi::OsStr::from_bytes(path.to_bytes()))); } if status != libc::ERANGE {