Add executor-context filesystem permission helpers (#43939)

## Why

Filesystem policies for remote execution need to interpret paths, home directories, and temporary directories using the execution host's context.

## What changed

- Add context-based read-denial matching, read-access checks, glob resolution, and `PathUri` workspace-write constructors without consulting local paths or environment variables.
- Share managed read-denial validation through `DenyReadValidator` and use it in core configuration, preserving required-entry checks, concrete grant validation, and constraint diagnostics.
- Reject invalid or unresolvable denial paths when constructing a context-based matcher, and apply `/tmp` denials according to the execution host's path convention.
- Preserve workspace-root symbols and protected metadata entries when materializing URI-based writable roots.

## Testing

Add regression tests for POSIX, Windows, and UNC path matching; conflicting read and write grants; malformed or unresolvable denials; `/tmp` handling; and workspace metadata protection.

GitOrigin-RevId: aa2b174288d1172ab52ff485f58d306236d27652
This commit is contained in:
Sean Huang
2026-09-08 22:52:05 +00:00
committed by copyberry
parent 1032738aa0
commit c53f342fec
7 changed files with 596 additions and 35 deletions
+51 -33
View File
@@ -116,10 +116,12 @@ use codex_protocol::models::SandboxEnforcement;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::DenyReadValidator;
use codex_protocol::permissions::DenyReadViolation;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::permissions::ReadDenyMatcher;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SandboxPolicy;
@@ -4101,49 +4103,65 @@ impl Config {
}) = filesystem_requirements.as_ref()
&& let Some(managed_file_system_policy) = managed_deny_read_policy.as_ref()
{
let managed_deny_matcher =
ReadDenyMatcher::try_new_for_local_paths(managed_file_system_policy, resolved_cwd.as_path())
.map_err(std::io::Error::other)?;
let managed_file_system_policy = Arc::clone(managed_file_system_policy);
let cwd = PathUri::from_abs_path(&resolved_cwd);
let user_home_dir = PathUri::from_host_native_path("~").ok();
let temporary_directories = std::env::var_os("TMPDIR")
.filter(|path| !path.is_empty())
.and_then(|path| AbsolutePathBuf::from_absolute_path(PathBuf::from(path)).ok())
.map(PathUri::from)
.into_iter()
.collect::<Vec<_>>();
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: std::slice::from_ref(&cwd),
user_home_dir: user_home_dir.as_ref(),
temporary_directories: Some(&temporary_directories),
};
let validator = DenyReadValidator::new(managed_file_system_policy, &context)
.map_err(std::io::Error::other)?;
let requirement_source = requirement_source.clone();
constrained_permission_profile
.value
.add_validator(move |permission_profile| {
let file_system_policy = permission_profile.file_system_sandbox_policy();
let missing_required_deny = managed_file_system_policy
.entries
.iter()
.any(|entry| !file_system_policy.entries.contains(entry));
let violating_root = file_system_policy
.entries
.iter()
.filter(|entry| entry.access.can_read())
.find_map(|entry| {
let FileSystemPath::Path { path } = &entry.path else {
return None;
let mut file_system_policy = permission_profile.file_system_sandbox_policy();
// Preserve the native conversion boundary before the shared URI checks.
// Mandatory entries are Deny entries, so their identity stays unchanged.
file_system_policy.entries.retain_mut(|entry| {
if entry.access.can_read()
&& let FileSystemPath::Path { path } = &mut entry.path
{
let Ok(native_path) = path.to_abs_path() else {
return false;
};
let path = path.to_abs_path().ok()?;
managed_deny_matcher
.as_ref()
.is_some_and(|matcher| matcher.is_local_path_read_denied(path.as_path()))
.then_some(path)
});
if missing_required_deny || violating_root.is_some() {
return Err(ConstraintError::InvalidValue {
*path = PathUri::from(native_path);
}
true
});
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: std::slice::from_ref(&cwd),
user_home_dir: user_home_dir.as_ref(),
temporary_directories: Some(&temporary_directories),
};
validator.validate(&file_system_policy, &context).map_err(|violation| {
let candidate = match violation {
DenyReadViolation::MissingRequiredDeny => "missing managed deny".to_string(),
DenyReadViolation::ReadablePath(path) => path.to_abs_path().map_or_else(
|_| path.to_string(),
|path| path.to_string_lossy().into_owned(),
),
};
ConstraintError::InvalidValue {
field_name: "permissions.filesystem",
candidate: violating_root
.map_or_else(|| "missing managed deny".to_string(), |path| {
path.to_string_lossy().into_owned()
}),
candidate,
allowed: "all managed deny_read restrictions".to_string(),
requirement_source: requirement_source.clone(),
});
}
Ok(())
}
})
})
.map_err(std::io::Error::from)?;
}
let permission_profile_state = PermissionProfileState::from_constrained_active_profile(
constrained_permission_profile.value,
active_permission_profile,
+18
View File
@@ -550,6 +550,24 @@ impl PermissionProfile {
}
}
/// Legacy workspace-write settings interpreted for executor-owned paths.
pub fn workspace_write_with_path_uris(
writable_roots: &[PathUri],
network: NetworkSandboxPolicy,
exclude_tmpdir_env_var: bool,
exclude_slash_tmp: bool,
) -> Self {
let file_system = FileSystemSandboxPolicy::workspace_write_with_path_uris(
writable_roots,
exclude_tmpdir_env_var,
exclude_slash_tmp,
);
Self::Managed {
file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
network,
}
}
pub fn materialize_project_roots_with_workspace_roots(
self,
workspace_roots: &[AbsolutePathBuf],
+4
View File
@@ -24,8 +24,12 @@ use crate::protocol::NetworkAccess;
use crate::protocol::SandboxPolicy;
use crate::protocol::WritableRoot;
mod deny_read_validator;
mod target;
mod windows_glob;
pub use deny_read_validator::DenyReadValidator;
pub use deny_read_validator::DenyReadViolation;
pub use windows_glob::WindowsDenyReadGlobScan;
pub use windows_glob::windows_deny_read_glob_scan;
@@ -0,0 +1,102 @@
//! Core's mandatory-denial validation, shared without resolving paths on the controller.
//! Checks raw concrete grants; policy application and sandbox enforcement remain caller-owned.
use super::FileSystemAccessMode;
use super::FileSystemPath;
use super::FileSystemSandboxEntry;
use super::FileSystemSandboxKind;
use super::FileSystemSandboxPolicy;
use super::FileSystemSandboxPolicyContext;
use super::FileSystemSpecialPath;
use super::InvalidDenyReadGlobBehavior;
use super::PreparedReadDenyMatcher;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
/// Prepared mandatory rules reused when a caller replaces its selected profile.
pub struct DenyReadValidator {
required_entries: Vec<FileSystemSandboxEntry>,
matcher: Option<PreparedReadDenyMatcher>,
}
/// Details needed to retain the caller's existing sourced constraint diagnostics.
#[derive(Debug, PartialEq, Eq)]
pub enum DenyReadViolation {
MissingRequiredDeny,
ReadablePath(PathUri),
}
impl DenyReadValidator {
/// Prepares the strict matcher once, using the supplied executor facts.
pub fn new(
required: &FileSystemSandboxPolicy,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<Self, String> {
let matcher = has_context_read_denials(required, context)
.then(|| {
required
.prepare_deny_read_matcher(context, InvalidDenyReadGlobBehavior::ReturnError)
})
.transpose()?;
Ok(Self {
required_entries: required.entries.clone(),
matcher,
})
}
/// Preserves Core's entry-presence and raw readable-path checks.
///
/// Symbolic entries are not expanded. An equally specific deny does not
/// exempt a concrete readable grant from this validation. Native callers
/// retain their native-path conversion boundary before passing the policy.
pub fn validate(
&self,
selected: &FileSystemSandboxPolicy,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<(), DenyReadViolation> {
let missing_required_deny = self
.required_entries
.iter()
.any(|entry| !selected.entries.contains(entry));
let violating_root = selected
.entries
.iter()
.filter(|entry| entry.access.can_read())
.find_map(|entry| {
let FileSystemPath::Path { path } = &entry.path else {
return None;
};
if path.infer_path_convention() != context.cwd.infer_path_convention() {
return None;
}
self.matcher
.as_ref()
.is_some_and(|matcher| {
FileSystemSandboxPolicy::matches_prepared_read_deny(path, context, matcher)
})
.then_some(path)
});
// Core reports the first conflicting path even when a required entry is missing too.
if let Some(path) = violating_root {
return Err(DenyReadViolation::ReadablePath(path.clone()));
}
if missing_required_deny {
return Err(DenyReadViolation::MissingRequiredDeny);
}
Ok(())
}
}
pub(super) fn has_context_read_denials(
policy: &FileSystemSandboxPolicy,
context: &FileSystemSandboxPolicyContext<'_>,
) -> bool {
policy.kind == FileSystemSandboxKind::Restricted
&& policy.entries.iter().any(|entry| {
entry.access == FileSystemAccessMode::Deny
&& !matches!(&entry.path,
FileSystemPath::Special { value: FileSystemSpecialPath::SlashTmp }
if context.cwd.infer_path_convention() != Some(PathConvention::Posix)
)
})
}
+149
View File
@@ -0,0 +1,149 @@
//! Executor-context policy operations that never consult controller paths or environment.
use super::FileSystemPath;
use super::FileSystemSandboxPolicy;
use super::FileSystemSandboxPolicyContext;
use super::FileSystemSpecialPath;
use super::InvalidDenyReadGlobBehavior;
use super::ReadDenyMatcher;
use super::deny_read_validator::DenyReadValidator;
use super::deny_read_validator::has_context_read_denials;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
const INVALID_PATH: &str = "managed filesystem denial cannot be materialized safely";
const SHADOWED: &str = "managed filesystem denial is shadowed by a selected profile grant";
impl ReadDenyMatcher {
/// Builds a strict matcher using only the supplied execution-host paths.
pub fn try_new_with_context(
policy: &FileSystemSandboxPolicy,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<Option<Self>, String> {
if !has_context_read_denials(policy, context) {
return Ok(None);
}
validate_context_paths(policy, context)?;
let prepared =
policy.prepare_deny_read_matcher(context, InvalidDenyReadGlobBehavior::ReturnError)?;
Ok(Some(Self {
native_cwd: None,
user_home_dir: None,
temporary_directories: Vec::new(),
prepared,
}))
}
}
impl FileSystemSandboxPolicy {
/// Checks exact-root read access using paths owned by the execution host.
/// Deny glob enforcement is checked separately with [`ReadDenyMatcher`].
pub fn can_read_path(
&self,
path: &PathUri,
context: &FileSystemSandboxPolicyContext<'_>,
) -> bool {
self.resolve_access(path, context).can_read()
}
/// Preserves workspace-root symbols and adds their executor-owned concrete paths.
pub fn with_materialized_project_roots_for_path_uris(mut self, roots: &[PathUri]) -> Self {
for entry in self
.clone()
.materialize_project_roots_with_path_uris(roots)
.entries
{
if !self.entries.contains(&entry) {
self.entries.push(entry);
}
}
self
}
/// Builds workspace-write defaults without inspecting the execution host's filesystem.
pub fn workspace_write_with_path_uris(
roots: &[PathUri],
exclude_tmpdir_env_var: bool,
exclude_slash_tmp: bool,
) -> Self {
Self::workspace_write(&[], exclude_tmpdir_env_var, exclude_slash_tmp)
.with_materialized_project_roots_for_path_uris(roots)
}
/// Resolves deny glob text using the execution host's convention and home.
pub fn get_unreadable_globs_with_context(
&self,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<Vec<String>, String> {
self.deny_read_globs(context)
}
/// Checks the existing Core rules for required entries and readable concrete paths.
///
/// Sandbox-mode selection and native glob expansion remain caller-owned.
pub fn validate_managed_deny_read(
&self,
required: &Self,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<(), String> {
DenyReadValidator::new(required, context)?
.validate(self, context)
.map_err(|_| SHADOWED.to_string())
}
}
fn validate_context_paths(
policy: &FileSystemSandboxPolicy,
context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<(), String> {
let convention = context.cwd.infer_path_convention().ok_or(INVALID_PATH)?;
let valid = |path: &PathUri| {
path.infer_path_convention() == Some(convention) && path.lexical_depth().is_some()
};
if !valid(context.cwd) || context.workspace_roots.iter().any(|path| !valid(path)) {
return Err(INVALID_PATH.into());
}
for entry in &policy.entries {
match &entry.path {
FileSystemPath::Path { path } if !valid(path) => return Err(INVALID_PATH.into()),
FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
} => {
let directories = context.temporary_directories.ok_or(
"executor temporary-directory metadata is unavailable for managed filesystem denials",
)?;
if directories.iter().any(|path| !valid(path)) {
return Err(
"executor temporary-directory metadata cannot be materialized safely"
.into(),
);
}
}
FileSystemPath::Special {
value:
FileSystemSpecialPath::ProjectRoots {
subpath: Some(subpath),
},
} if context
.workspace_roots
.iter()
.any(|root| root.join(subpath).is_err()) =>
{
return Err(INVALID_PATH.into());
}
FileSystemPath::GlobPattern { pattern }
if (pattern.starts_with("~/")
|| convention == PathConvention::Windows && pattern.starts_with("~\\"))
&& !context.user_home_dir.is_some_and(valid) =>
{
return Err(INVALID_PATH.into());
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
#[path = "target_tests.rs"]
mod tests;
@@ -0,0 +1,269 @@
//! Regression coverage for remote path matching and managed-denial enforcement.
use super::*;
use crate::permissions::FileSystemAccessMode;
use crate::permissions::FileSystemSandboxEntry;
use crate::permissions::PROTECTED_METADATA_PATH_NAMES;
use pretty_assertions::assert_eq;
fn uri(input: &str) -> PathUri {
PathUri::parse(input).unwrap()
}
fn deny(path: FileSystemPath) -> FileSystemSandboxEntry {
FileSystemSandboxEntry::new(path, FileSystemAccessMode::Deny)
}
#[test]
fn read_denials_use_target_paths_and_native_glob_semantics() {
for base in [
"file:///work/repo",
"file:///Users/agent/repo",
"file:///C:/work/repo",
"file://server/share/repo",
] {
let cwd = uri(base);
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: std::slice::from_ref(&cwd),
user_home_dir: Some(&cwd),
temporary_directories: Some(&[]),
};
let policy = FileSystemSandboxPolicy::restricted(vec![
deny(FileSystemPath::Path {
path: cwd.join("private").unwrap(),
}),
deny(FileSystemPath::GlobPattern {
pattern: "~/secrets/*.key".into(),
}),
deny(FileSystemPath::GlobPattern {
pattern: "nested/**/*.env".into(),
}),
]);
let matcher = ReadDenyMatcher::try_new_with_context(&policy, &context)
.unwrap()
.unwrap();
for (path, expected) in [
("private", true),
("private/file", true),
("private-other/file", false),
("secrets/token.key", true),
("secrets/nested/token.key", false),
("nested/.env", true),
("nested/deep/token.env", true),
("public/file", false),
] {
assert_eq!(
matcher.is_read_denied_uri(&cwd.join(path).unwrap(), &context),
expected,
"{base}: {path}"
);
}
assert_eq!(
matcher.is_read_denied_uri(&cwd.join("PRIVATE/file").unwrap(), &context),
cwd.infer_path_convention() == Some(PathConvention::Windows),
);
assert!(matcher.is_read_denied_uri(&uri("file:///C:/foreign%2Fpath"), &context));
}
}
#[test]
fn managed_denials_reject_concrete_read_and_write_grants() {
for base in [
"file:///work/repo",
"file:///C:/work/repo",
"file://server/share/repo",
] {
let cwd = uri(base);
let private = cwd.join("private").unwrap();
let required = FileSystemSandboxPolicy::restricted(vec![deny(FileSystemPath::Path {
path: private.clone(),
})]);
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: std::slice::from_ref(&cwd),
user_home_dir: None,
temporary_directories: Some(&[]),
};
let mut policy = FileSystemSandboxPolicy::workspace_write_with_path_uris(
&[],
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
policy.entries.extend(required.entries.clone());
assert_eq!(
policy.validate_managed_deny_read(&required, &context),
Ok(())
);
for access in [FileSystemAccessMode::Read, FileSystemAccessMode::Write] {
let mut carveout = policy.clone();
carveout.entries.push(FileSystemSandboxEntry::new(
FileSystemPath::Path {
path: private.join("carveout").unwrap(),
},
access,
));
assert_eq!(
carveout.validate_managed_deny_read(&required, &context),
Err(SHADOWED.into())
);
}
}
}
#[test]
fn managed_denials_preserve_native_metadata_read_rejections() {
for base in [
"file:///executor/home",
"file:///C:/Users/executor",
"file://server/share/executor",
] {
let extra_root = uri(base);
let cwd = extra_root.join("workspace").unwrap();
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: std::slice::from_ref(&cwd),
user_home_dir: Some(&extra_root),
temporary_directories: Some(&[]),
};
let legacy = FileSystemSandboxPolicy::workspace_write_with_path_uris(
std::slice::from_ref(&extra_root),
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
for name in [".codex", ".git"] {
let denied_root = extra_root.join(name).unwrap();
let child = denied_root.join("reopened").unwrap();
for (path, expected_root_access) in [
(
FileSystemPath::Path {
path: denied_root.clone(),
},
FileSystemAccessMode::Deny,
),
(
FileSystemPath::GlobPattern {
pattern: denied_root.join("*").unwrap().inferred_native_path_string(),
},
FileSystemAccessMode::Read,
),
] {
let required = FileSystemSandboxPolicy::restricted(vec![deny(path)]);
let mut policy = legacy.clone();
policy.preserve_deny_read_restrictions_from(&required);
assert_eq!(
(
policy.resolve_access(&denied_root, &context),
policy.validate_managed_deny_read(&required, &context),
),
(
expected_root_access,
if expected_root_access == FileSystemAccessMode::Deny {
Err(SHADOWED.into())
} else {
Ok(())
},
),
"{base}: {name}",
);
let matcher = ReadDenyMatcher::try_new_with_context(&required, &context)
.unwrap()
.unwrap();
assert!(matcher.is_read_denied_uri(&child, &context));
for access in [FileSystemAccessMode::Read, FileSystemAccessMode::Write] {
let mut reopened = policy.clone();
reopened.entries.push(FileSystemSandboxEntry::new(
FileSystemPath::Path {
path: child.clone(),
},
access,
));
assert_eq!(
reopened.validate_managed_deny_read(&required, &context),
Err(SHADOWED.into()),
"{base}: {name}: {access}",
);
}
}
}
}
}
#[test]
fn malformed_or_unresolvable_denials_fail_closed() {
let cwd = uri("file:///work/repo");
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: &[],
user_home_dir: None,
temporary_directories: Some(&[]),
};
for pattern in ["~/secrets/**", "secrets/[z-a]"] {
let policy = FileSystemSandboxPolicy::restricted(vec![deny(FileSystemPath::GlobPattern {
pattern: pattern.into(),
})]);
assert!(ReadDenyMatcher::try_new_with_context(&policy, &context).is_err());
}
let policy = FileSystemSandboxPolicy::restricted(vec![deny(FileSystemPath::Path {
path: uri("file:///C:/private"),
})]);
assert!(ReadDenyMatcher::try_new_with_context(&policy, &context).is_err());
}
#[test]
fn slash_tmp_denials_follow_the_executor_convention() {
let policy = FileSystemSandboxPolicy::restricted(vec![deny(FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
})]);
for (base, active) in [("file:///work", true), ("file:///C:/work", false)] {
let cwd = uri(base);
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: &[],
user_home_dir: None,
temporary_directories: None,
};
let matcher = ReadDenyMatcher::try_new_with_context(&policy, &context).unwrap();
assert_eq!(matcher.is_some(), active);
if let Some(matcher) = matcher {
assert!(matcher.is_read_denied_uri(&cwd.join("/tmp/file").unwrap(), &context));
}
}
}
#[test]
fn remote_workspace_write_keeps_metadata_protected() {
let cwd = uri("file:///C:/repo");
let extra = uri("file:///D:/extra");
let policy = FileSystemSandboxPolicy::workspace_write_with_path_uris(
std::slice::from_ref(&extra),
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
let context = FileSystemSandboxPolicyContext {
cwd: &cwd,
workspace_roots: std::slice::from_ref(&cwd),
user_home_dir: None,
temporary_directories: Some(&[]),
};
for name in PROTECTED_METADATA_PATH_NAMES {
assert!(
policy
.entries
.contains(&FileSystemSandboxEntry::skip_missing_path(
FileSystemPath::Path {
path: extra.join(name).unwrap()
},
FileSystemAccessMode::Read,
))
);
}
for root in [cwd.clone(), extra] {
assert!(policy.can_write_path(&root.join("file").unwrap(), &context));
for name in PROTECTED_METADATA_PATH_NAMES {
let path = root.join(name).unwrap().join("file").unwrap();
assert!(policy.can_read_path(&path, &context));
assert!(!policy.can_write_path(&path, &context));
}
}
}
@@ -1,4 +1,5 @@
//! Windows deny-glob scan bounds shared by policy validation and native ACL expansion.
use codex_utils_path_uri::PathConvention;
/// Literal scan root and maximum traversal depth for a Windows deny glob.
pub struct WindowsDenyReadGlobScan<'a> {
@@ -26,8 +27,8 @@ pub fn windows_deny_read_glob_scan(
}
None => (".", pattern),
};
let components = pattern_suffix
.split(['/', '\\'])
let components = PathConvention::Windows
.path_segments(pattern_suffix)
.filter(|component| !component.is_empty())
.collect::<Vec<_>>();
let max_depth = if components.contains(&"**") {