Files
codex/codex-rs/linux-sandbox/src/bwrap.rs
2026-05-01 08:48:56 -07:00

2537 lines
96 KiB
Rust

//! 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::path::Path;
use std::path::PathBuf;
use std::process::Command;
use crate::file_system_protected_metadata::FileSystemPermissionsEnforcement;
use crate::file_system_protected_metadata::SyntheticMountTarget;
use crate::file_system_protected_metadata::append_metadata_path_masks_for_writable_root;
use crate::file_system_protected_metadata::append_protected_create_targets_for_writable_root;
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<usize>,
}
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<String>,
pub preserved_files: Vec<File>,
pub file_system_permissions_enforcement: FileSystemPermissionsEnforcement,
}
/// 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<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_policy_cwd: &Path,
command_cwd: &Path,
options: BwrapOptions,
) -> Result<BwrapArgs> {
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(),
file_system_permissions_enforcement: FileSystemPermissionsEnforcement::default(),
})
} 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<String>, 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(),
file_system_permissions_enforcement: FileSystemPermissionsEnforcement::default(),
}
}
/// Build the bubblewrap flags (everything after `argv[0]`).
fn create_bwrap_flags(
command: Vec<String>,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_policy_cwd: &Path,
command_cwd: &Path,
options: BwrapOptions,
) -> Result<BwrapArgs> {
let BwrapArgs {
args: filesystem_args,
preserved_files,
file_system_permissions_enforcement,
} = 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,
file_system_permissions_enforcement,
})
}
/// 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 <root> <root>` re-enables writes for allowed roots, including
/// writable subpaths under `/dev` (for example, `/dev/shm`).
/// 5. `--ro-bind <subpath> <subpath>` 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<usize>,
) -> Result<BwrapArgs> {
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::<Vec<_>>();
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<PathBuf> =
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::<Vec<_>>();
// 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<PathBuf> = 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(),
file_system_permissions_enforcement: FileSystemPermissionsEnforcement::default(),
};
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<PathBuf> = 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<PathBuf> = 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<PathBuf> = 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.file_system_permissions_enforcement,
&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<PathBuf> = 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<PathBuf> = 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 expand_unreadable_globs_with_ripgrep(
patterns: &[String],
cwd: &Path,
max_depth: Option<usize>,
) -> Result<Vec<AbsolutePathBuf>> {
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<AbsolutePathBuf, Vec<String>> = 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<usize>,
) -> Result<Vec<AbsolutePathBuf>> {
// 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::<io::Result<Vec<_>>>()?;
Ok(paths)
}
fn glob_files(
search_root: &Path,
globs: &[String],
max_depth: Option<usize>,
) -> Result<Vec<AbsolutePathBuf>> {
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<usize>,
paths: &mut Vec<AbsolutePathBuf>,
) -> 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<PathBuf> {
// 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(&current) {
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<PathBuf>, root: &Path, target: &Path) -> Vec<PathBuf> {
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<String>, 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<PathBuf> = 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
.file_system_permissions_enforcement
.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
.file_system_permissions_enforcement
.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
.file_system_permissions_enforcement
.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
.file_system_permissions_enforcement
.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<EmptyProtectedMetadataPath> {
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<PathBuf> {
/*
* 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(&current) {
Ok(metadata) => metadata,
Err(_) => break,
};
if metadata.file_type().is_symlink()
&& is_within_allowed_write_paths(&current, 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<PathBuf> {
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<usize> = 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
.file_system_permissions_enforcement
.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::<PathBuf>::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.file_system_permissions_enforcement
.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<PathBuf> {
args.file_system_permissions_enforcement
.synthetic_mount_targets
.iter()
.map(|target| target.path().to_path_buf())
.collect()
}
fn protected_create_target_paths(args: &BwrapArgs) -> Vec<PathBuf> {
args.file_system_permissions_enforcement
.protected_create_targets
.iter()
.map(|target| target.path().to_path_buf())
.collect()
}
}