Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 83 additions & 9 deletions codex-rs/linux-sandbox/src/bwrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ impl BwrapNetworkMode {
pub(crate) struct BwrapArgs {
pub args: Vec<String>,
pub preserved_files: Vec<File>,
pub cleanup_mount_points: Vec<PathBuf>,
}

/// Wrap a command with bubblewrap so the filesystem is read-only by default,
Expand All @@ -126,6 +127,7 @@ pub(crate) fn create_bwrap_command_args(
Ok(BwrapArgs {
args: command,
preserved_files: Vec::new(),
cleanup_mount_points: Vec::new(),
})
} else {
Ok(create_bwrap_flags_full_filesystem(command, options))
Expand Down Expand Up @@ -165,6 +167,7 @@ fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOption
BwrapArgs {
args,
preserved_files: Vec::new(),
cleanup_mount_points: Vec::new(),
}
}

Expand All @@ -179,6 +182,7 @@ fn create_bwrap_flags(
let BwrapArgs {
args: filesystem_args,
preserved_files,
cleanup_mount_points,
} = create_filesystem_args(
file_system_sandbox_policy,
sandbox_policy_cwd,
Expand Down Expand Up @@ -216,6 +220,7 @@ fn create_bwrap_flags(
Ok(BwrapArgs {
args,
preserved_files,
cleanup_mount_points,
})
}

Expand Down Expand Up @@ -348,6 +353,7 @@ fn create_filesystem_args(
args
};
let mut preserved_files = Vec::new();
let mut cleanup_mount_points = 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();
Expand Down Expand Up @@ -388,6 +394,9 @@ fn create_filesystem_args(

for writable_root in &sorted_writable_roots {
let root = writable_root.root.as_path();
if !root.exists() {
continue;
}
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.
Expand All @@ -401,10 +410,6 @@ fn create_filesystem_args(
}

let mount_root = symlink_target.as_deref().unwrap_or(root);
args.push("--bind".to_string());
args.push(path_to_string(mount_root));
args.push(path_to_string(mount_root));

let mut read_only_subpaths: Vec<PathBuf> = writable_root
.read_only_subpaths
.iter()
Expand All @@ -414,9 +419,18 @@ fn create_filesystem_args(
if let Some(target) = &symlink_target {
read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target);
}
args.push("--bind".to_string());
args.push(path_to_string(mount_root));
args.push(path_to_string(mount_root));

read_only_subpaths.sort_by_key(|path| path_depth(path));
for subpath in read_only_subpaths {
append_read_only_subpath_args(&mut args, &subpath, &allowed_write_paths)?;
append_read_only_subpath_args(
&mut args,
&mut cleanup_mount_points,
&subpath,
&allowed_write_paths,
)?;
}
let mut nested_unreadable_roots: Vec<PathBuf> = unreadable_roots
.iter()
Expand Down Expand Up @@ -461,6 +475,7 @@ fn create_filesystem_args(
Ok(BwrapArgs {
args,
preserved_files,
cleanup_mount_points,
})
}

Expand Down Expand Up @@ -785,8 +800,19 @@ fn append_mount_target_parent_dir_args(args: &mut Vec<String>, mount_target: &Pa
}
}

fn track_cleanup_mount_point(cleanup_mount_points: &mut Vec<PathBuf>, mount_point: &Path) {
if cleanup_mount_points
.iter()
.any(|existing| existing == mount_point)
{
return;
}
cleanup_mount_points.push(mount_point.to_path_buf());
}

fn append_read_only_subpath_args(
args: &mut Vec<String>,
cleanup_mount_points: &mut Vec<PathBuf>,
subpath: &Path,
allowed_write_paths: &[PathBuf],
) -> Result<()> {
Expand All @@ -811,6 +837,9 @@ fn append_read_only_subpath_args(
args.push("--ro-bind".to_string());
args.push("/dev/null".to_string());
args.push(path_to_string(&first_missing_component));
if first_missing_component.file_name() == Some(std::ffi::OsStr::new(".git")) {
track_cleanup_mount_point(cleanup_mount_points, &first_missing_component);
}
}
return Ok(());
}
Expand Down Expand Up @@ -1359,6 +1388,37 @@ mod tests {
assert!(message.contains(&real_linked_private_str), "{message}");
}

#[test]
fn missing_top_level_git_tracks_cleanup_mount_point() {
let temp_dir = TempDir::new().expect("temp dir");
let workspace = temp_dir.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("create workspace");

let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"),
},
access: FileSystemAccessMode::Write,
}]);

let args =
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
.expect("filesystem args");
let git_dest = path_to_string(&workspace.join(".git"));

assert!(
args.args
.windows(3)
.any(|window| { window == ["--ro-bind", "/dev/null", git_dest.as_str()] }),
"missing top level .git should be reserved with a /dev/null bind",
);
assert_eq!(args.cleanup_mount_points, vec![workspace.join(".git")]);
assert!(
!workspace.join(".git").exists(),
"cleanup tracking should not materialize host side .git at arg construction time",
);
}

