//! Bubblewrap-based filesystem sandboxing for Linux. //! //! This module mirrors the semantics used by the macOS Seatbelt sandbox: //! - the filesystem is read-only by default, //! - explicit writable roots are layered on top, and //! - sensitive subpaths such as `.git`, `.agents`, and `.codex` remain //! read-only even when their parent root is writable. //! //! 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. use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashSet; use std::ffi::OsString; use std::fs; use std::fs::File; use std::fs::Metadata; use std::io; use std::os::fd::AsRawFd; use std::os::unix::ffi::OsStringExt; use std::os::unix::fs::MetadataExt; use std::path::Path; use std::path::PathBuf; use std::process::Command; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::permissions::is_protected_metadata_name; use codex_protocol::protocol::FileSystemAccessMode; use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::WritableRoot; use codex_utils_absolute_path::AbsolutePathBuf; use globset::GlobBuilder; use globset::GlobSet; use globset::GlobSetBuilder; /// Linux "platform defaults" that keep common system binaries and dynamic /// libraries readable when a split filesystem policy requests `:minimal`. /// /// These are intentionally system-level paths only (plus Nix store roots) so /// `include_platform_defaults` does not silently widen access to user data. const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[ "/bin", "/sbin", "/usr", "/etc", "/lib", "/lib64", "/nix/store", "/run/current-system/sw", ]; const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192; /// Options that control how bubblewrap is invoked. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct BwrapOptions { /// Whether to mount a fresh `/proc` inside the sandbox. /// /// This is the secure default, but some restrictive container environments /// deny `--proc /proc`. pub mount_proc: bool, /// How networking should be configured inside the bubblewrap sandbox. pub network_mode: BwrapNetworkMode, /// Optional maximum depth for expanding unreadable glob patterns with ripgrep. /// /// Keep this uncapped by default so existing nested deny-read matches are /// masked before the sandboxed command starts. pub glob_scan_max_depth: Option, } impl Default for BwrapOptions { fn default() -> Self { Self { mount_proc: true, network_mode: BwrapNetworkMode::FullAccess, glob_scan_max_depth: None, } } } /// Network policy modes for bubblewrap. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum BwrapNetworkMode { /// Keep access to the host network namespace. #[default] FullAccess, /// Remove access to the host network namespace. Isolated, /// Intended proxy-only mode. /// /// Bubblewrap enforces this by unsharing the network namespace. The /// proxy-routing bridge is established by the helper process after startup. ProxyOnly, } impl BwrapNetworkMode { fn should_unshare_network(self) -> bool { !matches!(self, Self::FullAccess) } } #[derive(Debug)] pub(crate) struct BwrapArgs { pub args: Vec, pub preserved_files: Vec, pub synthetic_mount_targets: Vec, pub protected_create_targets: Vec, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct FileIdentity { dev: u64, ino: u64, } impl FileIdentity { fn from_metadata(metadata: &Metadata) -> Self { Self { dev: metadata.dev(), ino: metadata.ino(), } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum SyntheticMountTargetKind { EmptyFile, EmptyDirectory, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct SyntheticMountTarget { path: PathBuf, kind: SyntheticMountTargetKind, // If an empty metadata path was already present, remember its inode so // cleanup does not delete a real pre-existing file or directory. pre_existing_path: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ProtectedCreateTarget { path: PathBuf, } impl ProtectedCreateTarget { pub(crate) fn missing(path: &Path) -> Self { Self { path: path.to_path_buf(), } } pub(crate) fn path(&self) -> &Path { &self.path } } impl SyntheticMountTarget { pub(crate) fn missing(path: &Path) -> Self { Self { path: path.to_path_buf(), kind: SyntheticMountTargetKind::EmptyFile, pre_existing_path: None, } } pub(crate) fn missing_empty_directory(path: &Path) -> Self { Self { path: path.to_path_buf(), kind: SyntheticMountTargetKind::EmptyDirectory, pre_existing_path: None, } } pub(crate) fn existing_empty_file(path: &Path, metadata: &Metadata) -> Self { Self { path: path.to_path_buf(), kind: SyntheticMountTargetKind::EmptyFile, pre_existing_path: Some(FileIdentity::from_metadata(metadata)), } } fn existing_empty_directory(path: &Path, metadata: &Metadata) -> Self { Self { path: path.to_path_buf(), kind: SyntheticMountTargetKind::EmptyDirectory, pre_existing_path: Some(FileIdentity::from_metadata(metadata)), } } pub(crate) fn preserves_pre_existing_path(&self) -> bool { self.pre_existing_path.is_some() } pub(crate) fn path(&self) -> &Path { &self.path } pub(crate) fn kind(&self) -> SyntheticMountTargetKind { self.kind } pub(crate) fn should_remove_after_bwrap(&self, metadata: &Metadata) -> bool { match self.kind { SyntheticMountTargetKind::EmptyFile => { if !metadata.file_type().is_file() || metadata.len() != 0 { return false; } } SyntheticMountTargetKind::EmptyDirectory => { if !metadata.file_type().is_dir() { return false; } } } match self.pre_existing_path { Some(pre_existing_path) => pre_existing_path != FileIdentity::from_metadata(metadata), None => true, } } } /// Wrap a command with bubblewrap so the filesystem is read-only by default, /// with explicit writable roots and read-only subpaths layered afterward. /// /// When the policy grants full disk write access and full network access, this /// returns `command` unchanged so we avoid unnecessary sandboxing overhead. /// If network isolation is requested, we still wrap with bubblewrap so network /// namespace restrictions apply while preserving full filesystem access. pub(crate) fn create_bwrap_command_args( command: Vec, file_system_sandbox_policy: &FileSystemSandboxPolicy, sandbox_policy_cwd: &Path, command_cwd: &Path, options: BwrapOptions, ) -> Result { let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(sandbox_policy_cwd); // 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 { Ok(BwrapArgs { args: command, preserved_files: Vec::new(), synthetic_mount_targets: Vec::new(), protected_create_targets: Vec::new(), }) } else { Ok(create_bwrap_flags_full_filesystem(command, options)) }; } create_bwrap_flags( command, file_system_sandbox_policy, sandbox_policy_cwd, command_cwd, options, ) } fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOptions) -> BwrapArgs { let mut args = vec![ "--new-session".to_string(), "--die-with-parent".to_string(), "--bind".to_string(), "/".to_string(), "/".to_string(), // Always enter a fresh user namespace so root inside a container does // not need ambient CAP_SYS_ADMIN to create the remaining namespaces. "--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 { args, preserved_files: Vec::new(), synthetic_mount_targets: Vec::new(), protected_create_targets: Vec::new(), } } /// Build the bubblewrap flags (everything after `argv[0]`). fn create_bwrap_flags( command: Vec, file_system_sandbox_policy: &FileSystemSandboxPolicy, sandbox_policy_cwd: &Path, command_cwd: &Path, options: BwrapOptions, ) -> Result { 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), )?; let normalized_command_cwd = normalize_command_cwd_for_bwrap(command_cwd); let mut args = Vec::new(); args.push("--new-session".to_string()); args.push("--die-with-parent".to_string()); args.extend(filesystem_args); // 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()); if options.network_mode.should_unshare_network() { 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()); } 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())); } args.push("--".to_string()); args.extend(command); Ok(BwrapArgs { args, preserved_files, synthetic_mount_targets, protected_create_targets, }) } /// Build the bubblewrap filesystem mounts for a given filesystem policy. /// /// The mount order is important: /// 1. Full-read policies, and restricted policies that explicitly read `/`, /// use `--ro-bind / /`; other restricted-read policies start from /// `--tmpfs /` and layer scoped `--ro-bind` mounts. /// 2. `--dev /dev` mounts a minimal writable `/dev` with standard device nodes /// (including `/dev/urandom`) even under a read-only root. /// 3. Unreadable ancestors of writable roots are masked before their child /// mounts are rebound so nested writable carveouts can be reopened safely. /// 4. `--bind ` re-enables writes for allowed roots, including /// writable subpaths under `/dev` (for example, `/dev/shm`). /// 5. `--ro-bind ` re-applies read-only protections under /// those writable roots so protected subpaths win. /// 6. Nested unreadable carveouts under a writable root are masked after that /// root is bound, and unrelated unreadable roots are masked afterward. fn create_filesystem_args( file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path, glob_scan_max_depth: Option, ) -> Result { let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd); // Bubblewrap requires bind mount targets to exist. Skip missing writable // roots so mixed-platform configs can keep harmless paths for other // environments without breaking Linux command startup. let mut writable_roots = file_system_sandbox_policy .get_writable_roots_with_cwd(cwd) .into_iter() .filter(|writable_root| writable_root.root.as_path().exists()) .collect::>(); if writable_roots.is_empty() && file_system_sandbox_policy.has_full_disk_write_access() && !unreadable_globs.is_empty() { writable_roots.push(WritableRoot { root: AbsolutePathBuf::from_absolute_path("/")?, read_only_subpaths: Vec::new(), protected_metadata_names: Vec::new(), }); } let missing_auto_metadata_read_only_project_root_subpaths: HashSet = file_system_sandbox_policy .entries .iter() .filter(|entry| entry.access == FileSystemAccessMode::Read) .filter_map(|entry| { let FileSystemPath::Special { value: FileSystemSpecialPath::ProjectRoots { subpath: Some(subpath), }, } = &entry.path else { return None; }; // Automatic repo-metadata read masks are skipped here so the // metadata handling below can apply the root-scoped // protection consistently for `.git`, `.agents`, and `.codex`. // User-authored `read` rules for other subpaths and `none` // rules should keep their normal bwrap behavior, which can mask // the first missing component to prevent creation under writable // roots. let project_subpath = subpath.as_path(); if project_subpath != Path::new(".git") && project_subpath != Path::new(".agents") && project_subpath != Path::new(".codex") { return None; } let resolved = AbsolutePathBuf::resolve_path_against_base(subpath, cwd); (!resolved.as_path().exists()).then(|| resolved.into_path_buf()) }) .collect(); let mut unreadable_roots = file_system_sandbox_policy .get_unreadable_roots_with_cwd(cwd) .into_iter() .map(AbsolutePathBuf::into_path_buf) .collect::>(); // Bubblewrap can only mask concrete paths. Expand unreadable glob patterns // 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), ); unreadable_roots.sort(); unreadable_roots.dedup(); let args = if file_system_sandbox_policy.has_full_disk_read_access() { // Read-only root, then mount a minimal device tree. // In bubblewrap (`bubblewrap.c`, `SETUP_MOUNT_DEV`), `--dev /dev` // creates the standard minimal nodes: null, zero, full, random, // urandom, and tty. `/dev` must be mounted before writable roots so // explicit `/dev/*` writable binds remain visible. vec![ "--ro-bind".to_string(), "/".to_string(), "/".to_string(), "--dev".to_string(), "/dev".to_string(), ] } else { // Start from an empty filesystem and add only the approved readable // roots plus a minimal `/dev`. let mut args = vec![ "--tmpfs".to_string(), "/".to_string(), "--dev".to_string(), "/dev".to_string(), ]; let mut readable_roots: BTreeSet = file_system_sandbox_policy .get_readable_roots_with_cwd(cwd) .into_iter() .map(PathBuf::from) .collect(); if file_system_sandbox_policy.include_platform_defaults() { readable_roots.extend( LINUX_PLATFORM_DEFAULT_READ_ROOTS .iter() .map(|path| PathBuf::from(*path)) .filter(|path| path.exists()), ); } // A restricted policy can still explicitly request `/`, which is // the broad read baseline. Explicit unreadable carveouts are // re-applied later. if readable_roots.iter().any(|root| root == Path::new("/")) { args = vec![ "--ro-bind".to_string(), "/".to_string(), "/".to_string(), "--dev".to_string(), "/dev".to_string(), ]; } else { for root in readable_roots { if !root.exists() { continue; } // Writable roots are rebound by real target below; mirror that // for their restricted-read bootstrap mount. Plain read-only // roots must stay logical because callers may execute those // paths inside bwrap, such as Bazel runfiles helper binaries. let mount_root = if writable_roots .iter() .any(|writable_root| root.starts_with(writable_root.root.as_path())) { canonical_target_if_symlinked_path(&root).unwrap_or(root) } else { root }; args.push("--ro-bind".to_string()); args.push(path_to_string(&mount_root)); args.push(path_to_string(&mount_root)); } } args }; let mut bwrap_args = BwrapArgs { args, preserved_files: Vec::new(), synthetic_mount_targets: Vec::new(), protected_create_targets: Vec::new(), }; let mut allowed_write_paths = Vec::with_capacity(writable_roots.len()); for writable_root in &writable_roots { let root = writable_root.root.as_path(); allowed_write_paths.push(root.to_path_buf()); if let Some(target) = canonical_target_if_symlinked_path(root) { allowed_write_paths.push(target); } } let unreadable_paths: HashSet = unreadable_roots.iter().cloned().collect(); let mut sorted_writable_roots = writable_roots; sorted_writable_roots.sort_by_key(|writable_root| path_depth(writable_root.root.as_path())); // Mask only the unreadable ancestors that sit outside every writable root. // Unreadable paths nested under a broader writable root are applied after // that broader root is bound, then reopened by any deeper writable child. let mut unreadable_ancestors_of_writable_roots: Vec = unreadable_roots .iter() .filter(|path| { let unreadable_root = path.as_path(); !allowed_write_paths .iter() .any(|root| unreadable_root.starts_with(root)) && allowed_write_paths .iter() .any(|root| root.starts_with(unreadable_root)) }) .cloned() .collect(); unreadable_ancestors_of_writable_roots.sort_by_key(|path| path_depth(path)); for unreadable_root in &unreadable_ancestors_of_writable_roots { append_unreadable_root_args(&mut bwrap_args, unreadable_root, &allowed_write_paths)?; } for writable_root in &sorted_writable_roots { let root = writable_root.root.as_path(); let symlink_target = canonical_target_if_symlinked_path(root); // If a denied ancestor was already masked, recreate any missing mount // target parents before binding the narrower writable descendant. if let Some(masking_root) = unreadable_roots .iter() .map(PathBuf::as_path) .filter(|unreadable_root| root.starts_with(unreadable_root)) .max_by_key(|unreadable_root| path_depth(unreadable_root)) { append_mount_target_parent_dir_args(&mut bwrap_args.args, root, masking_root); } let mount_root = symlink_target.as_deref().unwrap_or(root); bwrap_args.args.push("--bind".to_string()); bwrap_args.args.push(path_to_string(mount_root)); bwrap_args.args.push(path_to_string(mount_root)); let mut read_only_subpaths: Vec = writable_root .read_only_subpaths .iter() .map(|path| path.as_path().to_path_buf()) .filter(|path| !unreadable_paths.contains(path)) .filter(|path| !missing_auto_metadata_read_only_project_root_subpaths.contains(path)) .collect(); let protected_metadata_names = writable_root.protected_metadata_names.clone(); append_metadata_path_masks_for_writable_root( &mut read_only_subpaths, root, mount_root, &protected_metadata_names, ); if let Some(target) = &symlink_target { read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target); } append_protected_create_targets_for_writable_root( &mut bwrap_args, &protected_metadata_names, root, symlink_target.as_deref(), &read_only_subpaths, ); read_only_subpaths.sort_by_key(|path| path_depth(path)); for subpath in read_only_subpaths { append_read_only_subpath_args(&mut bwrap_args, &subpath, &allowed_write_paths)?; } let mut nested_unreadable_roots: Vec = unreadable_roots .iter() .filter(|path| path.starts_with(root)) .cloned() .collect(); if let Some(target) = &symlink_target { nested_unreadable_roots = remap_paths_for_symlink_target(nested_unreadable_roots, root, target); } nested_unreadable_roots.sort_by_key(|path| path_depth(path)); for unreadable_root in nested_unreadable_roots { append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?; } } let mut rootless_unreadable_roots: Vec = unreadable_roots .iter() .filter(|path| { let unreadable_root = path.as_path(); !allowed_write_paths .iter() .any(|root| unreadable_root.starts_with(root) || root.starts_with(unreadable_root)) }) .cloned() .collect(); rootless_unreadable_roots.sort_by_key(|path| path_depth(path)); for unreadable_root in rootless_unreadable_roots { append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?; } Ok(bwrap_args) } fn append_protected_create_targets_for_writable_root( bwrap_args: &mut BwrapArgs, protected_metadata_names: &[String], root: &Path, symlink_target: Option<&Path>, read_only_subpaths: &[PathBuf], ) { for name in protected_metadata_names { let mut path = root.join(name); if let Some(target) = symlink_target && let Ok(relative_path) = path.strip_prefix(root) { path = target.join(relative_path); } if read_only_subpaths.iter().any(|subpath| subpath == &path) || path.exists() { continue; } bwrap_args .protected_create_targets .push(ProtectedCreateTarget::missing(&path)); } } fn append_metadata_path_masks_for_writable_root( read_only_subpaths: &mut Vec, root: &Path, mount_root: &Path, protected_metadata_names: &[String], ) { for name in protected_metadata_names { let path = root.join(name); if should_leave_missing_git_for_parent_repo_discovery(mount_root, name) { continue; } if !read_only_subpaths.iter().any(|subpath| subpath == &path) { read_only_subpaths.push(path); } } } fn should_leave_missing_git_for_parent_repo_discovery(mount_root: &Path, name: &str) -> bool { let path = mount_root.join(name); name == ".git" && matches!( path.symlink_metadata(), Err(err) if err.kind() == io::ErrorKind::NotFound ) && mount_root .ancestors() .skip(1) .any(ancestor_has_git_metadata) } fn ancestor_has_git_metadata(ancestor: &Path) -> bool { let git_path = ancestor.join(".git"); let Ok(metadata) = git_path.symlink_metadata() else { return false; }; if metadata.is_dir() { return git_path.join("HEAD").symlink_metadata().is_ok(); } if metadata.is_file() { return fs::read_to_string(git_path) .is_ok_and(|contents| contents.trim_start().starts_with("gitdir:")); } false } fn expand_unreadable_globs_with_ripgrep( patterns: &[String], cwd: &Path, max_depth: Option, ) -> Result> { if patterns.is_empty() || max_depth == Some(0) { return Ok(Vec::new()); } // Group each pattern by the static path prefix before its first glob // metacharacter. That keeps scans narrow, avoids searching from `/`, and // lets one `rg --files` call handle all patterns under the same root. let mut patterns_by_search_root: BTreeMap> = BTreeMap::new(); for pattern in patterns { if let Some((search_root, glob)) = split_pattern_for_ripgrep(pattern, cwd) && search_root.as_path().is_dir() { patterns_by_search_root .entry(search_root) .or_default() .push(glob); } } // Record both the logical match and any canonical symlink target. The bwrap // overlay needs the resolved target to prevent a readable symlink path from // bypassing an unreadable glob match. let mut expanded_paths = BTreeSet::new(); for (search_root, globs) in patterns_by_search_root { for path in ripgrep_files(search_root.as_path(), &globs, max_depth)? { if let Some(target) = canonical_target_if_symlinked_path(path.as_path()) { expanded_paths.insert(AbsolutePathBuf::from_absolute_path_checked(target)?); } expanded_paths.insert(path); if expanded_paths.len() > MAX_UNREADABLE_GLOB_MATCHES { return Err(CodexErr::Fatal(format!( "unreadable glob expansion for {} matched more than {MAX_UNREADABLE_GLOB_MATCHES} paths", search_root.display() ))); } } } Ok(expanded_paths.into_iter().collect()) } fn split_pattern_for_ripgrep(pattern: &str, cwd: &Path) -> Option<(AbsolutePathBuf, String)> { // Resolve relative patterns once, then split at the first glob // metacharacter. The prefix becomes the search root and the suffix stays as // the ripgrep glob. Root-level glob scans are intentionally skipped because // they are too broad for startup-time sandbox construction. let absolute_pattern = AbsolutePathBuf::resolve_path_against_base(pattern, cwd); let pattern = absolute_pattern.to_string_lossy(); let first_glob_index = pattern .char_indices() .find_map(|(index, ch)| matches!(ch, '*' | '?' | '[' | ']').then_some(index))?; let static_prefix = &pattern[..first_glob_index]; if static_prefix.is_empty() || static_prefix == "/" { return None; } let search_root_end = if static_prefix.ends_with('/') { static_prefix.len() - 1 } else { static_prefix.rfind('/').unwrap_or(0) }; let search_root = if search_root_end == 0 { PathBuf::from("/") } else { PathBuf::from(&pattern[..search_root_end]) }; let search_root = AbsolutePathBuf::from_absolute_path_checked(search_root).ok()?; let glob = escape_unclosed_glob_classes(&pattern[search_root_end + 1..]); (!glob.is_empty()).then_some((search_root, glob)) } fn escape_unclosed_glob_classes(glob: &str) -> String { // The filesystem policy accepts an unclosed `[` as a literal. Ripgrep treats // that as invalid glob syntax, so escape only the unclosed class opener. let mut escaped = String::with_capacity(glob.len()); let mut chars = glob.chars(); while let Some(ch) = chars.next() { if ch != '[' { escaped.push(ch); continue; } let mut class = String::new(); let mut closed = false; for class_ch in chars.by_ref() { if class_ch == ']' { closed = true; break; } class.push(class_ch); } if closed { escaped.push('['); escaped.push_str(&class); escaped.push(']'); } else { escaped.push_str(r"\["); escaped.push_str(&class); } } escaped } fn ripgrep_files( search_root: &Path, globs: &[String], max_depth: Option, ) -> Result> { // 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"); command .arg("--files") .arg("--hidden") .arg("--no-ignore") .arg("--null"); if let Some(max_depth) = max_depth { command.arg("--max-depth").arg(max_depth.to_string()); } for glob in globs { command.arg("--glob").arg(glob); } command.arg("--").arg(search_root); /* * Prefer ripgrep for unreadable glob expansion because it is fast and * already implements the file-walking semantics we want here: include * dotfiles, ignore ignore files, and do not recurse through symlinked * directories. If `rg` is not installed in the runtime environment, fall * back to the internal globset walker so sandbox construction still masks * matching paths. Other ripgrep failures stay fatal so deny-read does not * silently weaken. */ let output = match command.output() { Ok(output) => output, Err(err) if err.kind() == io::ErrorKind::NotFound => { return glob_files(search_root, globs, max_depth); } Err(err) => return Err(err.into()), }; if !output.status.success() { if output.status.code() == Some(1) && output.stderr.is_empty() { return Ok(Vec::new()); } let stderr = String::from_utf8_lossy(&output.stderr); return Err(CodexErr::Fatal(format!( "ripgrep unreadable glob scan failed for {}: {stderr}", search_root.display() ))); } let paths = output .stdout .split(|byte| *byte == b'\0') .filter(|path| !path.is_empty()) .map(|path| { let path = PathBuf::from(OsString::from_vec(path.to_vec())); if path.is_absolute() { path } else { search_root.join(path) } }) .map(AbsolutePathBuf::from_absolute_path_checked) .collect::>>()?; Ok(paths) } fn glob_files( search_root: &Path, globs: &[String], max_depth: Option, ) -> Result> { let mut builder = GlobSetBuilder::new(); for glob in globs { let glob = GlobBuilder::new(glob) .literal_separator(true) .allow_unclosed_class(true) .build() .map_err(|err| { CodexErr::Fatal(format!( "unreadable glob pattern is invalid for {}: {err}", search_root.display() )) })?; builder.add(glob); } let glob_set = builder.build().map_err(|err| { CodexErr::Fatal(format!( "unreadable glob matcher failed for {}: {err}", search_root.display() )) })?; let mut paths = Vec::new(); collect_glob_files(search_root, search_root, &glob_set, max_depth, &mut paths)?; Ok(paths) } fn collect_glob_files( search_root: &Path, dir: &Path, glob_set: &GlobSet, remaining_depth: Option, paths: &mut Vec, ) -> Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; let relative = path.strip_prefix(search_root).unwrap_or(path.as_path()); if (file_type.is_file() || file_type.is_symlink()) && glob_set.is_match(relative) { paths.push(AbsolutePathBuf::from_absolute_path_checked(&path)?); } if !file_type.is_dir() { continue; } let remaining_depth = match remaining_depth { Some(0 | 1) => continue, Some(depth) => Some(depth - 1), None => None, }; collect_glob_files(search_root, &path, glob_set, remaining_depth, paths)?; } Ok(()) } fn path_to_string(path: &Path) -> String { path.to_string_lossy().to_string() } fn path_depth(path: &Path) -> usize { path.components().count() } fn canonical_target_if_symlinked_path(path: &Path) -> Option { // Return the fully resolved target only when some path component is a // symlink. Callers use this to bind/mask the real filesystem location while // leaving ordinary paths in their logical form. let mut current = PathBuf::new(); for component in path.components() { use std::path::Component; match component { Component::RootDir => { current.push(Path::new("/")); continue; } Component::CurDir => continue, Component::ParentDir => { current.pop(); continue; } Component::Normal(part) => current.push(part), Component::Prefix(_) => continue, } let metadata = match fs::symlink_metadata(¤t) { Ok(metadata) => metadata, Err(_) => return None, }; if metadata.file_type().is_symlink() { let target = fs::canonicalize(path).ok()?; if target.as_path() == path { return None; } return Some(target); } } None } fn remap_paths_for_symlink_target(paths: Vec, root: &Path, target: &Path) -> Vec { paths .into_iter() .map(|path| { if let Ok(relative) = path.strip_prefix(root) { target.join(relative) } else { path } }) .collect() } fn normalize_command_cwd_for_bwrap(command_cwd: &Path) -> PathBuf { command_cwd .canonicalize() .unwrap_or_else(|_| command_cwd.to_path_buf()) } fn append_mount_target_parent_dir_args(args: &mut Vec, mount_target: &Path, anchor: &Path) { let mount_target_dir = if mount_target.is_dir() { mount_target } else if let Some(parent) = mount_target.parent() { parent } else { return; }; let mut mount_target_dirs: Vec = mount_target_dir .ancestors() .take_while(|path| *path != anchor) .map(Path::to_path_buf) .collect(); mount_target_dirs.reverse(); for mount_target_dir in mount_target_dirs { args.push("--dir".to_string()); args.push(path_to_string(&mount_target_dir)); } } fn append_read_only_subpath_args( bwrap_args: &mut BwrapArgs, subpath: &Path, allowed_write_paths: &[PathBuf], ) -> Result<()> { if let Some(symlink) = first_writable_symlink_component_in_path(subpath, allowed_write_paths) { /* * A read-only carveout under a writable symlink cannot be made reliable * with bwrap path arguments. Binding the symlink's current target would * only protect a startup-time snapshot; the sandboxed process could * replace the writable symlink before it reads through the logical path. */ return Err(CodexErr::Fatal(format!( "cannot enforce sandbox read-only path {} because it crosses writable symlink {}", subpath.display(), symlink.display() ))); } if let Some(metadata) = transient_empty_metadata_path(subpath) && is_within_allowed_write_paths(subpath, allowed_write_paths) { // Another concurrent bwrap setup can leave an empty mount target at // a missing metadata path. Treat it like the missing case instead of // binding that transient host path as the stable source. match metadata { EmptyProtectedMetadataPath::File(metadata) => { append_existing_empty_file_bind_data_args(bwrap_args, subpath, &metadata)?; } EmptyProtectedMetadataPath::Directory(metadata) => { append_existing_empty_directory_args(bwrap_args, subpath, &metadata); } } return Ok(()); } if !subpath.exists() { if let Some(first_missing_component) = find_first_non_existent_component(subpath) && is_within_allowed_write_paths(&first_missing_component, allowed_write_paths) { append_missing_read_only_subpath_args(bwrap_args, &first_missing_component)?; } return Ok(()); } if is_within_allowed_write_paths(subpath, allowed_write_paths) { bwrap_args.args.push("--ro-bind".to_string()); bwrap_args.args.push(path_to_string(subpath)); bwrap_args.args.push(path_to_string(subpath)); } Ok(()) } fn append_empty_file_bind_data_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> { if bwrap_args.preserved_files.is_empty() { bwrap_args.preserved_files.push(File::open("/dev/null")?); } let null_fd = bwrap_args.preserved_files[0].as_raw_fd().to_string(); bwrap_args.args.push("--ro-bind-data".to_string()); bwrap_args.args.push(null_fd); bwrap_args.args.push(path_to_string(path)); Ok(()) } fn append_empty_directory_args(bwrap_args: &mut BwrapArgs, path: &Path) { bwrap_args.args.push("--perms".to_string()); bwrap_args.args.push("555".to_string()); bwrap_args.args.push("--tmpfs".to_string()); bwrap_args.args.push(path_to_string(path)); bwrap_args.args.push("--remount-ro".to_string()); bwrap_args.args.push(path_to_string(path)); } fn append_missing_read_only_subpath_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> { if path.file_name().is_some_and(is_protected_metadata_name) { append_empty_directory_args(bwrap_args, path); bwrap_args .synthetic_mount_targets .push(SyntheticMountTarget::missing_empty_directory(path)); return Ok(()); } append_missing_empty_file_bind_data_args(bwrap_args, path) } fn append_missing_empty_file_bind_data_args(bwrap_args: &mut BwrapArgs, path: &Path) -> Result<()> { append_empty_file_bind_data_args(bwrap_args, path)?; bwrap_args .synthetic_mount_targets .push(SyntheticMountTarget::missing(path)); Ok(()) } fn append_existing_empty_file_bind_data_args( bwrap_args: &mut BwrapArgs, path: &Path, metadata: &Metadata, ) -> Result<()> { append_empty_file_bind_data_args(bwrap_args, path)?; bwrap_args .synthetic_mount_targets .push(SyntheticMountTarget::existing_empty_file(path, metadata)); Ok(()) } fn append_existing_empty_directory_args( bwrap_args: &mut BwrapArgs, path: &Path, metadata: &Metadata, ) { append_empty_directory_args(bwrap_args, path); bwrap_args .synthetic_mount_targets .push(SyntheticMountTarget::existing_empty_directory( path, metadata, )); } fn append_unreadable_root_args( bwrap_args: &mut BwrapArgs, unreadable_root: &Path, allowed_write_paths: &[PathBuf], ) -> Result<()> { if let Some(symlink) = first_writable_symlink_component_in_path(unreadable_root, allowed_write_paths) { /* * Deny-read masks must fail closed when the protected path crosses a * symlink that remains writable to the sandboxed process. Resolving and * masking the symlink's current target is a TOCTTOU snapshot: bwrap would * protect the old target while the logical path could later point * somewhere else. */ return Err(CodexErr::Fatal(format!( "cannot enforce sandbox deny-read path {} because it crosses writable symlink {}", unreadable_root.display(), symlink.display() ))); } if !unreadable_root.exists() { if let Some(first_missing_component) = find_first_non_existent_component(unreadable_root) && is_within_allowed_write_paths(&first_missing_component, allowed_write_paths) { append_missing_empty_file_bind_data_args(bwrap_args, &first_missing_component)?; } return Ok(()); } append_existing_unreadable_path_args(bwrap_args, unreadable_root, allowed_write_paths) } fn append_existing_unreadable_path_args( bwrap_args: &mut BwrapArgs, unreadable_root: &Path, allowed_write_paths: &[PathBuf], ) -> Result<()> { if unreadable_root.is_dir() { let mut writable_descendants: Vec<&Path> = allowed_write_paths .iter() .map(PathBuf::as_path) .filter(|path| *path != unreadable_root && path.starts_with(unreadable_root)) .collect(); bwrap_args.args.push("--perms".to_string()); // Execute-only perms let the process traverse into explicitly // re-opened writable descendants while still hiding the denied // directory contents. Plain denied directories with no writable child // mounts stay at `000`. bwrap_args.args.push(if writable_descendants.is_empty() { "000".to_string() } else { "111".to_string() }); bwrap_args.args.push("--tmpfs".to_string()); bwrap_args.args.push(path_to_string(unreadable_root)); // Recreate any writable descendants inside the tmpfs before remounting // the denied parent read-only. Otherwise bubblewrap cannot mkdir the // nested mount targets after the parent has been frozen. writable_descendants.sort_by_key(|path| path_depth(path)); for writable_descendant in writable_descendants { append_mount_target_parent_dir_args( &mut bwrap_args.args, writable_descendant, unreadable_root, ); } bwrap_args.args.push("--remount-ro".to_string()); bwrap_args.args.push(path_to_string(unreadable_root)); return Ok(()); } bwrap_args.args.push("--perms".to_string()); bwrap_args.args.push("000".to_string()); append_empty_file_bind_data_args(bwrap_args, unreadable_root) } /// Returns true when `path` is under any allowed writable root. fn is_within_allowed_write_paths(path: &Path, allowed_write_paths: &[PathBuf]) -> bool { allowed_write_paths .iter() .any(|root| path.starts_with(root)) } enum EmptyProtectedMetadataPath { File(Metadata), Directory(Metadata), } fn transient_empty_metadata_path(path: &Path) -> Option { if !path.file_name().is_some_and(is_protected_metadata_name) { return None; } let metadata = fs::symlink_metadata(path).ok()?; if metadata.file_type().is_file() && metadata.len() == 0 { return Some(EmptyProtectedMetadataPath::File(metadata)); } if metadata.file_type().is_dir() && directory_is_empty(path) { return Some(EmptyProtectedMetadataPath::Directory(metadata)); } None } fn directory_is_empty(path: &Path) -> bool { let Ok(mut entries) = fs::read_dir(path) else { return false; }; entries.next().is_none() } fn first_writable_symlink_component_in_path( target_path: &Path, allowed_write_paths: &[PathBuf], ) -> Option { /* * Walk the logical path and report the first symlink component that lives * under a writable root. These symlinks are mutable from inside the sandbox, * so any mount or mask based on their resolved target would be racing a path * the sandboxed process can change. */ let mut current = PathBuf::new(); for component in target_path.components() { use std::path::Component; match component { Component::RootDir => { current.push(Path::new("/")); continue; } Component::CurDir => continue, Component::ParentDir => { current.pop(); continue; } Component::Normal(part) => current.push(part), Component::Prefix(_) => continue, } let metadata = match std::fs::symlink_metadata(¤t) { Ok(metadata) => metadata, Err(_) => break, }; if metadata.file_type().is_symlink() && is_within_allowed_write_paths(¤t, allowed_write_paths) { return Some(current); } } None } /// Find the first missing path component while walking `target_path`. /// /// Mounting `/dev/null` on the first missing component prevents the sandboxed /// process from creating the protected path hierarchy. fn find_first_non_existent_component(target_path: &Path) -> Option { let mut current = PathBuf::new(); for component in target_path.components() { use std::path::Component; match component { Component::RootDir => { current.push(Path::new("/")); continue; } Component::CurDir => continue, Component::ParentDir => { current.pop(); continue; } Component::Normal(part) => current.push(part), Component::Prefix(_) => continue, } if !current.exists() { return Some(current); } } None } #[cfg(test)] mod tests { use super::*; use codex_protocol::protocol::FileSystemAccessMode; use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxEntry; use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::FileSystemSpecialPath; use codex_utils_absolute_path::AbsolutePathBuf; 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); } fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry { FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern }, access: FileSystemAccessMode::None, } } fn default_policy_with_unreadable_glob(pattern: String) -> FileSystemSandboxPolicy { let mut policy = FileSystemSandboxPolicy::default(); policy.entries.push(unreadable_glob_entry(pattern)); policy } #[test] fn full_disk_write_full_network_returns_unwrapped_command() { let command = vec!["/bin/true".to_string()]; let args = create_bwrap_command_args( command.clone(), &FileSystemSandboxPolicy::unrestricted(), Path::new("/"), Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::FullAccess, ..Default::default() }, ) .expect("create bwrap args"); assert_eq!(args.args, command); } #[test] fn full_disk_write_proxy_only_keeps_full_filesystem_but_unshares_network() { let command = vec!["/bin/true".to_string()]; let args = create_bwrap_command_args( command, &FileSystemSandboxPolicy::unrestricted(), Path::new("/"), Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::ProxyOnly, ..Default::default() }, ) .expect("create bwrap args"); assert_eq!( args.args, vec![ "--new-session".to_string(), "--die-with-parent".to_string(), "--bind".to_string(), "/".to_string(), "/".to_string(), "--unshare-user".to_string(), "--unshare-pid".to_string(), "--unshare-net".to_string(), "--proc".to_string(), "/proc".to_string(), "--".to_string(), "/bin/true".to_string(), ] ); } #[test] fn full_disk_write_with_unreadable_glob_still_wraps_and_masks_match() { if !ripgrep_available() { return; } let temp_dir = TempDir::new().expect("temp dir"); let root_env = temp_dir.path().join(".env"); std::fs::write(&root_env, "secret").expect("write env"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }, unreadable_glob_entry(format!("{}/**/*.env", temp_dir.path().display())), ]); let command = vec!["/bin/true".to_string()]; let args = create_bwrap_command_args( command.clone(), &policy, temp_dir.path(), temp_dir.path(), BwrapOptions::default(), ) .expect("create bwrap args"); assert_ne!( args.args, command, "full-write policy with unreadable globs must still use bwrap" ); assert_file_masked(&args.args, &root_env); } #[cfg(unix)] #[test] fn restricted_policy_chdirs_to_canonical_command_cwd() { let temp_dir = TempDir::new().expect("temp dir"); let real_root = temp_dir.path().join("real"); let real_subdir = real_root.join("subdir"); let link_root = temp_dir.path().join("link"); std::fs::create_dir_all(&real_subdir).expect("create real subdir"); std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlinked root"); let sandbox_policy_cwd = AbsolutePathBuf::from_absolute_path(&link_root) .expect("absolute symlinked root") .to_path_buf(); let command_cwd = link_root.join("subdir"); let canonical_command_cwd = real_subdir .canonicalize() .expect("canonicalize command cwd"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Minimal, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, ]); let args = create_bwrap_command_args( vec!["/bin/true".to_string()], &policy, sandbox_policy_cwd.as_path(), &command_cwd, BwrapOptions::default(), ) .expect("create bwrap args"); let canonical_sandbox_cwd = path_to_string( &real_root .canonicalize() .expect("canonicalize sandbox policy cwd"), ); let canonical_command_cwd = path_to_string(&canonical_command_cwd); let link_sandbox_cwd = path_to_string(&link_root); let link_command_cwd = path_to_string(&command_cwd); assert!( args.args .windows(2) .any(|window| { window == ["--chdir", canonical_command_cwd.as_str()] }) ); assert!(args.args.windows(3).any(|window| { window == [ "--ro-bind", canonical_sandbox_cwd.as_str(), canonical_sandbox_cwd.as_str(), ] })); assert!( !args .args .windows(2) .any(|window| { window == ["--chdir", link_command_cwd.as_str()] }) ); assert!(!args.args.windows(3).any(|window| { window == [ "--ro-bind", link_sandbox_cwd.as_str(), link_sandbox_cwd.as_str(), ] })); } #[cfg(unix)] #[test] fn symlinked_writable_roots_bind_real_target_and_remap_carveouts() { let temp_dir = TempDir::new().expect("temp dir"); let real_root = temp_dir.path().join("real"); let link_root = temp_dir.path().join("link"); let blocked = real_root.join("blocked"); std::fs::create_dir_all(&blocked).expect("create blocked dir"); std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlinked root"); let link_root = AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); let link_blocked = link_root.join("blocked"); let real_root_str = path_to_string(&real_root); let real_blocked_str = path_to_string(&blocked); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_root }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_blocked }, access: FileSystemAccessMode::None, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); assert!(args.args.windows(3).any(|window| { window == ["--bind", real_root_str.as_str(), real_root_str.as_str()] })); assert!(args.args.windows(6).any(|window| { window == [ "--perms", "000", "--tmpfs", real_blocked_str.as_str(), "--remount-ro", real_blocked_str.as_str(), ] })); } #[cfg(unix)] #[test] fn writable_roots_under_symlinked_ancestors_bind_real_target() { let temp_dir = TempDir::new().expect("temp dir"); let logical_home = temp_dir.path().join("home"); let real_codex = temp_dir.path().join("real-codex"); let logical_codex = logical_home.join(".codex"); let real_memories = real_codex.join("memories"); let logical_memories = logical_codex.join("memories"); std::fs::create_dir_all(&logical_home).expect("create logical home"); std::fs::create_dir_all(&real_memories).expect("create memories dir"); std::os::unix::fs::symlink(&real_codex, &logical_codex) .expect("create symlinked codex home"); let logical_memories_root = AbsolutePathBuf::from_absolute_path(&logical_memories).expect("absolute memories"); let real_memories_str = path_to_string(&real_memories); let logical_memories_str = path_to_string(&logical_memories); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: logical_memories_root, }, access: FileSystemAccessMode::Write, }]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); assert!(args.args.windows(3).any(|window| { window == [ "--bind", real_memories_str.as_str(), real_memories_str.as_str(), ] })); assert!(!args.args.windows(3).any(|window| { window == [ "--bind", logical_memories_str.as_str(), logical_memories_str.as_str(), ] })); } #[cfg(unix)] #[test] fn protected_symlinked_directory_subpaths_fail_closed() { let temp_dir = TempDir::new().expect("temp dir"); let root = temp_dir.path().join("root"); let agents_target = root.join("agents-target"); let agents_link = root.join(".agents"); std::fs::create_dir_all(&agents_target).expect("create agents target"); std::os::unix::fs::symlink(&agents_target, &agents_link).expect("create symlinked .agents"); let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root"); let agents_link_str = path_to_string(&agents_link); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: root }, access: FileSystemAccessMode::Write, }]); let err = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect_err("protected symlinked subpath should fail closed"); let message = err.to_string(); assert!( message.contains("cannot enforce sandbox read-only path"), "{message}" ); assert!(message.contains(&agents_link_str), "{message}"); } #[cfg(unix)] #[test] fn symlinked_writable_roots_nested_symlink_escape_paths_fail_closed() { let temp_dir = TempDir::new().expect("temp dir"); let real_root = temp_dir.path().join("real"); let link_root = temp_dir.path().join("link"); let outside = temp_dir.path().join("outside-private"); let linked_private = real_root.join("linked-private"); std::fs::create_dir_all(&real_root).expect("create real root"); std::fs::create_dir_all(&outside).expect("create outside dir"); std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlinked root"); std::os::unix::fs::symlink(&outside, &linked_private) .expect("create nested escape symlink"); let link_root = AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); let link_private = link_root.join("linked-private"); let real_linked_private_str = path_to_string(&linked_private); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_root }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_private }, access: FileSystemAccessMode::None, }, ]); 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 message = err.to_string(); assert!( message.contains("cannot enforce sandbox deny-read path"), "{message}" ); assert!(message.contains(&real_linked_private_str), "{message}"); } #[test] fn missing_read_only_subpath_uses_empty_file_bind_data() { let temp_dir = TempDir::new().expect("temp dir"); let workspace = temp_dir.path().join("workspace"); let blocked = workspace.join("blocked"); std::fs::create_dir_all(&workspace).expect("create workspace"); let workspace_root = AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); let blocked_root = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: workspace_root, }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked_root }, access: FileSystemAccessMode::Read, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); assert_empty_file_bound_without_perms(&args.args, &blocked); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".git")); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents")); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex")); assert_eq!(args.preserved_files.len(), 1); assert_eq!( synthetic_mount_target_paths(&args), vec![ blocked.clone(), workspace.join(".git"), workspace.join(".agents"), workspace.join(".codex"), ] ); assert!( !blocked.exists(), "missing path mask should not materialize host-side metadata paths at arg construction time", ); } #[test] fn transient_empty_preserved_file_uses_empty_file_bind_data() { let temp_dir = TempDir::new().expect("temp dir"); let workspace = temp_dir.path().join("workspace"); let dot_git = workspace.join(".git"); std::fs::create_dir_all(&workspace).expect("create workspace"); File::create(&dot_git).expect("create empty .git file"); let workspace_root = AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: workspace_root, }, access: FileSystemAccessMode::Write, }]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); let dot_git_str = path_to_string(&dot_git); assert_empty_file_bound_without_perms(&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")); assert_eq!( synthetic_mount_target_paths(&args), vec![ dot_git.clone(), workspace.join(".agents"), workspace.join(".codex"), ] ); assert!( !args .args .windows(3) .any(|window| window == ["--ro-bind", dot_git_str.as_str(), dot_git_str.as_str()]), "transient empty preserved file should not be treated as a stable bind source", ); let metadata = std::fs::symlink_metadata(&dot_git).expect("stat .git"); assert!( !args.synthetic_mount_targets[0].should_remove_after_bwrap(&metadata), "pre-existing empty preserved files must not be cleaned up as synthetic targets", ); } #[test] fn missing_child_git_under_parent_repo_uses_protected_create_target() { let temp_dir = TempDir::new().expect("temp dir"); let repo = temp_dir.path().join("repo"); let workspace = repo.join("workspace"); let dot_git = workspace.join(".git"); std::fs::create_dir_all(repo.join(".git")).expect("create parent .git"); std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); std::fs::create_dir_all(&workspace).expect("create workspace"); let workspace_root = AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: workspace_root, }, access: FileSystemAccessMode::Write, }]); let args = create_filesystem_args(&policy, &workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents")); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex")); let dot_git_str = path_to_string(&dot_git); assert!( !args .args .windows(4) .any(|window| window == ["--perms", "555", "--tmpfs", dot_git_str.as_str()]), "missing child .git should not shadow parent repo discovery", ); assert!( !synthetic_mount_target_paths(&args).contains(&dot_git), "missing child .git should not be a transient mount target", ); assert_eq!( protected_create_target_paths(&args), vec![dot_git], "missing child .git should fail through protected create cleanup", ); } #[cfg(unix)] #[test] fn symlinked_missing_child_git_under_parent_repo_uses_effective_mount_root() { let temp_dir = TempDir::new().expect("temp dir"); let repo = temp_dir.path().join("repo"); let workspace = repo.join("workspace"); let link_repo = temp_dir.path().join("link-repo"); let link_workspace = link_repo.join("workspace"); let dot_git = workspace.join(".git"); std::fs::create_dir_all(repo.join(".git")).expect("create parent .git"); std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); std::fs::create_dir_all(&workspace).expect("create workspace"); std::os::unix::fs::symlink(&repo, &link_repo).expect("create symlinked repo"); let link_workspace_root = AbsolutePathBuf::from_absolute_path(&link_workspace) .expect("absolute symlinked workspace"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_workspace_root, }, access: FileSystemAccessMode::Write, }]); let args = create_filesystem_args(&policy, &link_workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".agents")); assert_empty_directory_mounted_read_only(&args.args, &workspace.join(".codex")); let dot_git_str = path_to_string(&dot_git); assert!( !args .args .windows(4) .any(|window| window == ["--perms", "555", "--tmpfs", dot_git_str.as_str()]), "symlinked missing child .git should not shadow parent repo discovery", ); assert!( !synthetic_mount_target_paths(&args).contains(&dot_git), "symlinked missing child .git should not be a transient mount target", ); assert_eq!( protected_create_target_paths(&args), vec![dot_git], "symlinked missing child .git should fail through protected create cleanup", ); } #[test] fn ignores_missing_writable_roots() { let temp_dir = TempDir::new().expect("temp dir"); let existing_root = temp_dir.path().join("existing"); let missing_root = temp_dir.path().join("missing"); std::fs::create_dir(&existing_root).expect("create existing root"); let policy = FileSystemSandboxPolicy::workspace_write( &[ AbsolutePathBuf::try_from(existing_root.as_path()).expect("absolute existing root"), AbsolutePathBuf::try_from(missing_root.as_path()).expect("absolute missing root"), ], /*exclude_tmpdir_env_var*/ true, /*exclude_slash_tmp*/ true, ); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); let existing_root = path_to_string(&existing_root); let missing_root = path_to_string(&missing_root); assert!( args.args.windows(3).any(|window| { window == ["--bind", existing_root.as_str(), existing_root.as_str()] }), "existing writable root should be rebound writable", ); assert!( !args.args.iter().any(|arg| arg == &missing_root), "missing writable root should be skipped", ); } #[test] fn missing_project_root_metadata_carveouts_use_metadata_path_masks() { let temp_dir = TempDir::new().expect("temp dir"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(".git".into())), }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(".agents".into())), }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(".codex".into())), }, access: FileSystemAccessMode::Read, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .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")); assert_empty_directory_mounted_read_only(&args.args, Path::new(&dot_git)); assert_empty_directory_mounted_read_only(&args.args, Path::new(&dot_agents)); assert_empty_directory_mounted_read_only(&args.args, Path::new(&dot_codex)); assert!(args.preserved_files.is_empty()); let synthetic_targets = synthetic_mount_target_paths(&args); assert!(synthetic_targets.contains(&PathBuf::from(&dot_git))); assert!(synthetic_targets.contains(&PathBuf::from(&dot_agents))); assert!(synthetic_targets.contains(&PathBuf::from(&dot_codex))); assert_eq!( protected_create_target_paths(&args), Vec::::new(), "missing protected metadata paths should fail at creation time through read-only mounts", ); } #[test] fn missing_user_project_root_subpath_rules_are_still_enforced() { let temp_dir = TempDir::new().expect("temp dir"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(".vscode".into())), }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(".secrets".into())), }, access: FileSystemAccessMode::None, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .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")); assert_empty_file_bound_without_perms(&args.args, Path::new(&dot_vscode)); assert_empty_file_bound_without_perms(&args.args, Path::new(&dot_secrets)); } #[test] fn mounts_dev_before_writable_dev_binds() { let sandbox_policy = FileSystemSandboxPolicy::workspace_write( &[AbsolutePathBuf::try_from(Path::new("/dev")).expect("/dev path")], /*exclude_tmpdir_env_var*/ true, /*exclude_slash_tmp*/ true, ); let args = create_filesystem_args( &sandbox_policy, Path::new("/"), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH, ) .expect("bwrap fs args"); assert!(args.preserved_files.is_empty()); assert_eq!( synthetic_mount_target_paths(&args), vec![ PathBuf::from("/.git"), PathBuf::from("/.agents"), PathBuf::from("/.codex"), PathBuf::from("/dev/.git"), PathBuf::from("/dev/.agents"), PathBuf::from("/dev/.codex"), ] ); assert_eq!( args.args, vec![ // Start from a read-only view of the full filesystem. "--ro-bind".to_string(), "/".to_string(), "/".to_string(), // Recreate a writable /dev inside the sandbox. "--dev".to_string(), "/dev".to_string(), // Make the writable root itself writable again. "--bind".to_string(), "/".to_string(), "/".to_string(), // Mask the default metadata path names under the writable root. // Because the root is `/` in this test, these carveout paths // appear directly below `/`. "--perms".to_string(), "555".to_string(), "--tmpfs".to_string(), "/.git".to_string(), "--remount-ro".to_string(), "/.git".to_string(), "--perms".to_string(), "555".to_string(), "--tmpfs".to_string(), "/.agents".to_string(), "--remount-ro".to_string(), "/.agents".to_string(), "--perms".to_string(), "555".to_string(), "--tmpfs".to_string(), "/.codex".to_string(), "--remount-ro".to_string(), "/.codex".to_string(), // Rebind /dev after the root bind so device nodes remain // writable/usable inside the writable root. "--bind".to_string(), "/dev".to_string(), "/dev".to_string(), // Then mask the metadata names that would otherwise be // creatable below the writable /dev bind. "--perms".to_string(), "555".to_string(), "--tmpfs".to_string(), "/dev/.git".to_string(), "--remount-ro".to_string(), "/dev/.git".to_string(), "--perms".to_string(), "555".to_string(), "--tmpfs".to_string(), "/dev/.agents".to_string(), "--remount-ro".to_string(), "/dev/.agents".to_string(), "--perms".to_string(), "555".to_string(), "--tmpfs".to_string(), "/dev/.codex".to_string(), "--remount-ro".to_string(), "/dev/.codex".to_string(), ] ); } #[test] fn restricted_read_only_uses_scoped_read_roots_instead_of_erroring() { let temp_dir = TempDir::new().expect("temp dir"); let readable_root = temp_dir.path().join("readable"); std::fs::create_dir(&readable_root).expect("create readable root"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: AbsolutePathBuf::try_from(readable_root.as_path()) .expect("absolute readable root"), }, access: FileSystemAccessMode::Read, }]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); assert_eq!(args.args[0..4], ["--tmpfs", "/", "--dev", "/dev"]); let readable_root_str = path_to_string(&readable_root); assert!(args.args.windows(3).any(|window| { window == [ "--ro-bind", readable_root_str.as_str(), readable_root_str.as_str(), ] })); } #[test] fn restricted_read_only_with_platform_defaults_includes_usr_when_present() { let temp_dir = TempDir::new().expect("temp dir"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Minimal, }, access: FileSystemAccessMode::Read, }]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); assert!( args.args .starts_with(&["--tmpfs".to_string(), "/".to_string()]) ); if Path::new("/usr").exists() { assert!( args.args .windows(3) .any(|window| window == ["--ro-bind", "/usr", "/usr"]) ); } } #[test] fn split_policy_reapplies_unreadable_carveouts_after_writable_binds() { let temp_dir = TempDir::new().expect("temp dir"); let writable_root = temp_dir.path().join("workspace"); let blocked = writable_root.join("blocked"); std::fs::create_dir_all(&blocked).expect("create blocked dir"); let writable_root = AbsolutePathBuf::from_absolute_path(&writable_root).expect("absolute writable root"); let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked dir"); let writable_root_str = path_to_string(writable_root.as_path()); let blocked_str = path_to_string(blocked.as_path()); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: writable_root, }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked }, access: FileSystemAccessMode::None, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); assert!(args.args.windows(3).any(|window| { window == [ "--bind", writable_root_str.as_str(), writable_root_str.as_str(), ] })); let blocked_mask_index = args .args .windows(6) .position(|window| { window == [ "--perms", "000", "--tmpfs", blocked_str.as_str(), "--remount-ro", blocked_str.as_str(), ] }) .expect("blocked directory should be remounted unreadable"); let writable_root_bind_index = args .args .windows(3) .position(|window| { window == [ "--bind", writable_root_str.as_str(), writable_root_str.as_str(), ] }) .expect("writable root should be rebound writable"); assert!( writable_root_bind_index < blocked_mask_index, "expected unreadable carveout to be re-applied after writable bind: {:#?}", args.args ); } #[test] fn split_policy_reenables_nested_writable_subpaths_after_read_only_parent() { let temp_dir = TempDir::new().expect("temp dir"); let writable_root = temp_dir.path().join("workspace"); let docs = writable_root.join("docs"); let docs_public = docs.join("public"); std::fs::create_dir_all(&docs_public).expect("create docs/public"); let writable_root = AbsolutePathBuf::from_absolute_path(&writable_root).expect("absolute writable root"); let docs = AbsolutePathBuf::from_absolute_path(&docs).expect("absolute docs"); let docs_public = AbsolutePathBuf::from_absolute_path(&docs_public).expect("absolute docs/public"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: writable_root, }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs.clone() }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs_public.clone(), }, access: FileSystemAccessMode::Write, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .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 .args .windows(3) .position(|window| window == ["--ro-bind", docs_str.as_str(), docs_str.as_str()]) .expect("docs should be remounted read-only"); let docs_public_rw_index = args .args .windows(3) .position(|window| { window == ["--bind", docs_public_str.as_str(), docs_public_str.as_str()] }) .expect("docs/public should be rebound writable"); assert!( docs_ro_index < docs_public_rw_index, "expected read-only parent remount before nested writable bind: {:#?}", args.args ); } #[test] fn split_policy_reenables_writable_subpaths_after_unreadable_parent() { let temp_dir = TempDir::new().expect("temp dir"); let blocked = temp_dir.path().join("blocked"); let allowed = blocked.join("allowed"); std::fs::create_dir_all(&allowed).expect("create blocked/allowed"); let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked"); let allowed = AbsolutePathBuf::from_absolute_path(&allowed).expect("absolute allowed"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked.clone(), }, access: FileSystemAccessMode::None, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: allowed.clone(), }, access: FileSystemAccessMode::Write, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .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 .args .windows(4) .position(|window| window == ["--perms", "111", "--tmpfs", blocked_str.as_str()]) .expect("blocked should be masked first"); let allowed_dir_index = args .args .windows(2) .position(|window| window == ["--dir", allowed_str.as_str()]) .expect("allowed mount target should be recreated"); let blocked_remount_ro_index = args .args .windows(2) .position(|window| window == ["--remount-ro", blocked_str.as_str()]) .expect("blocked directory should be remounted read-only"); let allowed_bind_index = args .args .windows(3) .position(|window| window == ["--bind", allowed_str.as_str(), allowed_str.as_str()]) .expect("allowed path should be rebound writable"); assert!( blocked_none_index < allowed_dir_index && allowed_dir_index < blocked_remount_ro_index && blocked_remount_ro_index < allowed_bind_index, "expected writable child target recreation before remounting and rebinding under unreadable parent: {:#?}", args.args ); } #[test] fn split_policy_reenables_writable_files_after_unreadable_parent() { let temp_dir = TempDir::new().expect("temp dir"); let blocked = temp_dir.path().join("blocked"); let allowed_dir = blocked.join("allowed"); let allowed_file = allowed_dir.join("note.txt"); std::fs::create_dir_all(&allowed_dir).expect("create blocked/allowed"); std::fs::write(&allowed_file, "ok").expect("create note"); let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked"); let allowed_dir = AbsolutePathBuf::from_absolute_path(&allowed_dir).expect("absolute allowed dir"); let allowed_file = AbsolutePathBuf::from_absolute_path(&allowed_file).expect("absolute allowed file"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked.clone(), }, access: FileSystemAccessMode::None, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: allowed_file.clone(), }, access: FileSystemAccessMode::Write, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .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()); assert!( args.args .windows(2) .any(|window| window == ["--dir", allowed_dir_str.as_str()]), "expected ancestor directory to be recreated: {:#?}", args.args ); assert!( !args .args .windows(2) .any(|window| window == ["--dir", allowed_file_str.as_str()]), "writable file target should not be converted into a directory: {:#?}", args.args ); let blocked_none_index = args .args .windows(4) .position(|window| window == ["--perms", "111", "--tmpfs", blocked_str.as_str()]) .expect("blocked should be masked first"); let allowed_bind_index = args .args .windows(3) .position(|window| { window == [ "--bind", allowed_file_str.as_str(), allowed_file_str.as_str(), ] }) .expect("allowed file should be rebound writable"); assert!( blocked_none_index < allowed_bind_index, "expected unreadable parent mask before rebinding writable file child: {:#?}", args.args ); } #[test] fn split_policy_reenables_nested_writable_roots_after_unreadable_parent() { let temp_dir = TempDir::new().expect("temp dir"); let writable_root = temp_dir.path().join("workspace"); let blocked = writable_root.join("blocked"); let allowed = blocked.join("allowed"); std::fs::create_dir_all(&allowed).expect("create blocked/allowed dir"); let writable_root = AbsolutePathBuf::from_absolute_path(&writable_root).expect("absolute writable root"); let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked dir"); let allowed = AbsolutePathBuf::from_absolute_path(&allowed).expect("absolute allowed dir"); let blocked_str = path_to_string(blocked.as_path()); let allowed_str = path_to_string(allowed.as_path()); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: writable_root, }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked }, access: FileSystemAccessMode::None, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: allowed }, access: FileSystemAccessMode::Write, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); let blocked_none_index = args .args .windows(4) .position(|window| window == ["--perms", "111", "--tmpfs", blocked_str.as_str()]) .expect("blocked should be masked first"); let allowed_dir_index = args .args .windows(2) .position(|window| window == ["--dir", allowed_str.as_str()]) .expect("allowed mount target should be recreated"); let allowed_bind_index = args .args .windows(3) .position(|window| window == ["--bind", allowed_str.as_str(), allowed_str.as_str()]) .expect("allowed path should be rebound writable"); assert!( blocked_none_index < allowed_dir_index && allowed_dir_index < allowed_bind_index, "expected unreadable parent mask before recreating and rebinding writable child: {:#?}", args.args ); } #[test] fn split_policy_masks_root_read_directory_carveouts() { let temp_dir = TempDir::new().expect("temp dir"); let blocked = temp_dir.path().join("blocked"); std::fs::create_dir_all(&blocked).expect("create blocked dir"); let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked dir"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked.clone(), }, access: FileSystemAccessMode::None, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); let blocked_str = path_to_string(blocked.as_path()); assert!( args.args .windows(3) .any(|window| window == ["--ro-bind", "/", "/"]) ); assert!( args.args .windows(4) .any(|window| { window == ["--perms", "000", "--tmpfs", blocked_str.as_str()] }) ); assert!( args.args .windows(2) .any(|window| window == ["--remount-ro", blocked_str.as_str()]) ); } #[test] fn split_policy_masks_root_read_file_carveouts() { let temp_dir = TempDir::new().expect("temp dir"); let blocked_file = temp_dir.path().join("blocked.txt"); std::fs::write(&blocked_file, "secret").expect("create blocked file"); let blocked_file = AbsolutePathBuf::from_absolute_path(&blocked_file).expect("absolute blocked file"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: blocked_file.clone(), }, access: FileSystemAccessMode::None, }, ]); let args = create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) .expect("filesystem args"); let blocked_file_str = path_to_string(blocked_file.as_path()); assert_eq!(args.preserved_files.len(), 1); assert!(args.synthetic_mount_targets.is_empty()); assert!(args.args.windows(5).any(|window| { window[0] == "--perms" && window[1] == "000" && window[2] == "--ro-bind-data" && window[4] == blocked_file_str })); } #[test] fn unreadable_globs_expand_existing_matches_with_configured_depth() { if !ripgrep_available() { return; } let temp_dir = TempDir::new().expect("temp dir"); let root_env = temp_dir.path().join(".env"); let nested_env = temp_dir.path().join("app").join(".env"); let too_deep_env = temp_dir.path().join("app").join("deep").join(".env"); std::fs::create_dir_all(too_deep_env.parent().expect("parent")).expect("create parent"); std::fs::write(temp_dir.path().join(".gitignore"), ".env\n").expect("write gitignore"); std::fs::write(&root_env, "secret").expect("write root env"); std::fs::write(&nested_env, "secret").expect("write nested env"); std::fs::write(&too_deep_env, "secret").expect("write deep env"); 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"); assert_file_masked(&args.args, &root_env); assert_file_masked(&args.args, &nested_env); assert!( !args .args .iter() .any(|arg| arg == &path_to_string(&too_deep_env)), "max depth should keep deeper matches out of bwrap args: {:#?}", args.args ); } #[cfg(unix)] #[test] fn unreadable_globs_add_canonical_targets_for_symlink_matches() { if !ripgrep_available() { return; } let temp_dir = TempDir::new().expect("temp dir"); let real_root = temp_dir.path().join("real"); let link_root = temp_dir.path().join("link"); let real_secret = real_root.join("secret.env"); std::fs::create_dir_all(&real_root).expect("create real root"); std::fs::write(&real_secret, "secret").expect("write real secret"); std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlink"); 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"); assert_file_masked(&args.args, &real_secret); } #[test] fn root_prefix_unreadable_globs_are_too_broad_for_linux_expansion() { assert_eq!( split_pattern_for_ripgrep("/**/*.env", Path::new("/tmp")), None ); } #[test] fn unclosed_character_classes_are_escaped_for_ripgrep() { let (search_root, glob) = split_pattern_for_ripgrep("/tmp/[*.env", Path::new("/")).expect("split pattern"); assert_eq!(search_root.as_path(), Path::new("/tmp")); assert_eq!(glob, r"\[*.env"); } fn ripgrep_available() -> bool { Command::new("rg") .arg("--version") .output() .is_ok_and(|output| output.status.success()) } /// Assert that `path` is masked due to a bwrap arg sequence like: /// /// `bwrap ... --perms 000 --ro-bind-data FD PATH` fn assert_file_masked(args: &[String], path: &Path) { let path = path_to_string(path); assert!( args.windows(5).any(|window| { window[0] == "--perms" && window[1] == "000" && window[2] == "--ro-bind-data" && window[4] == path }), "expected file mask for {path}: {args:#?}" ); } /// Assert that `path` is backed by an fd-supplied empty file without /// changing the next mount operation's permissions. fn assert_empty_file_bound_without_perms(args: &[String], path: &Path) { let path = path_to_string(path); assert!( args.windows(3) .any(|window| { window[0] == "--ro-bind-data" && window[2] == path }), "expected empty file bind for {path}: {args:#?}" ); assert!( !args.windows(5).any(|window| { window[0] == "--perms" && window[1] == "000" && window[2] == "--ro-bind-data" && window[4] == path }), "missing path bind should not set explicit file perms for {path}: {args:#?}" ); } fn assert_empty_directory_mounted_read_only(args: &[String], path: &Path) { let path = path_to_string(path); assert!( args.windows(4) .any(|window| window == ["--perms", "555", "--tmpfs", path.as_str()]), "expected empty directory mount for {path}: {args:#?}" ); assert!( args.windows(2) .any(|window| window == ["--remount-ro", path.as_str()]), "expected read-only remount for {path}: {args:#?}" ); } fn synthetic_mount_target_paths(args: &BwrapArgs) -> Vec { args.synthetic_mount_targets .iter() .map(|target| target.path().to_path_buf()) .collect() } fn protected_create_target_paths(args: &BwrapArgs) -> Vec { args.protected_create_targets .iter() .map(|target| target.path().to_path_buf()) .collect() } }