feat(permissions): add glob deny-read policy support (#15979)

## Summary
- adds first-class filesystem policy entries for deny-read glob patterns
- parses config such as :project_roots { "**/*.env" = "none" } into
pattern entries
- enforces deny-read patterns in direct read/list helpers
- fails closed for sandbox execution until platform backends enforce
glob patterns in #18096
- preserves split filesystem policy in turn context only when it cannot
be reconstructed from legacy sandbox policy

## Stack
1. This PR - glob deny-read policy/config/direct-tool support
2. #18096 - macOS and Linux sandbox enforcement
3. #17740 - managed deny-read requirements

## Verification
- just fmt
- cargo check -p codex-core -p codex-sandboxing --tests

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
viyatb-oai
2026-04-16 10:31:51 -07:00
committed by GitHub
co-authored by Codex
parent ff9744fd66
commit 6862b9c745
23 changed files with 1081 additions and 79 deletions
+425 -59
View File
@@ -6,6 +6,8 @@ use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
use globset::GlobBuilder;
use globset::GlobMatcher;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -154,14 +156,101 @@ struct FileSystemSemanticSignature {
readable_roots: Vec<AbsolutePathBuf>,
writable_roots: Vec<WritableRoot>,
unreadable_roots: Vec<AbsolutePathBuf>,
unreadable_globs: Vec<String>,
}
/// Runtime matcher for read-deny entries in a filesystem sandbox policy.
pub struct ReadDenyMatcher {
denied_candidates: Vec<Vec<PathBuf>>,
deny_read_matchers: Vec<GlobMatcher>,
invalid_pattern: bool,
}
impl ReadDenyMatcher {
/// Builds a matcher from exact deny-read roots and deny-read glob entries.
///
/// Returns `None` when the policy has no deny-read restrictions, so callers
/// can skip read-deny checks without allocating matcher state. The `cwd`
/// resolves cwd-relative policy paths and special paths before matching.
pub fn new(file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path) -> Option<Self> {
if !file_system_sandbox_policy.has_denied_read_restrictions() {
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)
.into_iter()
.map(|path| normalized_and_canonical_candidates(path.as_path()))
.collect();
// Pattern entries stay as policy-level globs. They are matched at read
// time here instead of being snapshotted to startup filesystem state.
let mut invalid_pattern = false;
let deny_read_matchers = file_system_sandbox_policy
.get_unreadable_globs_with_cwd(cwd)
.into_iter()
.filter_map(|pattern| match build_glob_matcher(&pattern) {
Some(matcher) => Some(matcher),
None => {
invalid_pattern = true;
None
}
})
.collect();
Some(Self {
denied_candidates,
deny_read_matchers,
invalid_pattern,
})
}
/// Returns whether `path` is denied by the policy used to build this matcher.
pub fn is_read_denied(&self, path: &Path) -> bool {
if self.invalid_pattern {
// Direct tool reads fail closed on malformed deny patterns. Silent
// allow would turn a config typo into a policy bypass.
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.
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)
})
})
}) {
return true;
}
self.deny_read_matchers.iter().any(|matcher| {
path_candidates
.iter()
.any(|candidate| matcher.is_match(candidate))
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum FileSystemPath {
Path { path: AbsolutePathBuf },
Special { value: FileSystemSpecialPath },
Path {
path: AbsolutePathBuf,
},
/// A git-style glob pattern. Pattern entries currently support
/// FileSystemAccessMode::None only.
GlobPattern {
pattern: String,
},
Special {
value: FileSystemSpecialPath,
},
}
impl Default for FileSystemSandboxPolicy {
@@ -179,62 +268,6 @@ impl Default for FileSystemSandboxPolicy {
}
impl FileSystemSandboxPolicy {
fn has_root_access(&self, predicate: impl Fn(FileSystemAccessMode) -> bool) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Special { value }
if matches!(value, FileSystemSpecialPath::Root) && predicate(entry.access)
)
})
}
fn has_explicit_deny_entries(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::None)
}
/// Returns true when a restricted policy contains any entry that really
/// reduces a broader `:root = write` grant.
///
/// Raw entry presence is not enough here: an equally specific `write`
/// entry for the same target wins under the normal precedence rules, so a
/// shadowed `read` entry must not downgrade the policy out of full-disk
/// write mode.
fn has_write_narrowing_entries(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
if entry.access.can_write() {
return false;
}
match &entry.path {
FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry),
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None,
FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => {
false
}
_ => !self.has_same_target_write_override(entry),
},
}
})
}
/// Returns true when a higher-priority `write` entry targets the same
/// location as `entry`, so `entry` cannot narrow effective write access.
fn has_same_target_write_override(&self, entry: &FileSystemSandboxEntry) -> bool {
self.entries.iter().any(|candidate| {
candidate.access.can_write()
&& candidate.access > entry.access
&& file_system_paths_share_target(&candidate.path, &entry.path)
})
}
pub fn unrestricted() -> Self {
Self {
kind: FileSystemSandboxKind::Unrestricted,
@@ -256,6 +289,86 @@ impl FileSystemSandboxPolicy {
}
}
fn has_root_access(&self, predicate: impl Fn(FileSystemAccessMode) -> bool) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
matches!(
&entry.path,
FileSystemPath::Special { value }
if matches!(value, FileSystemSpecialPath::Root) && predicate(entry.access)
)
})
}
pub fn has_denied_read_restrictions(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self
.entries
.iter()
.any(|entry| entry.access == FileSystemAccessMode::None)
}
pub fn from_legacy_sandbox_policy_preserving_deny_entries(
sandbox_policy: &SandboxPolicy,
cwd: &Path,
existing: &Self,
) -> Self {
let mut rebuilt = Self::from_legacy_sandbox_policy(sandbox_policy, cwd);
if !matches!(rebuilt.kind, FileSystemSandboxKind::Restricted) {
return rebuilt;
}
for deny_entry in existing
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
{
if !rebuilt.entries.iter().any(|entry| entry == deny_entry) {
rebuilt.entries.push(deny_entry.clone());
}
}
rebuilt
}
/// Returns true when a restricted policy contains any entry that really
/// reduces a broader `:root = write` grant.
///
/// Raw entry presence is not enough here: an equally specific `write`
/// entry for the same target wins under the normal precedence rules, so a
/// shadowed `read` entry must not downgrade the policy out of full-disk
/// write mode.
fn has_write_narrowing_entries(&self) -> bool {
matches!(self.kind, FileSystemSandboxKind::Restricted)
&& self.entries.iter().any(|entry| {
if entry.access.can_write() {
return false;
}
match &entry.path {
FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry),
FileSystemPath::GlobPattern { .. } => true,
FileSystemPath::Special { value } => match value {
FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None,
FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => {
false
}
_ => !self.has_same_target_write_override(entry),
},
}
})
}
/// Returns true when a higher-priority `write` entry targets the same
/// location as `entry`, so `entry` cannot narrow effective write access.
fn has_same_target_write_override(&self, entry: &FileSystemSandboxEntry) -> bool {
self.entries.iter().any(|candidate| {
candidate.access.can_write()
&& candidate.access > entry.access
&& file_system_paths_share_target(&candidate.path, &entry.path)
})
}
/// Converts a legacy sandbox policy into an equivalent filesystem policy
/// for the provided cwd.
///
@@ -276,6 +389,7 @@ impl FileSystemSandboxPolicy {
FileSystemPath::Path { path } => !legacy_writable_roots
.iter()
.any(|root| root.is_path_writable(path.as_path())),
FileSystemPath::GlobPattern { .. } => true,
FileSystemPath::Special { .. } => true,
}
});
@@ -312,7 +426,7 @@ impl FileSystemSandboxPolicy {
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true,
FileSystemSandboxKind::Restricted => {
self.has_root_access(FileSystemAccessMode::can_read)
&& !self.has_explicit_deny_entries()
&& !self.has_denied_read_restrictions()
}
}
}
@@ -585,6 +699,29 @@ impl FileSystemSandboxPolicy {
)
}
/// Returns unreadable glob patterns resolved against the provided cwd.
pub fn get_unreadable_globs_with_cwd(&self, cwd: &Path) -> Vec<String> {
if !matches!(self.kind, FileSystemSandboxKind::Restricted) {
return Vec::new();
}
let mut patterns = self
.entries
.iter()
.filter(|entry| entry.access == FileSystemAccessMode::None)
.filter_map(|entry| match &entry.path {
FileSystemPath::GlobPattern { pattern } => {
Some(AbsolutePathBuf::resolve_path_against_base(pattern, cwd))
}
FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => None,
})
.map(|pattern| pattern.to_string_lossy().into_owned())
.collect::<Vec<_>>();
patterns.sort();
patterns.dedup();
patterns
}
pub fn to_legacy_sandbox_policy(
&self,
network_policy: NetworkSandboxPolicy,
@@ -620,6 +757,7 @@ impl FileSystemSandboxPolicy {
for entry in &self.entries {
match &entry.path {
FileSystemPath::GlobPattern { .. } => {}
FileSystemPath::Path { path } => {
if entry.access.can_write() {
if cwd_absolute.as_ref().is_some_and(|cwd| cwd == path) {
@@ -770,6 +908,7 @@ impl FileSystemSandboxPolicy {
readable_roots: self.get_readable_roots_with_cwd(cwd),
writable_roots: self.get_writable_roots_with_cwd(cwd),
unreadable_roots: self.get_unreadable_roots_with_cwd(cwd),
unreadable_globs: self.get_unreadable_globs_with_cwd(cwd),
}
}
}
@@ -905,6 +1044,7 @@ fn resolve_file_system_path(
) -> Option<AbsolutePathBuf> {
match path {
FileSystemPath::Path { path } => Some(path.clone()),
FileSystemPath::GlobPattern { .. } => None,
FileSystemPath::Special { value } => resolve_file_system_special_path(value, cwd),
}
}
@@ -947,6 +1087,11 @@ fn file_system_paths_share_target(left: &FileSystemPath, right: &FileSystemPath)
| (FileSystemPath::Special { value }, FileSystemPath::Path { path }) => {
special_path_matches_absolute_path(value, path)
}
(
FileSystemPath::GlobPattern { pattern: left },
FileSystemPath::GlobPattern { pattern: right },
) => left == right,
(FileSystemPath::GlobPattern { .. }, _) | (_, FileSystemPath::GlobPattern { .. }) => false,
}
}
@@ -1021,6 +1166,44 @@ fn absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
.unwrap_or_else(|err| panic!("cwd root must be an absolute path: {err}"))
}
fn normalized_and_canonical_candidates(path: &Path) -> Vec<PathBuf> {
// Compare the lexical absolute form plus the canonical target when it
// exists. Missing paths still need the lexical candidate so future-created
// denied paths remain blocked by direct tool checks.
let mut candidates = Vec::new();
if let Ok(normalized) = AbsolutePathBuf::from_absolute_path(path) {
push_unique(&mut candidates, normalized.to_path_buf());
} else {
push_unique(&mut candidates, path.to_path_buf());
}
if let Ok(canonical) = path.canonicalize()
&& let Ok(canonical_absolute) = AbsolutePathBuf::from_absolute_path(canonical)
{
push_unique(&mut candidates, canonical_absolute.to_path_buf());
}
candidates
}
fn push_unique(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
if !candidates.iter().any(|existing| existing == &candidate) {
candidates.push(candidate);
}
}
fn build_glob_matcher(pattern: &str) -> Option<GlobMatcher> {
// Keep `*` and `?` within a single path component and preserve an unclosed
// `[` as a literal so matcher behavior stays aligned with config parsing.
GlobBuilder::new(pattern)
.literal_separator(true)
.allow_unclosed_class(true)
.build()
.ok()
.map(|glob| glob.compile_matcher())
}
fn resolve_file_system_special_path(
value: &FileSystemSpecialPath,
cwd: Option<&AbsolutePathBuf>,
@@ -2061,4 +2244,187 @@ mod tests {
assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write);
}
#[test]
fn legacy_bridge_preserves_explicit_deny_entries() {
let denied = AbsolutePathBuf::try_from("/tmp/private").expect("absolute path");
let existing = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: denied.clone(),
},
access: FileSystemAccessMode::None,
}]);
let rebuilt = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
&SandboxPolicy::new_workspace_write_policy(),
Path::new("/tmp/workspace"),
&existing,
);
assert!(
rebuilt.entries.iter().any(|entry| {
entry.path
== FileSystemPath::Path {
path: denied.clone(),
}
&& entry.access == FileSystemAccessMode::None
}),
"expected explicit deny entry to be preserved"
);
}
fn deny_policy(path: &Path) -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: AbsolutePathBuf::try_from(path).expect("absolute deny path"),
},
access: FileSystemAccessMode::None,
}])
}
fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry {
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern { pattern },
access: FileSystemAccessMode::None,
}
}
fn default_policy_with_unreadable_glob(pattern: String) -> FileSystemSandboxPolicy {
let mut policy = FileSystemSandboxPolicy::default();
policy.entries.push(unreadable_glob_entry(pattern));
policy
}
fn is_read_denied(
path: &Path,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> bool {
ReadDenyMatcher::new(file_system_sandbox_policy, cwd)
.is_some_and(|matcher| matcher.is_read_denied(path))
}
#[test]
fn exact_path_and_descendants_are_denied() {
let temp = TempDir::new().expect("tempdir");
let denied_dir = temp.path().join("denied");
let nested = denied_dir.join("nested.txt");
std::fs::create_dir_all(&denied_dir).expect("create denied dir");
std::fs::write(&nested, "secret").expect("write secret");
let policy = deny_policy(&denied_dir);
assert!(is_read_denied(&denied_dir, &policy, temp.path()));
assert!(is_read_denied(&nested, &policy, temp.path()));
assert!(!is_read_denied(
&temp.path().join("other.txt"),
&policy,
temp.path()
));
}
#[cfg(unix)]
#[test]
fn canonical_target_matches_denied_symlink_alias() {
let temp = TempDir::new().expect("tempdir");
let real_dir = temp.path().join("real");
let alias_dir = temp.path().join("alias");
std::fs::create_dir_all(&real_dir).expect("create real dir");
symlink_dir(&real_dir, &alias_dir).expect("symlink alias");
let secret = real_dir.join("secret.txt");
std::fs::write(&secret, "secret").expect("write secret");
let alias_secret = alias_dir.join("secret.txt");
let policy = deny_policy(&real_dir);
assert!(is_read_denied(&alias_secret, &policy, temp.path()));
}
#[test]
fn literal_patterns_and_globs_are_denied() {
let temp = TempDir::new().expect("tempdir");
let literal = temp.path().join("private");
let other = temp.path().join("notes.txt");
std::fs::create_dir_all(&literal).expect("create literal dir");
std::fs::write(&other, "notes").expect("write notes");
let mut policy = deny_policy(&literal);
policy.entries.push(unreadable_glob_entry(format!(
"{}/**/*.txt",
temp.path().display()
)));
assert!(is_read_denied(&literal, &policy, temp.path()));
assert!(is_read_denied(&other, &policy, temp.path()));
}
#[test]
fn glob_patterns_deny_matching_paths() {
let temp = TempDir::new().expect("tempdir");
let denied = temp.path().join("private").join("secret1.txt");
std::fs::create_dir_all(denied.parent().expect("parent")).expect("create parent");
std::fs::write(&denied, "secret").expect("write secret");
let policy = default_policy_with_unreadable_glob(format!(
"{}/private/secret?.txt",
temp.path().display()
));
assert!(is_read_denied(&denied, &policy, temp.path()));
}
#[test]
fn glob_patterns_do_not_cross_path_separators() {
let temp = TempDir::new().expect("tempdir");
let matching = temp.path().join("app").join("file42.txt");
let nested = temp.path().join("app").join("nested").join("file42.txt");
let short = temp.path().join("app").join("file4.txt");
let letters = temp.path().join("app").join("fileab.txt");
std::fs::create_dir_all(nested.parent().expect("parent")).expect("create parent");
std::fs::write(&matching, "secret").expect("write matching");
std::fs::write(&nested, "secret").expect("write nested");
std::fs::write(&short, "secret").expect("write short");
std::fs::write(&letters, "secret").expect("write letters");
let policy = default_policy_with_unreadable_glob(format!(
"{}/*/file[0-9]?.txt",
temp.path().display()
));
assert!(is_read_denied(&matching, &policy, temp.path()));
assert!(!is_read_denied(&nested, &policy, temp.path()));
assert!(!is_read_denied(&short, &policy, temp.path()));
assert!(!is_read_denied(&letters, &policy, temp.path()));
}
#[test]
fn globstar_patterns_deny_root_and_nested_matches() {
let temp = TempDir::new().expect("tempdir");
let root_env = temp.path().join(".env");
let nested_env = temp.path().join("app").join(".env");
let other = temp.path().join("app").join("notes.txt");
std::fs::create_dir_all(nested_env.parent().expect("parent")).expect("create parent");
std::fs::write(&root_env, "secret").expect("write root env");
std::fs::write(&nested_env, "secret").expect("write nested env");
std::fs::write(&other, "notes").expect("write notes");
let policy =
default_policy_with_unreadable_glob(format!("{}/**/*.env", temp.path().display()));
assert!(is_read_denied(&root_env, &policy, temp.path()));
assert!(is_read_denied(&nested_env, &policy, temp.path()));
assert!(!is_read_denied(&other, &policy, temp.path()));
}
#[test]
fn unclosed_character_classes_match_literal_brackets() {
let temp = TempDir::new().expect("tempdir");
let bracket_file = temp.path().join("[");
let other = temp.path().join("notes.txt");
std::fs::write(&bracket_file, "secret").expect("write bracket file");
std::fs::write(&other, "notes").expect("write notes");
let policy = default_policy_with_unreadable_glob(format!("{}/[", temp.path().display()));
assert!(is_read_denied(&bracket_file, &policy, temp.path()));
assert!(!is_read_denied(&other, &policy, temp.path()));
}
}
+24
View File
@@ -2844,6 +2844,8 @@ pub struct TurnContextItem {
pub sandbox_policy: SandboxPolicy,
#[serde(skip_serializing_if = "Option::is_none")]
pub network: Option<TurnContextNetworkItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_system_sandbox_policy: Option<FileSystemSandboxPolicy>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
@@ -4974,6 +4976,7 @@ mod tests {
assert_eq!(item.trace_id, None);
assert_eq!(item.network, None);
assert_eq!(item.file_system_sandbox_policy, None);
Ok(())
}
@@ -4991,6 +4994,14 @@ mod tests {
allowed_domains: vec!["api.example.com".to_string()],
denied_domains: vec!["blocked.example.com".to_string()],
}),
file_system_sandbox_policy: Some(FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "/tmp/private/**/*.txt".to_string(),
},
access: FileSystemAccessMode::None,
},
])),
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
@@ -5011,6 +5022,19 @@ mod tests {
"denied_domains": ["blocked.example.com"],
})
);
assert_eq!(
value["file_system_sandbox_policy"],
json!({
"kind": "restricted",
"entries": [{
"path": {
"type": "glob_pattern",
"pattern": "/tmp/private/**/*.txt"
},
"access": "none"
}]
})
);
Ok(())
}