#[test]
fn ignores_missing_writable_roots() {
let temp_dir = TempDir::new().expect("temp dir");
Expand Down Expand Up @@ -1393,7 +1453,10 @@ mod tests {
"existing writable root should be rebound writable",
);
assert!(
!args.args.iter().any(|arg| arg == &missing_root),
!args
.args
.iter()
.any(|arg| arg == &missing_root || arg.starts_with(&(missing_root.clone() + "/"))),
"missing writable root should be skipped",
);
}
Expand Down Expand Up @@ -1428,9 +1491,11 @@ mod tests {
"--bind".to_string(),
"/".to_string(),
"/".to_string(),
// Mask the default protected .codex subpath under that writable
// root. Because the root is `/` in this test, the carveout path
// appears as `/.codex`.
// Mask the default protected top level metadata paths under
// the writable root.
"--ro-bind".to_string(),
"/dev/null".to_string(),
"/.git".to_string(),
"--ro-bind".to_string(),
"/dev/null".to_string(),
"/.codex".to_string(),
Expand All @@ -1439,8 +1504,17 @@ mod tests {
"--bind".to_string(),
"/dev".to_string(),
"/dev".to_string(),
// The writable /dev carveout also needs its own protected
// top level metadata reservation when .git is absent.
"--ro-bind".to_string(),
"/dev/null".to_string(),
"/dev/.git".to_string(),
]
);
assert_eq!(
args.cleanup_mount_points,
vec![PathBuf::from("/.git"), PathBuf::from("/dev/.git")]
);
}

#[test]
Expand Down
72 changes: 71 additions & 1 deletion codex-rs/linux-sandbox/src/linux_run_main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ fn run_bwrap_with_proc_fallback(
options,
);
apply_inner_command_argv0(&mut bwrap_args.args);
exec_bwrap(bwrap_args.args, bwrap_args.preserved_files);
run_bwrap_command(bwrap_args);
}

fn bwrap_network_mode(
Expand Down Expand Up @@ -474,6 +474,7 @@ fn build_bwrap_argv(
crate::bwrap::BwrapArgs {
args: argv,
preserved_files: bwrap_args.preserved_files,
cleanup_mount_points: bwrap_args.cleanup_mount_points,
}
}

Expand Down Expand Up @@ -562,6 +563,73 @@ fn resolve_true_command() -> String {
"true".to_string()
}

fn cleanup_bwrap_mount_points(mount_points: &[PathBuf]) {
for mount_point in mount_points {
remove_bwrap_mount_point_if_safe(mount_point);
}
}

fn remove_bwrap_mount_point_if_safe(mount_point: &Path) {
let Ok(metadata) = std::fs::symlink_metadata(mount_point) else {
return;
};

if metadata.is_file() && metadata.len() == 0 {
let _ = std::fs::remove_file(mount_point);
return;
}

if metadata.is_dir() {
let Ok(mut entries) = std::fs::read_dir(mount_point) else {
return;
};
if entries.next().is_none() {
let _ = std::fs::remove_dir(mount_point);
}
}
}

fn run_bwrap_command(bwrap_args: crate::bwrap::BwrapArgs) -> ! {
if bwrap_args.cleanup_mount_points.is_empty() {
exec_bwrap(bwrap_args.args, bwrap_args.preserved_files);
}

// Bubblewrap materializes missing protected mount targets such as `.git`
// on the host before entering the child namespace, so wait for the child
// and remove only the synthetic empty mount points afterward.
run_bwrap_in_child_then_cleanup(bwrap_args)
}

fn run_bwrap_in_child_then_cleanup(bwrap_args: crate::bwrap::BwrapArgs) -> ! {
let pid = unsafe { libc::fork() };
if pid < 0 {
let err = std::io::Error::last_os_error();
panic!("failed to fork for bubblewrap: {err}");
}

if pid == 0 {
exec_bwrap(bwrap_args.args, bwrap_args.preserved_files);
}

let mut status: libc::c_int = 0;
let wait_res = unsafe { libc::waitpid(pid, &mut status as *mut libc::c_int, 0) };
if wait_res < 0 {
let err = std::io::Error::last_os_error();
panic!("waitpid failed for bubblewrap child: {err}");
}

cleanup_bwrap_mount_points(&bwrap_args.cleanup_mount_points);

if libc::WIFEXITED(status) {
std::process::exit(libc::WEXITSTATUS(status));
}
if libc::WIFSIGNALED(status) {
std::process::exit(128 + libc::WTERMSIG(status));
}

std::process::exit(1);
}

/// Run a short-lived bubblewrap preflight in a child process and capture stderr.
///
/// Strategy:
Expand Down Expand Up @@ -623,6 +691,8 @@ fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> Str
panic!("waitpid failed for bubblewrap child: {err}");
}

cleanup_bwrap_mount_points(&bwrap_args.cleanup_mount_points);

String::from_utf8_lossy(&stderr_bytes).into_owned()
}

Expand Down
42 changes: 42 additions & 0 deletions codex-rs/linux-sandbox/tests/suite/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,48 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
assert_ne!(codex_output.exit_code, 0);
}

#[tokio::test]
async fn sandbox_blocks_missing_git_creation_without_host_artifact() {
if should_skip_bwrap_tests().await {
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
return;
}

let tmpdir = tempfile::tempdir().expect("tempdir");
let allowed_target = tmpdir.path().join("allowed.txt");
let git_path = tmpdir.path().join(".git");

let output = expect_denied(
run_cmd_result_with_writable_roots(
&[
"bash",
"-lc",
&format!(
"cd {} && printf allowed > {} && git init -q",
tmpdir.path().to_string_lossy(),
allowed_target.to_string_lossy()
),
],
&[tmpdir.path().to_path_buf()],
LONG_TIMEOUT_MS,
/*use_legacy_landlock*/ false,
/*network_access*/ true,
)
.await,
"missing .git should stay blocked under bubblewrap",
);

assert_ne!(output.exit_code, 0);
assert_eq!(
std::fs::read_to_string(&allowed_target).expect("read allowed write target"),
"allowed",
);
assert!(
!git_path.exists(),
"sandbox should not materialize host side .git when the path is missing",
);
}

#[tokio::test]
async fn sandbox_blocks_codex_symlink_replacement_attack() {
if should_skip_bwrap_tests().await {
Expand Down
Loading
Loading