mirror of
https://github.com/openai/codex.git
synced 2026-05-01 09:56:37 +00:00
Compare commits
1 Commits
iceweasel/
...
codex/bugb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
253e8dfc2f |
@@ -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,
|
||||
@@ -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))
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -216,6 +220,7 @@ fn create_bwrap_flags(
|
||||
Ok(BwrapArgs {
|
||||
args,
|
||||
preserved_files,
|
||||
cleanup_mount_points,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,7 +246,8 @@ fn create_filesystem_args(
|
||||
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
|
||||
let mut cleanup_mount_points = Vec::new();
|
||||
// Bubblewrap requires bind mount sources 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
|
||||
@@ -381,6 +387,7 @@ fn create_filesystem_args(
|
||||
append_unreadable_root_args(
|
||||
&mut args,
|
||||
&mut preserved_files,
|
||||
&mut cleanup_mount_points,
|
||||
unreadable_root,
|
||||
&allowed_write_paths,
|
||||
)?;
|
||||
@@ -401,22 +408,35 @@ 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()
|
||||
.map(|path| path.as_path().to_path_buf())
|
||||
.filter(|path| !unreadable_paths.contains(path))
|
||||
.collect();
|
||||
if root == cwd {
|
||||
let top_level_git = root.join(".git");
|
||||
if !read_only_subpaths.iter().any(|path| path == &top_level_git)
|
||||
&& !unreadable_paths.contains(&top_level_git)
|
||||
{
|
||||
read_only_subpaths.push(top_level_git);
|
||||
}
|
||||
}
|
||||
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()
|
||||
@@ -432,6 +452,7 @@ fn create_filesystem_args(
|
||||
append_unreadable_root_args(
|
||||
&mut args,
|
||||
&mut preserved_files,
|
||||
&mut cleanup_mount_points,
|
||||
&unreadable_root,
|
||||
&allowed_write_paths,
|
||||
)?;
|
||||
@@ -453,6 +474,7 @@ fn create_filesystem_args(
|
||||
append_unreadable_root_args(
|
||||
&mut args,
|
||||
&mut preserved_files,
|
||||
&mut cleanup_mount_points,
|
||||
&unreadable_root,
|
||||
&allowed_write_paths,
|
||||
)?;
|
||||
@@ -461,6 +483,7 @@ fn create_filesystem_args(
|
||||
Ok(BwrapArgs {
|
||||
args,
|
||||
preserved_files,
|
||||
cleanup_mount_points,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -787,6 +810,7 @@ fn append_mount_target_parent_dir_args(args: &mut Vec<String>, mount_target: &Pa
|
||||
|
||||
fn append_read_only_subpath_args(
|
||||
args: &mut Vec<String>,
|
||||
cleanup_mount_points: &mut Vec<PathBuf>,
|
||||
subpath: &Path,
|
||||
allowed_write_paths: &[PathBuf],
|
||||
) -> Result<()> {
|
||||
@@ -808,9 +832,7 @@ fn append_read_only_subpath_args(
|
||||
if let Some(first_missing_component) = find_first_non_existent_component(subpath)
|
||||
&& is_within_allowed_write_paths(&first_missing_component, allowed_write_paths)
|
||||
{
|
||||
args.push("--ro-bind".to_string());
|
||||
args.push("/dev/null".to_string());
|
||||
args.push(path_to_string(&first_missing_component));
|
||||
append_missing_path_mask_args(args, cleanup_mount_points, &first_missing_component);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -823,9 +845,51 @@ fn append_read_only_subpath_args(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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_empty_file_mask_args(
|
||||
args: &mut Vec<String>,
|
||||
preserved_files: &mut Vec<File>,
|
||||
path: &Path,
|
||||
) -> Result<()> {
|
||||
if preserved_files.is_empty() {
|
||||
preserved_files.push(File::open("/dev/null")?);
|
||||
}
|
||||
let null_fd = preserved_files[0].as_raw_fd().to_string();
|
||||
args.push("--perms".to_string());
|
||||
args.push("000".to_string());
|
||||
args.push("--ro-bind-data".to_string());
|
||||
args.push(null_fd);
|
||||
args.push(path_to_string(path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_missing_path_mask_args(
|
||||
args: &mut Vec<String>,
|
||||
cleanup_mount_points: &mut Vec<PathBuf>,
|
||||
mount_point: &Path,
|
||||
) {
|
||||
args.push("--perms".to_string());
|
||||
args.push("000".to_string());
|
||||
args.push("--tmpfs".to_string());
|
||||
args.push(path_to_string(mount_point));
|
||||
args.push("--remount-ro".to_string());
|
||||
args.push(path_to_string(mount_point));
|
||||
track_cleanup_mount_point(cleanup_mount_points, mount_point);
|
||||
}
|
||||
|
||||
fn append_unreadable_root_args(
|
||||
args: &mut Vec<String>,
|
||||
preserved_files: &mut Vec<File>,
|
||||
cleanup_mount_points: &mut Vec<PathBuf>,
|
||||
unreadable_root: &Path,
|
||||
allowed_write_paths: &[PathBuf],
|
||||
) -> Result<()> {
|
||||
@@ -850,9 +914,7 @@ fn append_unreadable_root_args(
|
||||
if let Some(first_missing_component) = find_first_non_existent_component(unreadable_root)
|
||||
&& is_within_allowed_write_paths(&first_missing_component, allowed_write_paths)
|
||||
{
|
||||
args.push("--ro-bind".to_string());
|
||||
args.push("/dev/null".to_string());
|
||||
args.push(path_to_string(&first_missing_component));
|
||||
append_missing_path_mask_args(args, cleanup_mount_points, &first_missing_component);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -901,16 +963,7 @@ fn append_existing_unreadable_path_args(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if preserved_files.is_empty() {
|
||||
preserved_files.push(File::open("/dev/null")?);
|
||||
}
|
||||
let null_fd = preserved_files[0].as_raw_fd().to_string();
|
||||
args.push("--perms".to_string());
|
||||
args.push("000".to_string());
|
||||
args.push("--ro-bind-data".to_string());
|
||||
args.push(null_fd);
|
||||
args.push(path_to_string(unreadable_root));
|
||||
Ok(())
|
||||
append_empty_file_mask_args(args, preserved_files, unreadable_root)
|
||||
}
|
||||
|
||||
/// Returns true when `path` is under any allowed writable root.
|
||||
@@ -965,8 +1018,8 @@ fn first_writable_symlink_component_in_path(
|
||||
|
||||
/// 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.
|
||||
/// Masking 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();
|
||||
|
||||
@@ -1359,6 +1412,97 @@ mod tests {
|
||||
assert!(message.contains(&real_linked_private_str), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_default_metadata_paths_use_tmpfs_mask() {
|
||||
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, &workspace, NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
|
||||
.expect("filesystem args");
|
||||
|
||||
assert_missing_path_masked(&args.args, &workspace.join(".codex"));
|
||||
assert_missing_path_masked(&args.args, &workspace.join(".git"));
|
||||
assert!(args.preserved_files.is_empty());
|
||||
assert_eq!(
|
||||
args.cleanup_mount_points,
|
||||
vec![workspace.join(".codex"), workspace.join(".git")]
|
||||
);
|
||||
assert!(
|
||||
!workspace.join(".codex").exists() && !workspace.join(".git").exists(),
|
||||
"tmpfs mask should not materialize host side metadata paths at arg construction time",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_read_only_subpath_uses_tmpfs_mask() {
|
||||
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_missing_path_masked(&args.args, &blocked);
|
||||
assert_eq!(args.cleanup_mount_points, vec![blocked]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_unreadable_path_uses_tmpfs_mask() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let workspace = temp_dir.path().join("workspace");
|
||||
let secret = workspace.join("secret");
|
||||
std::fs::create_dir_all(&workspace).expect("create workspace");
|
||||
|
||||
let workspace_root =
|
||||
AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace");
|
||||
let secret_root = AbsolutePathBuf::from_absolute_path(&secret).expect("absolute secret");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: workspace_root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: secret_root },
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
]);
|
||||
|
||||
let args =
|
||||
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
|
||||
.expect("filesystem args");
|
||||
|
||||
assert_missing_path_masked(&args.args, &secret);
|
||||
assert_eq!(args.cleanup_mount_points, vec![secret]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_missing_writable_roots() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
@@ -1393,8 +1537,10 @@ mod tests {
|
||||
"existing writable root should be rebound writable",
|
||||
);
|
||||
assert!(
|
||||
!args.args.iter().any(|arg| arg == &missing_root),
|
||||
"missing writable root should be skipped",
|
||||
!args.args.windows(3).any(|window| {
|
||||
window == ["--bind", missing_root.as_str(), missing_root.as_str()]
|
||||
}),
|
||||
"missing writable root should not be rebound writable",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1414,6 +1560,11 @@ mod tests {
|
||||
NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH,
|
||||
)
|
||||
.expect("bwrap fs args");
|
||||
assert!(args.preserved_files.is_empty());
|
||||
assert_eq!(
|
||||
args.cleanup_mount_points,
|
||||
vec![PathBuf::from("/.codex"), PathBuf::from("/.git")]
|
||||
);
|
||||
assert_eq!(
|
||||
args.args,
|
||||
vec![
|
||||
@@ -1430,10 +1581,19 @@ mod tests {
|
||||
"/".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`.
|
||||
"--ro-bind".to_string(),
|
||||
"/dev/null".to_string(),
|
||||
// appears as `/.codex` and `/.git`.
|
||||
"--perms".to_string(),
|
||||
"000".to_string(),
|
||||
"--tmpfs".to_string(),
|
||||
"/.codex".to_string(),
|
||||
"--remount-ro".to_string(),
|
||||
"/.codex".to_string(),
|
||||
"--perms".to_string(),
|
||||
"000".to_string(),
|
||||
"--tmpfs".to_string(),
|
||||
"/.git".to_string(),
|
||||
"--remount-ro".to_string(),
|
||||
"/.git".to_string(),
|
||||
// Rebind /dev after the root bind so device nodes remain
|
||||
// writable/usable inside the writable root.
|
||||
"--bind".to_string(),
|
||||
@@ -2018,4 +2178,25 @@ mod tests {
|
||||
"expected file mask for {path}: {args:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Assert that `path` is masked due to a bwrap arg sequence like:
|
||||
///
|
||||
/// `bwrap ... --perms 000 --tmpfs PATH --remount-ro PATH`
|
||||
fn assert_missing_path_masked(args: &[String], path: &Path) {
|
||||
let path = path_to_string(path);
|
||||
assert!(
|
||||
args.windows(6).any(|window| {
|
||||
window
|
||||
== [
|
||||
"--perms",
|
||||
"000",
|
||||
"--tmpfs",
|
||||
path.as_str(),
|
||||
"--remount-ro",
|
||||
path.as_str(),
|
||||
]
|
||||
}),
|
||||
"expected missing path mask for {path}: {args:#?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -474,9 +474,46 @@ fn build_bwrap_argv(
|
||||
crate::bwrap::BwrapArgs {
|
||||
args: argv,
|
||||
preserved_files: bwrap_args.preserved_files,
|
||||
cleanup_mount_points: bwrap_args.cleanup_mount_points,
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
unsafe { libc::_exit(libc::WEXITSTATUS(status)) };
|
||||
}
|
||||
if libc::WIFSIGNALED(status) {
|
||||
unsafe { libc::_exit(128 + libc::WTERMSIG(status)) };
|
||||
}
|
||||
unsafe { libc::_exit(1) };
|
||||
}
|
||||
|
||||
fn apply_inner_command_argv0(argv: &mut Vec<String>) {
|
||||
apply_inner_command_argv0_for_launcher(
|
||||
argv,
|
||||
@@ -529,8 +566,8 @@ fn preflight_proc_mount_support(
|
||||
file_system_sandbox_policy,
|
||||
network_mode,
|
||||
);
|
||||
let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
|
||||
!is_proc_mount_failure(stderr.as_str())
|
||||
let output = run_bwrap_in_child_capture_stderr(preflight_argv);
|
||||
bwrap_child_succeeded(output.status) && !is_proc_mount_failure(output.stderr.as_str())
|
||||
}
|
||||
|
||||
fn build_preflight_bwrap_argv(
|
||||
@@ -573,7 +610,13 @@ fn resolve_true_command() -> String {
|
||||
/// - We capture stderr from that preflight to match known mount-failure text.
|
||||
/// We do not stream it because this is a one-shot probe with a trivial
|
||||
/// command, and reads are bounded to a fixed max size.
|
||||
fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> String {
|
||||
#[derive(Debug)]
|
||||
struct BwrapChildOutput {
|
||||
stderr: String,
|
||||
status: libc::c_int,
|
||||
}
|
||||
|
||||
fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> BwrapChildOutput {
|
||||
const MAX_PREFLIGHT_STDERR_BYTES: u64 = 64 * 1024;
|
||||
|
||||
let mut pipe_fds = [0; 2];
|
||||
@@ -623,7 +666,42 @@ fn run_bwrap_in_child_capture_stderr(bwrap_args: crate::bwrap::BwrapArgs) -> Str
|
||||
panic!("waitpid failed for bubblewrap child: {err}");
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&stderr_bytes).into_owned()
|
||||
cleanup_bwrap_mount_points(&bwrap_args.cleanup_mount_points);
|
||||
|
||||
BwrapChildOutput {
|
||||
stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
fn bwrap_child_succeeded(status: libc::c_int) -> bool {
|
||||
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0
|
||||
}
|
||||
|
||||
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.file_type().is_file() && metadata.len() == 0 {
|
||||
let _ = std::fs::remove_file(mount_point);
|
||||
return;
|
||||
}
|
||||
|
||||
if metadata.file_type().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close an owned file descriptor and panic with context on failure.
|
||||
|
||||
@@ -37,6 +37,54 @@ fn ignores_non_proc_mount_errors() {
|
||||
assert!(!is_proc_mount_failure(stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwrap_child_success_requires_clean_exit_status() {
|
||||
assert!(bwrap_child_succeeded(wait_status_for_exit_code(
|
||||
/*exit_code*/ 0
|
||||
)));
|
||||
assert!(!bwrap_child_succeeded(wait_status_for_exit_code(
|
||||
/*exit_code*/ 1
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_empty_synthetic_mount_point() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let git_path = temp_dir.path().join(".git");
|
||||
File::create(&git_path).expect("create empty git file");
|
||||
|
||||
remove_bwrap_mount_point_if_safe(&git_path);
|
||||
|
||||
assert!(!git_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_preserves_real_git_file() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let git_path = temp_dir.path().join(".git");
|
||||
std::fs::write(&git_path, "gitdir: /tmp/worktree\n").expect("write git file");
|
||||
|
||||
remove_bwrap_mount_point_if_safe(&git_path);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&git_path).expect("read git file"),
|
||||
"gitdir: /tmp/worktree\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_preserves_nonempty_git_directory() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let git_path = temp_dir.path().join(".git");
|
||||
std::fs::create_dir(&git_path).expect("create git dir");
|
||||
std::fs::write(git_path.join("config"), "[core]\n").expect("write git config");
|
||||
|
||||
remove_bwrap_mount_point_if_safe(&git_path);
|
||||
|
||||
assert!(git_path.exists());
|
||||
assert!(git_path.join("config").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_bwrap_argv0_before_command_separator() {
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
@@ -536,3 +584,8 @@ fn valid_inner_stage_modes_do_not_panic() {
|
||||
/*apply_seccomp_then_exec*/ true, /*use_legacy_landlock*/ false,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn wait_status_for_exit_code(exit_code: libc::c_int) -> libc::c_int {
|
||||
exit_code << 8
|
||||
}
|
||||
|
||||
@@ -48,6 +48,19 @@ fn create_env_from_core_vars() -> HashMap<String, String> {
|
||||
create_env(&policy, /*thread_id*/ None)
|
||||
}
|
||||
|
||||
fn codex_linux_sandbox_exe() -> PathBuf {
|
||||
let sandbox_program = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox"));
|
||||
if sandbox_program.is_absolute() {
|
||||
return sandbox_program;
|
||||
}
|
||||
|
||||
if let Ok(current_dir) = std::env::current_dir() {
|
||||
current_dir.join(sandbox_program)
|
||||
} else {
|
||||
sandbox_program
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::print_stdout)]
|
||||
async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
|
||||
let output = run_cmd_output(cmd, writable_roots, timeout_ms).await;
|
||||
@@ -75,12 +88,32 @@ async fn run_cmd_output(
|
||||
.expect("sandboxed command should execute")
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
async fn run_cmd_result_with_writable_roots(
|
||||
cmd: &[&str],
|
||||
writable_roots: &[PathBuf],
|
||||
timeout_ms: u64,
|
||||
use_legacy_landlock: bool,
|
||||
network_access: bool,
|
||||
) -> Result<codex_protocol::exec_output::ExecToolCallOutput> {
|
||||
run_cmd_result_with_writable_roots_and_cwd(
|
||||
cmd,
|
||||
writable_roots,
|
||||
&AbsolutePathBuf::current_dir().expect("cwd should exist"),
|
||||
timeout_ms,
|
||||
use_legacy_landlock,
|
||||
network_access,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_cmd_result_with_writable_roots_and_cwd(
|
||||
cmd: &[&str],
|
||||
writable_roots: &[PathBuf],
|
||||
cwd: &AbsolutePathBuf,
|
||||
timeout_ms: u64,
|
||||
use_legacy_landlock: bool,
|
||||
network_access: bool,
|
||||
) -> Result<codex_protocol::exec_output::ExecToolCallOutput> {
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: writable_roots
|
||||
@@ -102,26 +135,26 @@ async fn run_cmd_result_with_writable_roots(
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
cwd,
|
||||
timeout_ms,
|
||||
use_legacy_landlock,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
async fn run_cmd_result_with_policies(
|
||||
cmd: &[&str],
|
||||
sandbox_policy: SandboxPolicy,
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
cwd: &AbsolutePathBuf,
|
||||
timeout_ms: u64,
|
||||
use_legacy_landlock: bool,
|
||||
) -> Result<codex_protocol::exec_output::ExecToolCallOutput> {
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd should exist");
|
||||
let sandbox_cwd = cwd.clone();
|
||||
let params = ExecParams {
|
||||
command: cmd.iter().copied().map(str::to_owned).collect(),
|
||||
cwd,
|
||||
cwd: cwd.clone(),
|
||||
expiration: timeout_ms.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
env: create_env_from_core_vars(),
|
||||
@@ -132,8 +165,7 @@ async fn run_cmd_result_with_policies(
|
||||
justification: None,
|
||||
arg0: None,
|
||||
};
|
||||
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
|
||||
let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program));
|
||||
let codex_linux_sandbox_exe = Some(codex_linux_sandbox_exe());
|
||||
|
||||
process_exec_tool_call(
|
||||
params,
|
||||
@@ -394,8 +426,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
|
||||
};
|
||||
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox");
|
||||
let codex_linux_sandbox_exe: Option<PathBuf> = Some(PathBuf::from(sandbox_program));
|
||||
let codex_linux_sandbox_exe: Option<PathBuf> = Some(codex_linux_sandbox_exe());
|
||||
let result = process_exec_tool_call(
|
||||
params,
|
||||
&sandbox_policy,
|
||||
@@ -505,6 +536,50 @@ 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 sandbox_cwd =
|
||||
AbsolutePathBuf::try_from(tmpdir.path()).expect("tempdir should be an absolute cwd");
|
||||
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_and_cwd(
|
||||
&[
|
||||
"bash",
|
||||
"-lc",
|
||||
&format!(
|
||||
"printf allowed > {} && git init -q",
|
||||
allowed_target.to_string_lossy(),
|
||||
),
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
&sandbox_cwd,
|
||||
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 {
|
||||
@@ -554,7 +629,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() {
|
||||
let blocked_target = blocked.join("secret.txt");
|
||||
// These tests bypass the usual legacy-policy bridge, so explicitly keep
|
||||
// the sandbox helper binary and minimal runtime paths readable.
|
||||
let sandbox_helper_dir = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox"))
|
||||
let sandbox_helper_dir = codex_linux_sandbox_exe()
|
||||
.parent()
|
||||
.expect("sandbox helper should have a parent")
|
||||
.to_path_buf();
|
||||
@@ -603,6 +678,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() {
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
&AbsolutePathBuf::current_dir().expect("cwd should exist"),
|
||||
LONG_TIMEOUT_MS,
|
||||
/*use_legacy_landlock*/ false,
|
||||
)
|
||||
@@ -627,7 +703,7 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() {
|
||||
let allowed_target = allowed.join("note.txt");
|
||||
// These tests bypass the usual legacy-policy bridge, so explicitly keep
|
||||
// the sandbox helper binary and minimal runtime paths readable.
|
||||
let sandbox_helper_dir = PathBuf::from(env!("CARGO_BIN_EXE_codex-linux-sandbox"))
|
||||
let sandbox_helper_dir = codex_linux_sandbox_exe()
|
||||
.parent()
|
||||
.expect("sandbox helper should have a parent")
|
||||
.to_path_buf();
|
||||
@@ -685,6 +761,7 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() {
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
&AbsolutePathBuf::current_dir().expect("cwd should exist"),
|
||||
LONG_TIMEOUT_MS,
|
||||
/*use_legacy_landlock*/ false,
|
||||
)
|
||||
@@ -736,6 +813,7 @@ async fn sandbox_blocks_root_read_carveouts_under_bwrap() {
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
&AbsolutePathBuf::current_dir().expect("cwd should exist"),
|
||||
LONG_TIMEOUT_MS,
|
||||
/*use_legacy_landlock*/ false,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user