fix: preserve reopened descendants under read denies

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-05-05 23:10:46 -07:00
co-authored by Codex noreply@openai.com
parent a736cb55a2
commit e01fd53004
3 changed files with 274 additions and 21 deletions
+62 -17
View File
@@ -220,7 +220,7 @@ struct FileSystemSemanticSignature {
/// Runtime matcher for read-deny entries in a filesystem sandbox policy.
pub struct ReadDenyMatcher {
denied_candidates: Vec<Vec<PathBuf>>,
exact_candidates: Vec<ResolvedFileSystemEntry>,
deny_read_matchers: Vec<GlobMatcher>,
invalid_pattern: bool,
}
@@ -236,13 +236,25 @@ impl ReadDenyMatcher {
return None;
}
// Exact roots are stored as all meaningful path spellings we can derive
// cheaply. This lets direct tool checks catch both a symlink path and
// its canonical target without changing the policy entries themselves.
let denied_candidates = file_system_sandbox_policy
.get_unreadable_roots_with_cwd(cwd)
// Exact entries are stored with all meaningful path spellings we can
// derive cheaply. That lets direct tool checks preserve the same
// most-specific precedence as the main resolver while still catching
// both a symlink path and its canonical target.
let exact_candidates = file_system_sandbox_policy
.resolved_entries_with_cwd(cwd)
.into_iter()
.map(|path| normalized_and_canonical_candidates(path.as_path()))
.flat_map(|entry| {
normalized_and_canonical_candidates(entry.path.as_path())
.into_iter()
.filter_map(move |path| {
AbsolutePathBuf::from_absolute_path(path).ok().map(|path| {
ResolvedFileSystemEntry {
path,
access: entry.access,
}
})
})
})
.collect();
// Pattern entries stay as policy-level globs. They are matched at read
// time here instead of being snapshotted to startup filesystem state.
@@ -259,7 +271,7 @@ impl ReadDenyMatcher {
})
.collect();
Some(Self {
denied_candidates,
exact_candidates,
deny_read_matchers,
invalid_pattern,
})
@@ -273,17 +285,20 @@ impl ReadDenyMatcher {
return true;
}
// Check exact roots against each candidate spelling before evaluating
// glob matchers. Exact entries are subtree denies; glob entries match
// according to the pattern compiler's path-separator rules.
// Exact entries preserve the same most-specific precedence as the
// policy resolver, across every candidate spelling we know about.
let path_candidates = normalized_and_canonical_candidates(path);
if self.denied_candidates.iter().any(|denied_candidates| {
path_candidates.iter().any(|candidate| {
denied_candidates.iter().any(|denied_candidate| {
candidate == denied_candidate || candidate.starts_with(denied_candidate)
})
if self
.exact_candidates
.iter()
.filter(|entry| {
path_candidates
.iter()
.any(|candidate| candidate.starts_with(entry.path.as_path()))
})
}) {
.max_by_key(|entry| resolved_entry_precedence(entry))
.is_some_and(|entry| !entry.access.can_read())
{
return true;
}
@@ -2858,6 +2873,36 @@ mod tests {
));
}
#[test]
fn exact_path_denies_honor_more_specific_read_allows() {
let temp = TempDir::new().expect("tempdir");
let denied_dir = AbsolutePathBuf::resolve_path_against_base("denied", temp.path());
let allowed_dir = denied_dir.join("allowed");
let allowed_file = allowed_dir.join("visible.txt");
let blocked_file = denied_dir.join("blocked.txt");
std::fs::create_dir_all(allowed_dir.as_path()).expect("create allowed dir");
std::fs::write(allowed_file.as_path(), "visible").expect("write allowed");
std::fs::write(blocked_file.as_path(), "blocked").expect("write blocked");
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: denied_dir },
access: FileSystemAccessMode::None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: allowed_dir },
access: FileSystemAccessMode::Read,
},
]);
assert!(is_read_denied(blocked_file.as_path(), &policy, temp.path()));
assert!(!is_read_denied(
allowed_file.as_path(),
&policy,
temp.path()
));
}
#[cfg(unix)]
#[test]
fn canonical_target_matches_denied_symlink_alias() {