From c53f342fecfb00a8d068024516f6483da8c852b4 Mon Sep 17 00:00:00 2001 From: Sean Huang Date: Tue, 8 Sep 2026 22:42:19 +0000 Subject: [PATCH] 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 --- codex-rs/core/src/config/mod.rs | 84 +++--- codex-rs/protocol/src/models.rs | 18 ++ codex-rs/protocol/src/permissions.rs | 4 + .../src/permissions/deny_read_validator.rs | 102 +++++++ codex-rs/protocol/src/permissions/target.rs | 149 ++++++++++ .../protocol/src/permissions/target_tests.rs | 269 ++++++++++++++++++ .../protocol/src/permissions/windows_glob.rs | 5 +- 7 files changed, 596 insertions(+), 35 deletions(-) create mode 100644 codex-rs/protocol/src/permissions/deny_read_validator.rs create mode 100644 codex-rs/protocol/src/permissions/target.rs create mode 100644 codex-rs/protocol/src/permissions/target_tests.rs diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f555f74584..9af0ec6d0d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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::>(); + 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, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d9fed84077..f2fe1af541 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -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], diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index 71bf57d66d..894e1268c3 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -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; diff --git a/codex-rs/protocol/src/permissions/deny_read_validator.rs b/codex-rs/protocol/src/permissions/deny_read_validator.rs new file mode 100644 index 0000000000..5a1a9a21d7 --- /dev/null +++ b/codex-rs/protocol/src/permissions/deny_read_validator.rs @@ -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, + matcher: Option, +} + +/// 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 { + 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) + ) + }) +} diff --git a/codex-rs/protocol/src/permissions/target.rs b/codex-rs/protocol/src/permissions/target.rs new file mode 100644 index 0000000000..fd6c98d809 --- /dev/null +++ b/codex-rs/protocol/src/permissions/target.rs @@ -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, 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, 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; diff --git a/codex-rs/protocol/src/permissions/target_tests.rs b/codex-rs/protocol/src/permissions/target_tests.rs new file mode 100644 index 0000000000..7e4876c222 --- /dev/null +++ b/codex-rs/protocol/src/permissions/target_tests.rs @@ -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)); + } + } +} diff --git a/codex-rs/protocol/src/permissions/windows_glob.rs b/codex-rs/protocol/src/permissions/windows_glob.rs index 641caa5ff0..3f424ead95 100644 --- a/codex-rs/protocol/src/permissions/windows_glob.rs +++ b/codex-rs/protocol/src/permissions/windows_glob.rs @@ -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::>(); let max_depth = if components.contains(&"**") {