mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Expand Linux sandbox deny globs in process (#59711)
## What changed Replace external `rg` scans and the fallback walker with an in-process `ignore` walker for bubblewrap deny-glob expansion. Remove scanner lookup and dependency tracking from sandbox integrity checks. Include hidden and ignored files, respect scan depth limits, and fail closed on traversal errors. Anchor patterns to their static search roots and preserve literal leading `#`, leading `!`, trailing spaces, and unclosed character classes. Follow explicit symlink roots while skipping child symlinks, and mask canonical targets of matched paths. ## Testing Extend unit and sandbox integration coverage for glob syntax, depth limits, traversal failures, symlink handling, commands launched from `/`, and operation without executing `rg`. GitOrigin-RevId: 1767d7f37acb149457642bf24ee5971a28696438
This commit is contained in:
Generated
+1
-1
@@ -4012,7 +4012,7 @@ dependencies = [
|
||||
"codex-uds",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"globset",
|
||||
"ignore",
|
||||
"landlock",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
|
||||
@@ -80,13 +80,7 @@ fn dependency_inventory_uses_launcher_location_and_command_path() -> io::Result<
|
||||
fs::create_dir(&cwd)?;
|
||||
fs::create_dir(&tools)?;
|
||||
fs::create_dir(tools.join("codex-resources"))?;
|
||||
for path in [
|
||||
tools.join("bwrap"),
|
||||
tools.join("rg"),
|
||||
cwd.join("rg"),
|
||||
tools.join("codex"),
|
||||
bundled.clone(),
|
||||
] {
|
||||
for path in [tools.join("bwrap"), tools.join("codex"), bundled.clone()] {
|
||||
fs::write(&path, "fixture")?;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(/*mode*/ 0o755))?;
|
||||
}
|
||||
@@ -95,18 +89,12 @@ fn dependency_inventory_uses_launcher_location_and_command_path() -> io::Result<
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![deny_glob("*.key")]);
|
||||
let mut request = sandbox_request(&cwd, &policy);
|
||||
request.command[0] = launcher.to_string_lossy().into_owned();
|
||||
for (path, scanner) in [
|
||||
("../tools", tools.join("rg")),
|
||||
(":../tools", tools.join("rg")),
|
||||
] {
|
||||
for path in ["../tools", ":../tools"] {
|
||||
request.env.insert("PATH".to_owned(), path.to_owned());
|
||||
let dependencies = backend::critical_dependencies(&request, &policy, &cwd)?
|
||||
.into_iter()
|
||||
.filter(|(name, _)| {
|
||||
matches!(
|
||||
*name,
|
||||
"bundled_bwrap_candidate" | "system_bwrap_candidate" | "glob_scanner"
|
||||
)
|
||||
matches!(*name, "bundled_bwrap_candidate" | "system_bwrap_candidate")
|
||||
})
|
||||
.map(|(name, path)| path.and_then(fs::canonicalize).map(|path| (name, path)))
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
@@ -118,7 +106,6 @@ fn dependency_inventory_uses_launcher_location_and_command_path() -> io::Result<
|
||||
"system_bwrap_candidate",
|
||||
tools.join("bwrap").into_path_buf()
|
||||
),
|
||||
("glob_scanner", scanner.into_path_buf()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,20 +55,6 @@ pub(super) fn critical_dependencies(
|
||||
// The launcher probes this executable even if it later uses its bundled copy.
|
||||
dependencies.push(("system_bwrap_candidate", Ok(bwrap)));
|
||||
}
|
||||
if !policy
|
||||
.get_unreadable_globs_with_cwd(cwd.as_path())
|
||||
.is_empty()
|
||||
&& let Some(path) = &path
|
||||
&& let Some(scanner) = codex_sandboxing::find_executable_in_search_paths(
|
||||
codex_linux_sandbox::GLOB_SCAN_PROGRAM,
|
||||
std::env::split_paths(path),
|
||||
command_cwd.as_path(),
|
||||
policy,
|
||||
cwd.as_path(),
|
||||
)
|
||||
{
|
||||
dependencies.push(("glob_scanner", Ok(scanner)));
|
||||
}
|
||||
Ok(dependencies)
|
||||
}
|
||||
|
||||
@@ -107,13 +93,10 @@ pub(super) fn prepare_policy(
|
||||
});
|
||||
let patterns = policy.get_unreadable_globs_with_cwd(cwd.as_path());
|
||||
if !patterns.is_empty() {
|
||||
let paths = codex_linux_sandbox::expand_unreadable_globs_in_environment(
|
||||
let paths = codex_linux_sandbox::expand_unreadable_globs(
|
||||
&patterns,
|
||||
cwd.as_path(),
|
||||
&policy,
|
||||
policy.glob_scan_max_depth,
|
||||
&request.env,
|
||||
command_cwd.as_path(),
|
||||
)
|
||||
.map_err(io::Error::other)?;
|
||||
policy = policy.with_expanded_deny_globs(paths);
|
||||
|
||||
@@ -25,7 +25,7 @@ codex-protocol = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-uds = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
landlock = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
rustix = { workspace = true }
|
||||
|
||||
@@ -15,19 +15,16 @@
|
||||
//! - bubblewrap used to construct the filesystem view before exec.
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::Metadata;
|
||||
use std::io;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::linux_run_main::synthetic_mount_registry_root;
|
||||
use codex_protocol::error::CodexErr;
|
||||
@@ -39,12 +36,9 @@ use codex_protocol::protocol::FileSystemPath;
|
||||
use codex_protocol::protocol::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::FileSystemSpecialPath;
|
||||
use codex_protocol::protocol::WritableRoot;
|
||||
use codex_sandboxing::find_executable_in_search_paths;
|
||||
use codex_sandboxing::find_pre_sandbox_executable_in_path;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use globset::GlobBuilder;
|
||||
use globset::GlobSet;
|
||||
use globset::GlobSetBuilder;
|
||||
use ignore::WalkState;
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
|
||||
/// Linux "platform defaults" that keep common system binaries and dynamic
|
||||
/// libraries readable when a split filesystem policy requests `:minimal`.
|
||||
@@ -62,9 +56,6 @@ const LINUX_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
|
||||
"/run/current-system/sw",
|
||||
];
|
||||
|
||||
/// External scanner used by deny-glob expansion and its dependency inventory.
|
||||
pub const GLOB_SCAN_PROGRAM: &str = "rg";
|
||||
|
||||
const MAX_UNREADABLE_GLOB_MATCHES: usize = 8192;
|
||||
pub(crate) const WSL_INTEROP_DIR: &str = "/run/WSL";
|
||||
pub(crate) const WSLG_DISTRO_ROOT: &str = "/mnt/wslg/distro";
|
||||
@@ -83,7 +74,7 @@ pub(crate) struct BwrapOptions {
|
||||
/// Whether the host exposes WSLg's duplicate distro root, which must be hidden
|
||||
/// when constructing a restricted filesystem view.
|
||||
pub mask_wslg_distro: bool,
|
||||
/// Optional maximum depth for expanding unreadable glob patterns with ripgrep.
|
||||
/// Optional maximum depth for expanding unreadable glob patterns.
|
||||
///
|
||||
/// Keep this uncapped by default so existing nested deny-read matches are
|
||||
/// masked before the sandboxed command starts.
|
||||
@@ -480,18 +471,10 @@ fn create_filesystem_args(
|
||||
// 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(
|
||||
expand_unreadable_globs(
|
||||
&resolved.unreadable_globs,
|
||||
cwd,
|
||||
|| {
|
||||
find_pre_sandbox_executable_in_path(
|
||||
GLOB_SCAN_PROGRAM,
|
||||
file_system_sandbox_policy,
|
||||
cwd,
|
||||
)
|
||||
},
|
||||
options.glob_scan_max_depth.or(resolved.glob_scan_max_depth),
|
||||
|_| {},
|
||||
)?
|
||||
.into_iter()
|
||||
.map(AbsolutePathBuf::into_path_buf),
|
||||
@@ -846,54 +829,23 @@ fn append_daemon_socket_masks(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expand deny globs with the launcher's scanner in an explicit command environment.
|
||||
/// The supplied environment replaces inheritance, including when it is empty.
|
||||
pub fn expand_unreadable_globs_in_environment(
|
||||
/// Snapshot deny-glob matches and their canonical targets for bubblewrap masks.
|
||||
/// Glob overrides are relative to each pattern's static search root.
|
||||
pub fn expand_unreadable_globs(
|
||||
patterns: &[String],
|
||||
cwd: &Path,
|
||||
file_system_policy: &FileSystemSandboxPolicy,
|
||||
max_depth: Option<usize>,
|
||||
env: &HashMap<String, String>,
|
||||
command_cwd: &Path,
|
||||
) -> Result<Vec<AbsolutePathBuf>> {
|
||||
expand_unreadable_globs_with_ripgrep(
|
||||
patterns,
|
||||
cwd,
|
||||
|| {
|
||||
find_executable_in_search_paths(
|
||||
GLOB_SCAN_PROGRAM,
|
||||
std::env::split_paths(env.get("PATH")?),
|
||||
command_cwd,
|
||||
file_system_policy,
|
||||
cwd,
|
||||
)
|
||||
},
|
||||
max_depth,
|
||||
|command| {
|
||||
command.env_clear().envs(env).current_dir(command_cwd);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn expand_unreadable_globs_with_ripgrep(
|
||||
patterns: &[String],
|
||||
cwd: &Path,
|
||||
resolve_scanner: impl FnOnce() -> Option<PathBuf>,
|
||||
max_depth: Option<usize>,
|
||||
configure_command: impl Fn(&mut Command),
|
||||
) -> Result<Vec<AbsolutePathBuf>> {
|
||||
if patterns.is_empty() || max_depth == Some(0) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let rg_path = resolve_scanner();
|
||||
|
||||
// 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.
|
||||
// lets one walk handle all patterns under the same root.
|
||||
let mut patterns_by_search_root: BTreeMap<AbsolutePathBuf, Vec<String>> = BTreeMap::new();
|
||||
for pattern in patterns {
|
||||
let Some((search_root, glob)) = split_pattern_for_ripgrep(pattern, cwd) else {
|
||||
let Some((search_root, glob)) = split_glob_pattern(pattern, cwd) else {
|
||||
return Err(CodexErr::Fatal(format!(
|
||||
"unreadable glob `{pattern}` cannot be safely expanded; use a pattern with a non-root directory prefix"
|
||||
)));
|
||||
@@ -927,16 +879,7 @@ fn expand_unreadable_globs_with_ripgrep(
|
||||
// bypassing an unreadable glob match.
|
||||
let mut expanded_paths = BTreeSet::new();
|
||||
for (search_root, globs) in patterns_by_search_root {
|
||||
let paths = match &rg_path {
|
||||
Some(rg_path) => ripgrep_files(
|
||||
rg_path,
|
||||
search_root.as_path(),
|
||||
&globs,
|
||||
max_depth,
|
||||
&configure_command,
|
||||
)?,
|
||||
None => glob_files(search_root.as_path(), &globs, max_depth)?,
|
||||
};
|
||||
let paths = glob_files(search_root.as_path(), &globs, max_depth)?;
|
||||
for path in paths {
|
||||
if let Some(target) = canonical_target_if_symlinked_path(path.as_path()) {
|
||||
expanded_paths.insert(AbsolutePathBuf::from_absolute_path_checked(target)?);
|
||||
@@ -954,10 +897,10 @@ fn expand_unreadable_globs_with_ripgrep(
|
||||
Ok(expanded_paths.into_iter().collect())
|
||||
}
|
||||
|
||||
fn split_pattern_for_ripgrep(pattern: &str, cwd: &Path) -> Option<(AbsolutePathBuf, String)> {
|
||||
fn split_glob_pattern(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
|
||||
// the file 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();
|
||||
@@ -979,175 +922,93 @@ fn split_pattern_for_ripgrep(pattern: &str, cwd: &Path) -> Option<(AbsolutePathB
|
||||
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..]);
|
||||
let glob = pattern[search_root_end + 1..].to_string();
|
||||
(!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(
|
||||
rg_path: &Path,
|
||||
search_root: &Path,
|
||||
globs: &[String],
|
||||
max_depth: Option<usize>,
|
||||
configure_command: &impl Fn(&mut Command),
|
||||
) -> Result<Vec<AbsolutePathBuf>> {
|
||||
let mut command = Command::new(rg_path);
|
||||
configure_command(&mut command);
|
||||
// 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.
|
||||
command
|
||||
.arg("--no-config")
|
||||
.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 the selected `rg` is unavailable at execution time, 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();
|
||||
let mut builder = OverrideBuilder::new(search_root);
|
||||
builder.allow_unclosed_class(/*yes*/ true);
|
||||
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);
|
||||
// Anchor to the scan root; this also keeps leading `#` and `!` literal.
|
||||
let mut glob = format!("/{glob}");
|
||||
if glob.ends_with(' ') && !glob.ends_with("\\ ") {
|
||||
glob.insert(glob.len() - 1, '\\');
|
||||
}
|
||||
builder.add(&glob).map_err(|err| {
|
||||
CodexErr::Fatal(format!(
|
||||
"unreadable glob pattern is invalid for {}: {err}",
|
||||
search_root.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
let glob_set = builder.build().map_err(|err| {
|
||||
let overrides = 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,
|
||||
let paths = Mutex::new(Ok(Vec::new()));
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map_or(1, usize::from)
|
||||
.min(12);
|
||||
// Include hidden and ignored files, and follow explicit symlink roots.
|
||||
// Skip child symlinks and only return regular files.
|
||||
let mut walker = ignore::WalkBuilder::new(search_root);
|
||||
walker
|
||||
.standard_filters(/*yes*/ false)
|
||||
.follow_links(/*yes*/ false)
|
||||
.max_depth(max_depth)
|
||||
.overrides(overrides)
|
||||
.threads(threads);
|
||||
let visit = |entry: std::result::Result<ignore::DirEntry, ignore::Error>| {
|
||||
let path = match entry {
|
||||
Ok(entry) => {
|
||||
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
|
||||
return WalkState::Continue;
|
||||
}
|
||||
AbsolutePathBuf::from_absolute_path_checked(entry.into_path())
|
||||
.map_err(CodexErr::from)
|
||||
}
|
||||
Err(err) => Err(CodexErr::Fatal(format!(
|
||||
"failed to enumerate unreadable glob paths under {}: {err}",
|
||||
search_root.display()
|
||||
))),
|
||||
};
|
||||
collect_glob_files(search_root, &path, glob_set, remaining_depth, paths)?;
|
||||
let Ok(mut paths) = paths.lock() else {
|
||||
return WalkState::Quit;
|
||||
};
|
||||
match path {
|
||||
Ok(path) => {
|
||||
if let Ok(paths) = &mut *paths {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
*paths = Err(err);
|
||||
return WalkState::Quit;
|
||||
}
|
||||
}
|
||||
WalkState::Continue
|
||||
};
|
||||
// Like rg, use the serial walk on one CPU: depth-boundary errors differ.
|
||||
if threads == 1 {
|
||||
for entry in walker.build() {
|
||||
if visit(entry) == WalkState::Quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
walker.build_parallel().run(|| Box::new(&visit));
|
||||
}
|
||||
Ok(())
|
||||
paths
|
||||
.into_inner()
|
||||
.map_err(|_| CodexErr::Fatal("unreadable glob scan result mutex poisoned".to_string()))?
|
||||
}
|
||||
|
||||
fn path_to_string(path: &Path) -> String {
|
||||
|
||||
@@ -10,6 +10,8 @@ use codex_protocol::protocol::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::FileSystemSpecialPath;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
@@ -134,10 +136,6 @@ fn full_disk_write_proxy_only_keeps_full_filesystem_but_unshares_network() {
|
||||
|
||||
#[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");
|
||||
@@ -1393,19 +1391,17 @@ fn assert_bind_data_uses_distinct_preserved_fds(args: &BwrapArgs) {
|
||||
|
||||
#[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");
|
||||
let literal_env = temp_dir.path().join("[literal.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");
|
||||
std::fs::write(&literal_env, "secret").expect("write literal class env");
|
||||
let policy =
|
||||
default_policy_with_unreadable_glob(format!("{}/**/*.env", temp_dir.path().display()));
|
||||
|
||||
@@ -1429,15 +1425,35 @@ fn unreadable_globs_expand_existing_matches_with_configured_depth() {
|
||||
"max depth should keep deeper matches out of bwrap args: {:#?}",
|
||||
args.args
|
||||
);
|
||||
for (glob, expected) in [
|
||||
("*.env", vec![root_env.clone(), literal_env.clone()]),
|
||||
(
|
||||
"**/*.env",
|
||||
vec![root_env, literal_env.clone(), nested_env.clone()],
|
||||
),
|
||||
("app*/.env", vec![nested_env]),
|
||||
("[*.env", vec![literal_env.clone()]),
|
||||
(r"\[*.env", vec![literal_env]),
|
||||
] {
|
||||
assert_eq!(
|
||||
expand_unreadable_globs(
|
||||
&[format!("{}/{glob}", temp_dir.path().display())],
|
||||
temp_dir.path(),
|
||||
/*max_depth*/ Some(2),
|
||||
)
|
||||
.expect("expand scanner glob"),
|
||||
expected
|
||||
.into_iter()
|
||||
.map(|path| AbsolutePathBuf::from_absolute_path_checked(path)
|
||||
.expect("absolute path"))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
@@ -1465,12 +1481,10 @@ fn unreadable_glob_root_metadata_errors_fail_before_scanning() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let root = temp_dir.path().join("loop");
|
||||
std::os::unix::fs::symlink(&root, &root).expect("create symlink loop");
|
||||
let error = expand_unreadable_globs_with_ripgrep(
|
||||
let error = expand_unreadable_globs(
|
||||
&[format!("{}/**/.env", root.display())],
|
||||
temp_dir.path(),
|
||||
|| Some(temp_dir.path().join("scanner-must-not-run")),
|
||||
/*max_depth*/ None,
|
||||
|_| panic!("scanner must not run after a root metadata error"),
|
||||
)
|
||||
.expect_err("metadata errors must reject deny-glob expansion")
|
||||
.to_string();
|
||||
@@ -1479,6 +1493,36 @@ fn unreadable_glob_root_metadata_errors_fail_before_scanning() {
|
||||
assert!(error.contains(&io::Error::from_raw_os_error(libc::ELOOP).to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_glob_traversal_errors_fail_closed() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
eprintln!("skipping permission fixture as root");
|
||||
return;
|
||||
}
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let denied = temp.path().join("denied");
|
||||
fs::create_dir(&denied).expect("create denied directory");
|
||||
fs::set_permissions(&denied, fs::Permissions::from_mode(0o0)).expect("deny traversal");
|
||||
let [result, bounded] = [None, Some(1)].map(|max_depth| {
|
||||
expand_unreadable_globs(
|
||||
&[format!("{}/**/*.env", temp.path().display())],
|
||||
temp.path(),
|
||||
max_depth,
|
||||
)
|
||||
});
|
||||
fs::set_permissions(&denied, fs::Permissions::from_mode(0o700)).expect("restore traversal");
|
||||
let error = result.expect_err("incomplete scans must fail").to_string();
|
||||
assert!(error.contains(&denied.display().to_string()));
|
||||
assert!(error.contains(&io::Error::from_raw_os_error(libc::EACCES).to_string()));
|
||||
assert_eq!(
|
||||
bounded.is_ok(),
|
||||
std::thread::available_parallelism().map_or(1, usize::from) == 1,
|
||||
"rg's serial walk suppresses traversal errors beyond the depth limit",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_globs_skip_absent_and_non_directory_roots() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
@@ -1487,12 +1531,10 @@ fn unreadable_globs_skip_absent_and_non_directory_roots() {
|
||||
fs::write(&file, "ordinary file").expect("write file");
|
||||
|
||||
for root in [missing, file.join("child"), file] {
|
||||
let paths = expand_unreadable_globs_with_ripgrep(
|
||||
let paths = expand_unreadable_globs(
|
||||
&[format!("{}/**/.env", root.display())],
|
||||
temp_dir.path(),
|
||||
|| None,
|
||||
/*max_depth*/ None,
|
||||
|_| {},
|
||||
)
|
||||
.expect("roots without possible matches should be skipped");
|
||||
|
||||
@@ -1501,7 +1543,7 @@ fn unreadable_globs_skip_absent_and_non_directory_roots() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_globs_follow_symlink_roots_without_ripgrep() {
|
||||
fn unreadable_globs_follow_symlink_roots() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let root = temp_dir.path().join("data");
|
||||
fs::create_dir(&root).expect("create directory");
|
||||
@@ -1510,12 +1552,14 @@ fn unreadable_globs_follow_symlink_roots_without_ripgrep() {
|
||||
let link = temp_dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&root, &link).expect("create directory symlink");
|
||||
|
||||
let paths = expand_unreadable_globs_with_ripgrep(
|
||||
&[format!("{}/**/.env", link.display())],
|
||||
std::os::unix::fs::symlink(&root, root.join("cycle")).expect("child directory link");
|
||||
let outside = temp_dir.path().join("outside.txt");
|
||||
fs::write(&outside, "public").expect("outside file");
|
||||
std::os::unix::fs::symlink(outside, root.join("alias.env")).expect("matching child symlink");
|
||||
let paths = expand_unreadable_globs(
|
||||
&[format!("{}/**/*.env", link.display())],
|
||||
temp_dir.path(),
|
||||
|| None,
|
||||
/*max_depth*/ None,
|
||||
|_| panic!("fallback scanner must not launch ripgrep"),
|
||||
)
|
||||
.expect("expand deny glob");
|
||||
let expected = [secret, link.join(".env")]
|
||||
@@ -1544,22 +1588,6 @@ fn root_prefix_unreadable_globs_fail_closed_on_linux() {
|
||||
);
|
||||
}
|
||||
|
||||
#[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(GLOB_SCAN_PROGRAM)
|
||||
.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`
|
||||
|
||||
@@ -199,29 +199,7 @@ fn explicit_wslg_alias_paths_are_rejected(source: &str) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_ancestor_mask_in_no_rg_fallback() {
|
||||
// Isolate PATH in a child test process instead of mutating the test runner's
|
||||
// environment. This exercises the supported missing-rg branch end to end.
|
||||
const CHILD: &str = "CODEX_WSLG_GLOB_TEST_CHILD";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let output = Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args([
|
||||
"--exact",
|
||||
"bwrap::wslg_tests::glob_ancestor_mask_in_no_rg_fallback",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("PATH", "")
|
||||
.output()
|
||||
.expect("run isolated test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
fn wslg_masks_remain_when_glob_skips_directory_symlinks() {
|
||||
let temp = tempfile::tempdir().expect("temp directory");
|
||||
// '/' is an ancestor on every test host; no WSL mount is needed. Only
|
||||
// inspect arguments: never launch this synthetic filesystem policy.
|
||||
@@ -247,12 +225,14 @@ fn glob_ancestor_mask_in_no_rg_fallback() {
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.expect("expanded ancestor mask should suffice")
|
||||
.expect("skipped glob links still need the WSLg mask")
|
||||
.args;
|
||||
assert!(
|
||||
args.windows(4)
|
||||
.any(|args| args == ["--tmpfs", "/", "--remount-ro", "/"])
|
||||
);
|
||||
assert!(!args.iter().any(|arg| arg == WSLG_DISTRO_ROOT));
|
||||
assert!(args.windows(4).any(|args| args
|
||||
== [
|
||||
"--tmpfs",
|
||||
WSLG_DISTRO_ROOT,
|
||||
"--remount-ro",
|
||||
WSLG_DISTRO_ROOT
|
||||
]));
|
||||
assert!(args.windows(2).any(|args| args == ["--tmpfs", "/proc"]));
|
||||
}
|
||||
|
||||
@@ -31,9 +31,7 @@ mod wslg;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use bundled_bwrap::find_bundled_bwrap_for_exe;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use bwrap::GLOB_SCAN_PROGRAM;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use bwrap::expand_unreadable_globs_in_environment;
|
||||
pub use bwrap::expand_unreadable_globs;
|
||||
|
||||
/// Exit status returned when bundled bubblewrap fails digest verification.
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -19,14 +19,16 @@ use pretty_assertions::assert_eq;
|
||||
enum DeniedFileRules {
|
||||
ExactPaths,
|
||||
Globs,
|
||||
GlobsFromRoot,
|
||||
GlobsWithProtectedRg,
|
||||
GlobsWithNativeFallback,
|
||||
GlobsWithoutRg,
|
||||
}
|
||||
|
||||
#[test_case::test_case(DeniedFileRules::ExactPaths; "exact_paths")]
|
||||
#[test_case::test_case(DeniedFileRules::Globs; "globs")]
|
||||
#[test_case::test_case(DeniedFileRules::GlobsFromRoot; "globs_from_root")]
|
||||
#[test_case::test_case(DeniedFileRules::GlobsWithProtectedRg; "globs_with_protected_rg")]
|
||||
#[test_case::test_case(DeniedFileRules::GlobsWithNativeFallback; "globs_with_native_fallback")]
|
||||
#[test_case::test_case(DeniedFileRules::GlobsWithoutRg; "globs_without_rg")]
|
||||
#[tokio::test]
|
||||
async fn sandbox_starts_with_multiple_denied_files(rules: DeniedFileRules) {
|
||||
if should_skip_bwrap_tests().await {
|
||||
@@ -36,7 +38,7 @@ async fn sandbox_starts_with_multiple_denied_files(rules: DeniedFileRules) {
|
||||
|
||||
let use_controlled_rg_path = matches!(
|
||||
rules,
|
||||
DeniedFileRules::GlobsWithProtectedRg | DeniedFileRules::GlobsWithNativeFallback
|
||||
DeniedFileRules::GlobsWithProtectedRg | DeniedFileRules::GlobsWithoutRg
|
||||
);
|
||||
let temp = if use_controlled_rg_path {
|
||||
// Bazel can mount the default temp directory noexec.
|
||||
@@ -54,10 +56,22 @@ async fn sandbox_starts_with_multiple_denied_files(rules: DeniedFileRules) {
|
||||
(workspace.join("one.key"), "dummy first key"),
|
||||
(workspace.join("nested/two.key"), "dummy second key"),
|
||||
(workspace.join(".env.local"), "dummy environment"),
|
||||
(workspace.join("nested/.env"), "dummy nested environment"),
|
||||
(workspace.join("#report-1.csv"), "dummy report"),
|
||||
(workspace.join("!report.csv"), "dummy literal bang report"),
|
||||
(workspace.join("notes.txt "), "dummy spaced filename"),
|
||||
(workspace.join("root-only.cfg"), "dummy root configuration"),
|
||||
];
|
||||
for (path, contents) in &denied_files {
|
||||
std::fs::write(path, contents).expect("write denied dummy file");
|
||||
}
|
||||
for path in ["notes.txt", "nested/allowed.cfg"] {
|
||||
std::fs::write(workspace.join(path), "public").expect("write allowed file");
|
||||
}
|
||||
std::os::unix::fs::symlink("AGENTS.md", workspace.join("relative.key"))
|
||||
.expect("relative file symlink");
|
||||
std::os::unix::fs::symlink(workspace.join("AGENTS.md"), workspace.join("absolute.key"))
|
||||
.expect("absolute file symlink");
|
||||
|
||||
let sandbox_helper = codex_linux_sandbox_exe();
|
||||
let helper_dir = AbsolutePathBuf::try_from(sandbox_helper.parent().expect("helper parent"))
|
||||
@@ -142,9 +156,18 @@ printf '%s\000' "$CODEX_TEST_DENIED_PATH_0" "$CODEX_TEST_DENIED_PATH_1" "$CODEX_
|
||||
}));
|
||||
}
|
||||
DeniedFileRules::Globs
|
||||
| DeniedFileRules::GlobsFromRoot
|
||||
| DeniedFileRules::GlobsWithProtectedRg
|
||||
| DeniedFileRules::GlobsWithNativeFallback => {
|
||||
for suffix in ["**/*.key", "**/.env.local"] {
|
||||
| DeniedFileRules::GlobsWithoutRg => {
|
||||
for suffix in [
|
||||
"**/*.key",
|
||||
"**/.env.local",
|
||||
"nest*/.env",
|
||||
"#report-*.csv",
|
||||
"!*.csv",
|
||||
"*.txt ",
|
||||
"*.cfg",
|
||||
] {
|
||||
entries.push(FileSystemSandboxEntry::new(
|
||||
FileSystemPath::GlobPattern {
|
||||
pattern: format!("{}/{suffix}", workspace.display()),
|
||||
@@ -158,24 +181,14 @@ printf '%s\000' "$CODEX_TEST_DENIED_PATH_0" "$CODEX_TEST_DENIED_PATH_1" "$CODEX_
|
||||
&FileSystemSandboxPolicy::restricted(entries),
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
);
|
||||
if matches!(rules, DeniedFileRules::Globs)
|
||||
&& codex_sandboxing::find_pre_sandbox_executable_in_path(
|
||||
"rg",
|
||||
&permission_profile.file_system_sandbox_policy(),
|
||||
workspace.as_path(),
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
eprintln!("skipping ripgrep config test: no protected rg is available");
|
||||
return;
|
||||
}
|
||||
let output = run_cmd_result_with_permission_profile_for_cwd(
|
||||
&[
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
r#"set -eu
|
||||
cd "$1"
|
||||
/bin/cat AGENTS.md
|
||||
for blocked in one.key nested/two.key .env.local; do
|
||||
for blocked in one.key nested/two.key .env.local nested/.env '#report-1.csv' '!report.csv' 'notes.txt ' root-only.cfg; do
|
||||
if (: < "$blocked") 2>/dev/null; then
|
||||
printf 'read unexpectedly allowed: %s\n' "$blocked" >&2
|
||||
exit 10
|
||||
@@ -185,11 +198,23 @@ for blocked in one.key nested/two.key .env.local; do
|
||||
exit 11
|
||||
fi
|
||||
done
|
||||
for allowed in notes.txt nested/allowed.cfg; do
|
||||
test "$(/bin/cat "$allowed")" = public
|
||||
done
|
||||
for alias in relative.key absolute.key; do
|
||||
test "$(/bin/cat "$alias")" = 'project instructions'
|
||||
done
|
||||
printf 'allowed\n' > allowed.txt
|
||||
/bin/cat allowed.txt
|
||||
"#,
|
||||
"sh",
|
||||
workspace.to_str().expect("UTF-8 workspace"),
|
||||
],
|
||||
workspace.clone(),
|
||||
if matches!(rules, DeniedFileRules::GlobsFromRoot) {
|
||||
AbsolutePathBuf::try_from("/").expect("absolute root")
|
||||
} else {
|
||||
workspace.clone()
|
||||
},
|
||||
permission_profile,
|
||||
env,
|
||||
LONG_TIMEOUT_MS,
|
||||
@@ -221,9 +246,6 @@ printf 'allowed\n' > allowed.txt
|
||||
!fixture.path().join("rejected-rg-ran").exists(),
|
||||
"writable rg ran before confinement"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.path().join("protected-rg-ran").exists(),
|
||||
matches!(rules, DeniedFileRules::GlobsWithProtectedRg),
|
||||
);
|
||||
assert!(!fixture.path().join("protected-rg-ran").exists());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user