mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Support readable files below sandbox denies
This commit is contained in:
@@ -16,16 +16,19 @@ use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::Metadata;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::permissions::ReadDenyMatcher;
|
||||
use codex_protocol::permissions::is_protected_metadata_name;
|
||||
use codex_protocol::protocol::FileSystemAccessMode;
|
||||
use codex_protocol::protocol::FileSystemPath;
|
||||
@@ -54,6 +57,7 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
|
||||
];
|
||||
|
||||
const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192;
|
||||
const MAX_BWRAP_PRESERVED_FILES: usize = 128;
|
||||
|
||||
/// Options that control how bubblewrap is invoked.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -436,6 +440,31 @@ fn create_filesystem_args(
|
||||
);
|
||||
unreadable_roots.sort();
|
||||
unreadable_roots.dedup();
|
||||
let unreadable_paths: HashSet<PathBuf> = unreadable_roots.iter().cloned().collect();
|
||||
// Exact deny roots may intentionally have a narrower readable-file
|
||||
// carveback. Keep only glob denies in this matcher so unexpanded glob
|
||||
// matches cannot be reopened without treating the parent deny itself as
|
||||
// overriding that explicit carveback.
|
||||
let mut deny_glob_policy = file_system_sandbox_policy.clone();
|
||||
deny_glob_policy.entries.retain(|entry| {
|
||||
entry.access == FileSystemAccessMode::Deny
|
||||
&& matches!(entry.path, FileSystemPath::GlobPattern { .. })
|
||||
});
|
||||
let read_deny_glob_matcher = ReadDenyMatcher::new(&deny_glob_policy, cwd);
|
||||
|
||||
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()),
|
||||
);
|
||||
}
|
||||
|
||||
let args = if file_system_sandbox_policy.has_full_disk_read_access() {
|
||||
// Read-only root, then mount a minimal device tree.
|
||||
@@ -460,20 +489,6 @@ fn create_filesystem_args(
|
||||
"/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.
|
||||
@@ -486,7 +501,7 @@ fn create_filesystem_args(
|
||||
"/dev".to_string(),
|
||||
];
|
||||
} else {
|
||||
for root in readable_roots {
|
||||
for root in &readable_roots {
|
||||
if !root.exists() {
|
||||
continue;
|
||||
}
|
||||
@@ -498,9 +513,9 @@ fn create_filesystem_args(
|
||||
.iter()
|
||||
.any(|writable_root| root.starts_with(writable_root.root.as_path()))
|
||||
{
|
||||
canonical_target_if_symlinked_path(&root).unwrap_or(root)
|
||||
canonical_target_if_symlinked_path(root).unwrap_or_else(|| root.clone())
|
||||
} else {
|
||||
root
|
||||
root.clone()
|
||||
};
|
||||
args.push("--ro-bind".to_string());
|
||||
args.push(path_to_string(&mount_root));
|
||||
@@ -524,7 +539,34 @@ fn create_filesystem_args(
|
||||
allowed_write_paths.push(target);
|
||||
}
|
||||
}
|
||||
let unreadable_paths: HashSet<PathBuf> = unreadable_roots.iter().cloned().collect();
|
||||
let mut reopened_readable_files = readable_roots
|
||||
.into_iter()
|
||||
.filter(|path| {
|
||||
std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file())
|
||||
})
|
||||
.filter(|path| !unreadable_paths.contains(path))
|
||||
.filter(|path| {
|
||||
!read_deny_glob_matcher
|
||||
.as_ref()
|
||||
.is_some_and(|matcher| matcher.is_read_denied(path))
|
||||
})
|
||||
.filter(|path| !file_system_sandbox_policy.can_write_path_with_cwd(path, cwd))
|
||||
.filter(|path| {
|
||||
unreadable_roots
|
||||
.iter()
|
||||
.any(|unreadable_root| path != unreadable_root && path.starts_with(unreadable_root))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if reopened_readable_files.iter().any(|path| {
|
||||
allowed_write_paths
|
||||
.iter()
|
||||
.any(|writable_root| path.starts_with(writable_root))
|
||||
}) {
|
||||
return Err(CodexErr::Fatal(
|
||||
"cannot reopen a sandbox-readable file below a sandbox-writable root".to_string(),
|
||||
));
|
||||
}
|
||||
reopened_readable_files.sort_by_key(|path| path_depth(path));
|
||||
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.
|
||||
@@ -546,7 +588,12 @@ fn create_filesystem_args(
|
||||
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)?;
|
||||
append_unreadable_root_args(
|
||||
&mut bwrap_args,
|
||||
unreadable_root,
|
||||
&allowed_write_paths,
|
||||
&reopened_readable_files,
|
||||
)?;
|
||||
}
|
||||
|
||||
for writable_root in &sorted_writable_roots {
|
||||
@@ -605,9 +652,14 @@ fn create_filesystem_args(
|
||||
nested_unreadable_roots =
|
||||
remap_paths_for_symlink_target(nested_unreadable_roots, root, target);
|
||||
}
|
||||
nested_unreadable_roots.sort_by_key(|path| path_depth(path));
|
||||
nested_unreadable_roots.sort_by_key(|path| std::cmp::Reverse(path_depth(path)));
|
||||
for unreadable_root in nested_unreadable_roots {
|
||||
append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?;
|
||||
append_unreadable_root_args(
|
||||
&mut bwrap_args,
|
||||
&unreadable_root,
|
||||
&allowed_write_paths,
|
||||
&reopened_readable_files,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,9 +673,29 @@ fn create_filesystem_args(
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
rootless_unreadable_roots.sort_by_key(|path| path_depth(path));
|
||||
// Apply narrower masks before their denied ancestors. A directory mask is
|
||||
// remounted read-only, so a later nested file mask could not create its
|
||||
// mount target. The final ancestor mask still subsumes every child that is
|
||||
// not explicitly reopened below.
|
||||
rootless_unreadable_roots.sort_by_key(|path| std::cmp::Reverse(path_depth(path)));
|
||||
for unreadable_root in rootless_unreadable_roots {
|
||||
append_unreadable_root_args(&mut bwrap_args, &unreadable_root, &allowed_write_paths)?;
|
||||
append_unreadable_root_args(
|
||||
&mut bwrap_args,
|
||||
&unreadable_root,
|
||||
&allowed_write_paths,
|
||||
&reopened_readable_files,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Unreadable directory masks are layered after the initial read mounts.
|
||||
// Preserve each narrower read-only file by descriptor so its source stays
|
||||
// available after the denied parent has hidden the original path.
|
||||
for readable_file in reopened_readable_files {
|
||||
let file = open_reopened_readable_file(&readable_file)?;
|
||||
let file_fd = preserve_file(&mut bwrap_args, file)?;
|
||||
bwrap_args.args.push("--ro-bind-data".to_string());
|
||||
bwrap_args.args.push(file_fd);
|
||||
bwrap_args.args.push(path_to_string(&readable_file));
|
||||
}
|
||||
|
||||
Ok(bwrap_args)
|
||||
@@ -1013,6 +1085,11 @@ fn append_mount_target_parent_dir_args(args: &mut Vec<String>, mount_target: &Pa
|
||||
.collect();
|
||||
mount_target_dirs.reverse();
|
||||
for mount_target_dir in mount_target_dirs {
|
||||
// These directories only make a narrower mount target reachable below
|
||||
// a denied parent. Keep them traversable but not listable; the real
|
||||
// readable/writable descendant is mounted over the final target later.
|
||||
args.push("--perms".to_string());
|
||||
args.push("111".to_string());
|
||||
args.push("--dir".to_string());
|
||||
args.push(path_to_string(&mount_target_dir));
|
||||
}
|
||||
@@ -1072,16 +1149,53 @@ fn append_read_only_subpath_args(
|
||||
}
|
||||
|
||||
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();
|
||||
let null_fd = empty_file_fd(bwrap_args)?;
|
||||
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 empty_file_fd(bwrap_args: &mut BwrapArgs) -> Result<String> {
|
||||
if bwrap_args.preserved_files.is_empty() {
|
||||
bwrap_args.preserved_files.push(File::open("/dev/null")?);
|
||||
}
|
||||
Ok(bwrap_args.preserved_files[0].as_raw_fd().to_string())
|
||||
}
|
||||
|
||||
fn empty_file_copy_fd(bwrap_args: &mut BwrapArgs) -> Result<String> {
|
||||
// `--file` consumes its input descriptor, while `--ro-bind-data` can reuse
|
||||
// one descriptor. Reserve the shared descriptor first so later masks never
|
||||
// reuse a descriptor that an earlier `--file` operation closed.
|
||||
empty_file_fd(bwrap_args)?;
|
||||
preserve_file(bwrap_args, File::open("/dev/null")?)
|
||||
}
|
||||
|
||||
fn preserve_file(bwrap_args: &mut BwrapArgs, file: File) -> Result<String> {
|
||||
if bwrap_args.preserved_files.len() >= MAX_BWRAP_PRESERVED_FILES {
|
||||
return Err(CodexErr::Fatal(format!(
|
||||
"cannot construct bubblewrap filesystem with more than {MAX_BWRAP_PRESERVED_FILES} preserved files"
|
||||
)));
|
||||
}
|
||||
let fd = file.as_raw_fd().to_string();
|
||||
bwrap_args.preserved_files.push(file);
|
||||
Ok(fd)
|
||||
}
|
||||
|
||||
fn open_reopened_readable_file(path: &Path) -> Result<File> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(path)?;
|
||||
if !file.metadata()?.file_type().is_file() {
|
||||
return Err(CodexErr::Fatal(format!(
|
||||
"cannot reopen sandbox readable path because it is not a regular file: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -1140,6 +1254,7 @@ fn append_unreadable_root_args(
|
||||
bwrap_args: &mut BwrapArgs,
|
||||
unreadable_root: &Path,
|
||||
allowed_write_paths: &[PathBuf],
|
||||
reopened_readable_files: &[PathBuf],
|
||||
) -> Result<()> {
|
||||
if let Some(symlink) =
|
||||
first_writable_symlink_component_in_path(unreadable_root, allowed_write_paths)
|
||||
@@ -1167,43 +1282,63 @@ fn append_unreadable_root_args(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
append_existing_unreadable_path_args(bwrap_args, unreadable_root, allowed_write_paths)
|
||||
append_existing_unreadable_path_args(
|
||||
bwrap_args,
|
||||
unreadable_root,
|
||||
allowed_write_paths,
|
||||
reopened_readable_files,
|
||||
)
|
||||
}
|
||||
|
||||
fn append_existing_unreadable_path_args(
|
||||
bwrap_args: &mut BwrapArgs,
|
||||
unreadable_root: &Path,
|
||||
allowed_write_paths: &[PathBuf],
|
||||
reopened_readable_files: &[PathBuf],
|
||||
) -> Result<()> {
|
||||
if unreadable_root.is_dir() {
|
||||
let mut writable_descendants: Vec<&Path> = allowed_write_paths
|
||||
let mut reopened_descendants: Vec<&Path> = allowed_write_paths
|
||||
.iter()
|
||||
.chain(reopened_readable_files)
|
||||
.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
|
||||
// re-opened readable or writable descendants while still hiding the
|
||||
// denied directory contents. Plain denied directories with no child
|
||||
// mounts stay at `000`.
|
||||
bwrap_args.args.push(if writable_descendants.is_empty() {
|
||||
bwrap_args.args.push(if reopened_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 {
|
||||
// Recreate any narrower readable or 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.
|
||||
reopened_descendants.sort_by_key(|path| path_depth(path));
|
||||
for reopened_descendant in reopened_descendants {
|
||||
append_mount_target_parent_dir_args(
|
||||
&mut bwrap_args.args,
|
||||
writable_descendant,
|
||||
reopened_descendant,
|
||||
unreadable_root,
|
||||
);
|
||||
}
|
||||
// File bind destinations must exist before the denied parent becomes
|
||||
// read-only. Install empty placeholders now; the real readable files
|
||||
// are rebound after every deny mask has been layered.
|
||||
for readable_file in reopened_readable_files
|
||||
.iter()
|
||||
.filter(|path| path.as_path() != unreadable_root && path.starts_with(unreadable_root))
|
||||
{
|
||||
let null_fd = empty_file_copy_fd(bwrap_args)?;
|
||||
bwrap_args.args.push("--file".to_string());
|
||||
bwrap_args.args.push(null_fd);
|
||||
bwrap_args.args.push(path_to_string(readable_file));
|
||||
}
|
||||
bwrap_args.args.push("--remount-ro".to_string());
|
||||
bwrap_args.args.push(path_to_string(unreadable_root));
|
||||
return Ok(());
|
||||
@@ -2425,6 +2560,196 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_policy_reenables_readable_files_after_unreadable_parent() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let blocked = temp_dir.path().join("blocked");
|
||||
let active_dir = blocked.join("active");
|
||||
let active_file = active_dir.join("ca-bundle.pem");
|
||||
let glob_denied_file = active_dir.join("glob-denied.pem");
|
||||
let stale_file = blocked.join("stale-bundle.pem");
|
||||
std::fs::create_dir_all(&active_dir).expect("create blocked/active");
|
||||
std::fs::write(&active_file, "active").expect("create active bundle");
|
||||
std::fs::write(&glob_denied_file, "denied").expect("create glob-denied bundle");
|
||||
std::fs::write(&stale_file, "stale").expect("create stale bundle");
|
||||
let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute blocked");
|
||||
let active_file =
|
||||
AbsolutePathBuf::from_absolute_path(&active_file).expect("absolute active 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::Deny,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: active_file.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: AbsolutePathBuf::from_absolute_path(&glob_denied_file)
|
||||
.expect("absolute glob-denied file"),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
// Root-prefix globs are intentionally not expanded by the
|
||||
// host scan, so direct matcher enforcement must keep this
|
||||
// explicit read grant from being reopened.
|
||||
pattern: "/**/glob-denied.pem".to_string(),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
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 active_dir_str = path_to_string(&active_dir);
|
||||
let active_file_str = path_to_string(active_file.as_path());
|
||||
let glob_denied_file_str = path_to_string(&glob_denied_file);
|
||||
let stale_file_str = path_to_string(&stale_file);
|
||||
assert!(
|
||||
args.preserved_files.len() >= 2,
|
||||
"expected distinct descriptors for the placeholder and active bundle"
|
||||
);
|
||||
let active_fd = args
|
||||
.preserved_files
|
||||
.last()
|
||||
.expect("active bundle descriptor")
|
||||
.as_raw_fd()
|
||||
.to_string();
|
||||
let blocked_mask_index = args
|
||||
.args
|
||||
.windows(4)
|
||||
.position(|window| window == ["--perms", "111", "--tmpfs", blocked_str.as_str()])
|
||||
.expect("blocked should be masked with traversal enabled");
|
||||
let active_dir_index = args
|
||||
.args
|
||||
.windows(2)
|
||||
.position(|window| window == ["--dir", active_dir_str.as_str()])
|
||||
.expect("active bundle parent should be recreated");
|
||||
let active_dir_perms_index = args
|
||||
.args
|
||||
.windows(4)
|
||||
.position(|window| window == ["--perms", "111", "--dir", active_dir_str.as_str()])
|
||||
.expect("active bundle parent should be execute-only");
|
||||
let active_placeholder_index = args
|
||||
.args
|
||||
.windows(3)
|
||||
.position(|window| {
|
||||
window.first().is_some_and(|arg| arg == "--file")
|
||||
&& window.get(2).is_some_and(|arg| arg == &active_file_str)
|
||||
})
|
||||
.expect("active bundle should have a placeholder before remounting");
|
||||
let placeholder_fd = args
|
||||
.args
|
||||
.get(active_placeholder_index + 1)
|
||||
.expect("active placeholder descriptor");
|
||||
assert_ne!(placeholder_fd, &active_fd);
|
||||
let blocked_remount_index = args
|
||||
.args
|
||||
.windows(2)
|
||||
.position(|window| window == ["--remount-ro", blocked_str.as_str()])
|
||||
.expect("blocked parent should be remounted read-only");
|
||||
let active_bind_index = args
|
||||
.args
|
||||
.windows(3)
|
||||
.position(|window| {
|
||||
window
|
||||
== [
|
||||
"--ro-bind-data",
|
||||
active_fd.as_str(),
|
||||
active_file_str.as_str(),
|
||||
]
|
||||
})
|
||||
.expect("active bundle should be rebound read-only from a preserved descriptor");
|
||||
|
||||
assert!(
|
||||
blocked_mask_index < active_dir_perms_index
|
||||
&& active_dir_perms_index < active_dir_index
|
||||
&& active_dir_index < active_placeholder_index
|
||||
&& active_placeholder_index < blocked_remount_index
|
||||
&& blocked_remount_index < active_bind_index,
|
||||
"expected active bundle to be reopened after masking its parent: {:#?}",
|
||||
args.args
|
||||
);
|
||||
assert!(
|
||||
!args.args.windows(3).any(|window| {
|
||||
window
|
||||
== [
|
||||
"--ro-bind-data",
|
||||
active_fd.as_str(),
|
||||
stale_file_str.as_str(),
|
||||
]
|
||||
}),
|
||||
"stale sibling must remain hidden: {:#?}",
|
||||
args.args
|
||||
);
|
||||
assert!(
|
||||
!args.args.windows(3).any(|window| {
|
||||
window
|
||||
== [
|
||||
"--ro-bind-data",
|
||||
active_fd.as_str(),
|
||||
glob_denied_file_str.as_str(),
|
||||
]
|
||||
}),
|
||||
"glob-denied file must not be reopened: {:#?}",
|
||||
args.args
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_policy_rejects_readable_carveback_below_writable_root() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let blocked = temp_dir.path().join("proxy");
|
||||
let active_file = blocked.join("ca-bundle.pem");
|
||||
std::fs::create_dir_all(&blocked).expect("create proxy dir");
|
||||
std::fs::write(&active_file, "active").expect("create active bundle");
|
||||
let writable_root =
|
||||
AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute writable root");
|
||||
let blocked = AbsolutePathBuf::from_absolute_path(&blocked).expect("absolute proxy dir");
|
||||
let active_file =
|
||||
AbsolutePathBuf::from_absolute_path(&active_file).expect("absolute active bundle");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: writable_root,
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: blocked },
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: active_file },
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
|
||||
let err =
|
||||
create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH)
|
||||
.expect_err("writable ancestry must fail closed");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("cannot reopen a sandbox-readable file below a sandbox-writable root"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_policy_reenables_nested_writable_roots_after_unreadable_parent() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -527,6 +527,11 @@ fn run_bwrap_in_child_with_synthetic_mount_cleanup(bwrap_args: crate::bwrap::Bwr
|
||||
exec_bwrap(args, preserved_files);
|
||||
}
|
||||
|
||||
// The forked child owns the descriptors bubblewrap needs. Close the
|
||||
// parent's copies before allowing untrusted code to start so a fallback
|
||||
// that preserves the host /proc cannot expose their backing files through
|
||||
// /proc/<parent-pid>/fd.
|
||||
drop(preserved_files);
|
||||
close_child_exec_start_read(exec_start_pipe[0]);
|
||||
let protected_create_monitor = ProtectedCreateMonitor::start(&protected_create_targets);
|
||||
let signal_forwarders = install_bwrap_signal_forwarders(pid);
|
||||
|
||||
@@ -907,6 +907,91 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() {
|
||||
assert_eq!(output.stdout.text.trim(), "allowed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_reenables_only_selected_readable_file_under_unreadable_parent() {
|
||||
if should_skip_bwrap_tests().await {
|
||||
eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmpdir = tempfile::tempdir().expect("tempdir");
|
||||
let proxy_dir = tmpdir.path().join("proxy");
|
||||
std::fs::create_dir_all(&proxy_dir).expect("create proxy dir");
|
||||
let active_bundle = proxy_dir.join("ca-bundle-active.pem");
|
||||
let glob_denied_bundle = proxy_dir.join("ca-bundle-glob-denied.pem");
|
||||
let stale_bundle = proxy_dir.join("ca-bundle-stale.pem");
|
||||
let ca_key = proxy_dir.join("ca.key");
|
||||
std::fs::write(&active_bundle, "active").expect("write active bundle");
|
||||
std::fs::write(&glob_denied_bundle, "glob denied").expect("write glob-denied bundle");
|
||||
std::fs::write(&stale_bundle, "stale").expect("write stale bundle");
|
||||
std::fs::write(&ca_key, "private").expect("write CA key");
|
||||
|
||||
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: AbsolutePathBuf::try_from(proxy_dir.as_path()).expect("absolute proxy dir"),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: AbsolutePathBuf::try_from(active_bundle.as_path())
|
||||
.expect("absolute active bundle"),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: AbsolutePathBuf::try_from(glob_denied_bundle.as_path())
|
||||
.expect("absolute glob-denied bundle"),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: format!("{}/**/ca-bundle-glob-denied.pem", tmpdir.path().display()),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
let permission_profile = PermissionProfile::from_runtime_permissions(
|
||||
&file_system_sandbox_policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
);
|
||||
let script = format!(
|
||||
r#"set -e
|
||||
test "$(cat '{active_bundle}')" = active
|
||||
! cat '{stale_bundle}' >/dev/null 2>&1
|
||||
! cat '{glob_denied_bundle}' >/dev/null 2>&1
|
||||
! cat '{ca_key}' >/dev/null 2>&1
|
||||
"#,
|
||||
active_bundle = active_bundle.display(),
|
||||
stale_bundle = stale_bundle.display(),
|
||||
glob_denied_bundle = glob_denied_bundle.display(),
|
||||
ca_key = ca_key.display(),
|
||||
);
|
||||
let output = run_cmd_result_with_permission_profile(
|
||||
&["bash", "-lc", &script],
|
||||
permission_profile,
|
||||
LONG_TIMEOUT_MS,
|
||||
/*use_legacy_landlock*/ false,
|
||||
)
|
||||
.await
|
||||
.expect("nested readable carveback should execute under bubblewrap");
|
||||
|
||||
assert_eq!(
|
||||
output.exit_code, 0,
|
||||
"stdout:\n{}\nstderr:\n{}",
|
||||
output.stdout.text, output.stderr.text
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_blocks_root_read_carveouts_under_bwrap() {
|
||||
if should_skip_bwrap_tests().await {
|
||||
|
||||
@@ -389,6 +389,33 @@ fn build_seatbelt_access_policy(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_seatbelt_unreadable_root_policy(
|
||||
unreadable_roots: &[AbsolutePathBuf],
|
||||
) -> (String, Vec<(String, PathBuf)>) {
|
||||
let mut policy_components = Vec::new();
|
||||
let mut params = Vec::new();
|
||||
for (index, unreadable_root) in unreadable_roots.iter().enumerate() {
|
||||
let unreadable_root = normalize_path_for_sandbox(unreadable_root.as_path())
|
||||
.unwrap_or_else(|| unreadable_root.clone());
|
||||
let param = format!("UNREADABLE_ROOT_{index}");
|
||||
params.push((param.clone(), unreadable_root.into_path_buf()));
|
||||
policy_components.push(format!("(literal (param \"{param}\"))"));
|
||||
policy_components.push(format!("(subpath (param \"{param}\"))"));
|
||||
}
|
||||
|
||||
if policy_components.is_empty() {
|
||||
(String::new(), params)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"(deny file-read* file-write*\n{}\n)",
|
||||
policy_components.join(" ")
|
||||
),
|
||||
params,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn seatbelt_protected_metadata_name_regex(root: &AbsolutePathBuf, name: &str) -> String {
|
||||
let mut root = root.to_string_lossy().to_string();
|
||||
while root.len() > 1 && root.ends_with('/') {
|
||||
@@ -612,6 +639,8 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
|
||||
|
||||
let unreadable_roots =
|
||||
file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd);
|
||||
let readable_roots = file_system_sandbox_policy.get_readable_roots_with_cwd(sandbox_policy_cwd);
|
||||
let writable_roots = file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
|
||||
let (file_write_policy, file_write_dir_params) =
|
||||
if file_system_sandbox_policy.has_full_disk_write_access() {
|
||||
if unreadable_roots.is_empty() {
|
||||
@@ -635,9 +664,9 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
|
||||
build_seatbelt_access_policy(
|
||||
"file-write*",
|
||||
"WRITABLE_ROOT",
|
||||
file_system_sandbox_policy
|
||||
.get_writable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.into_iter()
|
||||
writable_roots
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|root| SeatbeltAccessRoot {
|
||||
protected_metadata_names: protected_metadata_names_for_writable_root(
|
||||
file_system_sandbox_policy,
|
||||
@@ -664,7 +693,7 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
|
||||
"READABLE_ROOT",
|
||||
vec![SeatbeltAccessRoot {
|
||||
root: root_absolute_path(),
|
||||
excluded_subpaths: unreadable_roots,
|
||||
excluded_subpaths: unreadable_roots.clone(),
|
||||
protected_metadata_names: Vec::new(),
|
||||
}],
|
||||
);
|
||||
@@ -677,9 +706,9 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
|
||||
let (policy, params) = build_seatbelt_access_policy(
|
||||
"file-read*",
|
||||
"READABLE_ROOT",
|
||||
file_system_sandbox_policy
|
||||
.get_readable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.into_iter()
|
||||
readable_roots
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|root| SeatbeltAccessRoot {
|
||||
excluded_subpaths: unreadable_roots
|
||||
.iter()
|
||||
@@ -701,29 +730,108 @@ pub fn create_seatbelt_command_args(args: CreateSeatbeltCommandArgsParams<'_>) -
|
||||
}
|
||||
};
|
||||
|
||||
// Platform-default grants such as `/private/tmp` are appended separately
|
||||
// from explicit filesystem roots. Reassert exact denies after those broad
|
||||
// grants, then reopen only narrower explicit roots. This preserves the
|
||||
// deny-parent/read-file shape used for per-command managed CA bundles.
|
||||
let (unreadable_root_policy, unreadable_root_dir_params) =
|
||||
build_seatbelt_unreadable_root_policy(&unreadable_roots);
|
||||
let (readable_carveback_policy, readable_carveback_dir_params) = build_seatbelt_access_policy(
|
||||
"file-read*",
|
||||
"READABLE_CARVEBACK",
|
||||
readable_roots
|
||||
.iter()
|
||||
.filter(|root| {
|
||||
unreadable_roots.iter().any(|unreadable_root| {
|
||||
root.as_path() != unreadable_root.as_path()
|
||||
&& root.as_path().starts_with(unreadable_root.as_path())
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.map(|root| SeatbeltAccessRoot {
|
||||
excluded_subpaths: unreadable_roots
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
path.as_path() != root.as_path()
|
||||
&& path.as_path().starts_with(root.as_path())
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
protected_metadata_names: Vec::new(),
|
||||
root,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let (writable_carveback_policy, writable_carveback_dir_params) = build_seatbelt_access_policy(
|
||||
"file-write*",
|
||||
"WRITABLE_CARVEBACK",
|
||||
writable_roots
|
||||
.iter()
|
||||
.filter(|root| {
|
||||
unreadable_roots.iter().any(|unreadable_root| {
|
||||
root.root.as_path() != unreadable_root.as_path()
|
||||
&& root.root.as_path().starts_with(unreadable_root.as_path())
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.map(|root| {
|
||||
let protected_metadata_names = protected_metadata_names_for_writable_root(
|
||||
file_system_sandbox_policy,
|
||||
&root,
|
||||
sandbox_policy_cwd,
|
||||
);
|
||||
let mut excluded_subpaths = root.read_only_subpaths;
|
||||
excluded_subpaths.extend(
|
||||
unreadable_roots
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
path.as_path() != root.root.as_path()
|
||||
&& path.as_path().starts_with(root.root.as_path())
|
||||
})
|
||||
.cloned(),
|
||||
);
|
||||
excluded_subpaths.sort();
|
||||
excluded_subpaths.dedup();
|
||||
SeatbeltAccessRoot {
|
||||
root: root.root,
|
||||
excluded_subpaths,
|
||||
protected_metadata_names,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let proxy = proxy_policy_inputs(network, extra_allow_unix_sockets);
|
||||
let network_policy =
|
||||
dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy);
|
||||
|
||||
let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults();
|
||||
let deny_read_policy =
|
||||
let deny_read_glob_policy =
|
||||
build_seatbelt_unreadable_glob_policy(file_system_sandbox_policy, sandbox_policy_cwd);
|
||||
let mut policy_sections = vec![
|
||||
MACOS_SEATBELT_BASE_POLICY.to_string(),
|
||||
file_read_policy,
|
||||
file_write_policy,
|
||||
deny_read_policy,
|
||||
network_policy,
|
||||
];
|
||||
if include_platform_defaults {
|
||||
policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string());
|
||||
}
|
||||
policy_sections.push(unreadable_root_policy);
|
||||
policy_sections.push(readable_carveback_policy);
|
||||
policy_sections.push(writable_carveback_policy);
|
||||
// Glob denies remain last so they cannot be overridden by an explicit
|
||||
// carveback or a broad platform-default grant.
|
||||
policy_sections.push(deny_read_glob_policy);
|
||||
|
||||
let full_policy = policy_sections.join("\n");
|
||||
|
||||
let dir_params = [
|
||||
file_read_dir_params,
|
||||
file_write_dir_params,
|
||||
unreadable_root_dir_params,
|
||||
readable_carveback_dir_params,
|
||||
writable_carveback_dir_params,
|
||||
unix_socket_dir_params(&proxy),
|
||||
]
|
||||
.concat();
|
||||
|
||||
@@ -307,6 +307,97 @@ fn explicit_unreadable_paths_are_excluded_from_readable_roots() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_readable_file_is_reopened_below_unreadable_parent() {
|
||||
let temp_dir = TempDir::new_in("/private/tmp").expect("temp dir");
|
||||
let proxy_dir = temp_dir.path().join("proxy");
|
||||
fs::create_dir_all(&proxy_dir).expect("create proxy dir");
|
||||
let active_bundle = proxy_dir.join("ca-bundle-active.pem");
|
||||
let stale_bundle = proxy_dir.join("ca-bundle-stale.pem");
|
||||
let ca_key = proxy_dir.join("ca.key");
|
||||
fs::write(&active_bundle, "active").expect("write active bundle");
|
||||
fs::write(&stale_bundle, "stale").expect("write stale bundle");
|
||||
fs::write(&ca_key, "private").expect("write CA key");
|
||||
let proxy_dir = AbsolutePathBuf::from_absolute_path(
|
||||
proxy_dir.canonicalize().expect("canonicalize proxy dir"),
|
||||
)
|
||||
.expect("absolute proxy dir");
|
||||
let active_bundle = AbsolutePathBuf::from_absolute_path(
|
||||
active_bundle
|
||||
.canonicalize()
|
||||
.expect("canonicalize active bundle"),
|
||||
)
|
||||
.expect("absolute active bundle");
|
||||
let stale_bundle = AbsolutePathBuf::from_absolute_path(
|
||||
stale_bundle
|
||||
.canonicalize()
|
||||
.expect("canonicalize stale bundle"),
|
||||
)
|
||||
.expect("absolute stale bundle");
|
||||
let ca_key =
|
||||
AbsolutePathBuf::from_absolute_path(ca_key.canonicalize().expect("canonicalize CA key"))
|
||||
.expect("absolute CA key");
|
||||
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Minimal,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: proxy_dir },
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: active_bundle.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
r#"set -e
|
||||
test "$(cat "$1")" = active
|
||||
! cat "$2" >/dev/null 2>&1
|
||||
! cat "$3" >/dev/null 2>&1
|
||||
"#
|
||||
.to_string(),
|
||||
"bash".to_string(),
|
||||
active_bundle.to_string_lossy().into_owned(),
|
||||
stale_bundle.to_string_lossy().into_owned(),
|
||||
ca_key.to_string_lossy().into_owned(),
|
||||
];
|
||||
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
|
||||
command,
|
||||
file_system_sandbox_policy: &file_system_policy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Enabled,
|
||||
sandbox_policy_cwd: temp_dir.path(),
|
||||
enforce_managed_network: false,
|
||||
network: None,
|
||||
extra_allow_unix_sockets: &[],
|
||||
});
|
||||
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&args)
|
||||
.current_dir(temp_dir.path())
|
||||
.output()
|
||||
.expect("execute seatbelt command");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success()
|
||||
&& stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted")
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"expected only the active bundle to be readable; status={:?}; stdout={}; stderr={stderr}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_globstar_slash_matches_zero_or_more_directories() {
|
||||
let regex = seatbelt_regex_for_unreadable_glob("/tmp/repo/**/*.env");
|
||||
|
||||
Reference in New Issue
Block a user