use std::collections::HashSet; use std::ffi::OsStr; use std::io; use std::path::Path; 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; use strum_macros::Display; use tracing::error; use ts_rs::TS; use crate::protocol::NetworkAccess; use crate::protocol::SandboxPolicy; use crate::protocol::WritableRoot; const PROTECTED_METADATA_GIT_PATH_NAME: &str = ".git"; const PROTECTED_METADATA_AGENTS_PATH_NAME: &str = ".agents"; const PROTECTED_METADATA_CODEX_PATH_NAME: &str = ".codex"; /// Top-level workspace metadata paths that stay protected under writable roots. pub const PROTECTED_METADATA_PATH_NAMES: &[&str] = &[ PROTECTED_METADATA_GIT_PATH_NAME, PROTECTED_METADATA_AGENTS_PATH_NAME, PROTECTED_METADATA_CODEX_PATH_NAME, ]; /// Returns true when a path basename is one of the protected workspace metadata names. pub fn is_protected_metadata_name(name: &OsStr) -> bool { PROTECTED_METADATA_PATH_NAMES .iter() .any(|metadata_name| name == OsStr::new(metadata_name)) } pub fn is_protected_metadata_directory_name(name: &OsStr) -> bool { name == OsStr::new(PROTECTED_METADATA_AGENTS_PATH_NAME) || name == OsStr::new(PROTECTED_METADATA_CODEX_PATH_NAME) } /// Returns the protected workspace metadata name when an agent write to `path` /// should be blocked before execution. pub fn forbidden_agent_metadata_write( path: &Path, cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, ) -> Option<&'static str> { if !matches!( file_system_sandbox_policy.kind, FileSystemSandboxKind::Restricted ) { return None; } let target = resolve_candidate_path(path, cwd)?; let (protected_metadata_path, metadata_name) = metadata_child_of_writable_root(file_system_sandbox_policy, target.as_path(), cwd)?; if has_explicit_write_entry_for_metadata_path( file_system_sandbox_policy, &protected_metadata_path, target.as_path(), cwd, ) { return None; } if !file_system_sandbox_policy.can_write_path_with_cwd(target.as_path(), cwd) { return Some(metadata_name); } None } #[derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS, )] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum NetworkSandboxPolicy { #[default] Restricted, Enabled, } impl NetworkSandboxPolicy { pub fn is_enabled(self) -> bool { matches!(self, NetworkSandboxPolicy::Enabled) } } /// Access mode for a filesystem entry. /// /// When two equally specific entries target the same path, we compare these by /// conflict precedence rather than by capability breadth: `none` beats /// `write`, and `write` beats `read`. #[derive( Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Display, JsonSchema, TS, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum FileSystemAccessMode { Read, Write, None, } impl FileSystemAccessMode { pub fn can_read(self) -> bool { !matches!(self, FileSystemAccessMode::None) } pub fn can_write(self) -> bool { matches!(self, FileSystemAccessMode::Write) } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] #[serde(tag = "kind", rename_all = "snake_case")] #[ts(tag = "kind")] pub enum FileSystemSpecialPath { Root, Minimal, #[serde(alias = "current_working_directory")] ProjectRoots { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] subpath: Option, }, Tmpdir, SlashTmp, /// WARNING: `:special_path` tokens are part of config compatibility. /// Do not make older runtimes reject newly introduced tokens. /// New parser support should be additive, while unknown values must stay /// representable so config from a newer Codex degrades to warn-and-ignore /// instead of failing to load. Codex 0.112.0 rejected unknown values here, /// which broke forward compatibility for newer config. /// Preserves future special-path tokens so older runtimes can ignore them /// without rejecting config authored by a newer release. Unknown { path: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] subpath: Option, }, } impl FileSystemSpecialPath { pub fn project_roots(subpath: Option) -> Self { Self::ProjectRoots { subpath } } pub fn unknown(path: impl Into, subpath: Option) -> Self { Self::Unknown { path: path.into(), subpath, } } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] pub struct FileSystemSandboxEntry { pub path: FileSystemPath, pub access: FileSystemAccessMode, } #[derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS, )] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] pub enum FileSystemSandboxKind { #[default] Restricted, Unrestricted, ExternalSandbox, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] pub struct FileSystemSandboxPolicy { pub kind: FileSystemSandboxKind, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub glob_scan_max_depth: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub entries: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] struct ResolvedFileSystemEntry { path: AbsolutePathBuf, access: FileSystemAccessMode, } #[derive(Debug, Clone, PartialEq, Eq)] struct FileSystemSemanticSignature { has_full_disk_read_access: bool, has_full_disk_write_access: bool, include_platform_defaults: bool, readable_roots: Vec, writable_roots: Vec, unreadable_roots: Vec, unreadable_globs: Vec, } /// Runtime matcher for read-deny entries in a filesystem sandbox policy. pub struct ReadDenyMatcher { denied_candidates: Vec>, deny_read_matchers: Vec, 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 { 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, Hash, Serialize, Deserialize, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type")] pub enum FileSystemPath { 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 { fn default() -> Self { Self { kind: FileSystemSandboxKind::Restricted, glob_scan_max_depth: None, entries: vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }], } } } impl FileSystemSandboxPolicy { pub fn unrestricted() -> Self { Self { kind: FileSystemSandboxKind::Unrestricted, glob_scan_max_depth: None, entries: Vec::new(), } } pub fn external_sandbox() -> Self { Self { kind: FileSystemSandboxKind::ExternalSandbox, glob_scan_max_depth: None, entries: Vec::new(), } } pub fn restricted(entries: Vec) -> Self { Self { kind: FileSystemSandboxKind::Restricted, glob_scan_max_depth: None, entries, } } 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_for_cwd(sandbox_policy, cwd); if !matches!(rebuilt.kind, FileSystemSandboxKind::Restricted) { return rebuilt; } rebuilt.glob_scan_max_depth = existing.glob_scan_max_depth; 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 } /// Preserve explicit read-deny rules from `existing` when a caller /// replaces the allow side of a policy. pub fn preserve_deny_read_restrictions_from(&mut self, existing: &Self) { let has_deny_read_entries = existing .entries .iter() .any(|entry| entry.access == FileSystemAccessMode::None); if matches!(self.kind, FileSystemSandboxKind::Unrestricted) && has_deny_read_entries { *self = Self::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }]); } if !matches!(self.kind, FileSystemSandboxKind::Restricted) { return; } if self.glob_scan_max_depth.is_none() { self.glob_scan_max_depth = existing.glob_scan_max_depth; } for deny_entry in existing .entries .iter() .filter(|entry| entry.access == FileSystemAccessMode::None) { if !self.entries.iter().any(|entry| entry == deny_entry) { self.entries.push(deny_entry.clone()); } } } /// 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) }) } /// Filesystem policy matching `WorkspaceWrite` semantics without requiring /// callers to construct a legacy [`SandboxPolicy`] first. pub fn workspace_write( writable_roots: &[AbsolutePathBuf], exclude_tmpdir_env_var: bool, exclude_slash_tmp: bool, ) -> Self { let mut entries = vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }]; entries.push(FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }); if !exclude_slash_tmp { entries.push(FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::SlashTmp, }, access: FileSystemAccessMode::Write, }); } if !exclude_tmpdir_env_var { entries.push(FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Tmpdir, }, access: FileSystemAccessMode::Write, }); } entries.extend( writable_roots .iter() .cloned() .map(|path| FileSystemSandboxEntry { path: FileSystemPath::Path { path }, access: FileSystemAccessMode::Write, }), ); append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".git"); append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".agents"); append_default_read_only_project_root_subpath_if_no_explicit_rule(&mut entries, ".codex"); for writable_root in writable_roots { for protected_path in default_read_only_subpaths_for_writable_root( writable_root, /*protect_missing_dot_codex*/ false, ) { append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path); } } FileSystemSandboxPolicy::restricted(entries) } /// Converts a legacy sandbox policy into an equivalent filesystem policy /// after resolving cwd-sensitive legacy defaults for the provided cwd. /// /// Legacy `WorkspaceWrite` policies may list readable roots that live /// under an already-writable root. Those paths were redundant in the /// legacy model and should not become read-only carveouts when projected /// into split filesystem policy. pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self { let mut file_system_policy = Self::from(sandbox_policy); if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = sandbox_policy { if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) { for protected_path in default_read_only_subpaths_for_writable_root( &cwd_root, /*protect_missing_dot_codex*/ true, ) { append_default_read_only_path_if_no_explicit_rule( &mut file_system_policy.entries, protected_path, ); } } for writable_root in writable_roots { for protected_path in default_read_only_subpaths_for_writable_root( writable_root, /*protect_missing_dot_codex*/ false, ) { append_default_read_only_path_if_no_explicit_rule( &mut file_system_policy.entries, protected_path, ); } } } file_system_policy } /// Returns true when filesystem reads are unrestricted. pub fn has_full_disk_read_access(&self) -> bool { match self.kind { FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true, FileSystemSandboxKind::Restricted => { self.has_root_access(FileSystemAccessMode::can_read) && !self.has_denied_read_restrictions() } } } /// Returns true when filesystem writes are unrestricted. pub fn has_full_disk_write_access(&self) -> bool { match self.kind { FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => true, FileSystemSandboxKind::Restricted => { self.has_root_access(FileSystemAccessMode::can_write) && !self.has_write_narrowing_entries() } } } /// Returns true when platform-default readable roots should be included. pub fn include_platform_defaults(&self) -> bool { !self.has_full_disk_read_access() && matches!(self.kind, FileSystemSandboxKind::Restricted) && self.entries.iter().any(|entry| { matches!( &entry.path, FileSystemPath::Special { value } if matches!(value, FileSystemSpecialPath::Minimal) && entry.access.can_read() ) }) } pub fn resolve_access_with_cwd(&self, path: &Path, cwd: &Path) -> FileSystemAccessMode { match self.kind { FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => { return FileSystemAccessMode::Write; } FileSystemSandboxKind::Restricted => {} } let Some(path) = resolve_candidate_path(path, cwd) else { return FileSystemAccessMode::None; }; self.resolved_entries_with_cwd(cwd) .into_iter() .filter(|entry| path.as_path().starts_with(entry.path.as_path())) .max_by_key(resolved_entry_precedence) .map(|entry| entry.access) .unwrap_or(FileSystemAccessMode::None) } pub fn can_read_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool { self.resolve_access_with_cwd(path, cwd).can_read() } pub fn can_write_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool { if !self.resolve_access_with_cwd(path, cwd).can_write() { return false; } if self.has_full_disk_write_access() { return true; } !self.is_metadata_write_denied(path, cwd) } fn is_metadata_write_denied(&self, path: &Path, cwd: &Path) -> bool { if !matches!(self.kind, FileSystemSandboxKind::Restricted) { return false; } let Some(target) = resolve_candidate_path(path, cwd) else { return true; }; let Some((protected_metadata_path, _)) = metadata_child_of_writable_root(self, target.as_path(), cwd) else { return false; }; !has_explicit_write_entry_for_metadata_path( self, &protected_metadata_path, target.as_path(), cwd, ) } /// Replaces symbolic `:project_roots` entries with absolute paths resolved /// against `cwd`. /// /// Use this when a durable permission profile must survive a cwd-only /// update without rebinding its project-root authority to the new cwd. pub fn materialize_project_roots_with_cwd(mut self, cwd: &Path) -> Self { let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok(); for entry in &mut self.entries { let FileSystemPath::Special { value: FileSystemSpecialPath::ProjectRoots { .. }, } = &entry.path else { continue; }; if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) { entry.path = FileSystemPath::Path { path }; } } self } pub fn with_additional_readable_roots( mut self, cwd: &Path, additional_readable_roots: &[AbsolutePathBuf], ) -> Self { if self.has_full_disk_read_access() { return self; } for path in additional_readable_roots { if self.can_read_path_with_cwd(path.as_path(), cwd) { continue; } self.entries.push(FileSystemSandboxEntry { path: FileSystemPath::Path { path: path.clone() }, access: FileSystemAccessMode::Read, }); } self } pub fn with_additional_writable_roots( mut self, cwd: &Path, additional_writable_roots: &[AbsolutePathBuf], ) -> Self { for path in additional_writable_roots { if self.can_write_path_with_cwd(path.as_path(), cwd) { continue; } self.entries.push(FileSystemSandboxEntry { path: FileSystemPath::Path { path: path.clone() }, access: FileSystemAccessMode::Write, }); } self } /// Add roots using legacy `WorkspaceWrite` behavior. /// /// Unlike [`Self::with_additional_writable_roots`], this mirrors legacy /// writable-roots semantics by adding exact roots even when they are /// already writable through `:project_roots`, and by adding the default /// read-only protected subpaths for each new root. pub fn with_additional_legacy_workspace_writable_roots( mut self, additional_writable_roots: &[AbsolutePathBuf], ) -> Self { if !matches!(self.kind, FileSystemSandboxKind::Restricted) { return self; } for path in additional_writable_roots { if !self.entries.iter().any(|entry| { entry.access.can_write() && matches!(&entry.path, FileSystemPath::Path { path: existing } if existing == path) }) { self.entries.push(FileSystemSandboxEntry { path: FileSystemPath::Path { path: path.clone() }, access: FileSystemAccessMode::Write, }); } for protected_path in default_read_only_subpaths_for_writable_root( path, /*protect_missing_dot_codex*/ false, ) { append_default_read_only_path_if_no_explicit_rule( &mut self.entries, protected_path, ); } } self } pub fn needs_direct_runtime_enforcement( &self, network_policy: NetworkSandboxPolicy, cwd: &Path, ) -> bool { if !matches!(self.kind, FileSystemSandboxKind::Restricted) { return false; } let Ok(legacy_policy) = self.to_legacy_sandbox_policy(network_policy, cwd) else { return true; }; if protected_metadata_names_need_direct_runtime_enforcement(self, &legacy_policy, cwd) { return true; } self.semantic_signature(cwd) != legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd) .semantic_signature(cwd) } /// Returns true when two policies resolve to the same filesystem access /// model for `cwd`, ignoring incidental entry ordering. pub fn is_semantically_equivalent_to(&self, other: &Self, cwd: &Path) -> bool { self.semantic_signature(cwd) == other.semantic_signature(cwd) } /// Returns the explicit readable roots resolved against the provided cwd. pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec { if self.has_full_disk_read_access() { return Vec::new(); } dedup_absolute_paths( self.resolved_entries_with_cwd(cwd) .into_iter() .filter(|entry| entry.access.can_read()) .filter(|entry| self.can_read_path_with_cwd(entry.path.as_path(), cwd)) .map(|entry| entry.path) .collect(), /*normalize_effective_paths*/ true, ) } /// Returns the writable roots together with read-only carveouts resolved /// against the provided cwd. pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec { if self.has_full_disk_write_access() { return Vec::new(); } let resolved_entries = self.resolved_entries_with_cwd(cwd); let writable_entries: Vec = resolved_entries .iter() .filter(|entry| entry.access.can_write()) .filter(|entry| self.can_write_path_with_cwd(entry.path.as_path(), cwd)) .map(|entry| entry.path.clone()) .collect(); dedup_absolute_paths( writable_entries.clone(), /*normalize_effective_paths*/ true, ) .into_iter() .map(|root| { // Filesystem-root policies stay in their effective canonical form // so root-wide aliases do not create duplicate top-level masks. // Example: keep `/var/...` normalized under `/` instead of // materializing both `/var/...` and `/private/var/...`. // Nested symlink paths under a writable root stay logical so // downstream sandboxes can still bind the real target while // masking the user-visible symlink inode when needed. let preserve_raw_carveout_paths = root.as_path().parent().is_some(); let raw_writable_roots: Vec<&AbsolutePathBuf> = writable_entries .iter() .filter(|path| normalize_effective_absolute_path((*path).clone()) == root) .collect(); let protected_metadata_names = protected_metadata_names_for_writable_root(self, &root, &raw_writable_roots, cwd); let protect_missing_dot_codex = AbsolutePathBuf::from_absolute_path(cwd) .ok() .is_some_and(|cwd| normalize_effective_absolute_path(cwd) == root); let mut read_only_subpaths: Vec = default_read_only_subpaths_for_writable_root(&root, protect_missing_dot_codex) .into_iter() .filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path)) .collect(); // Narrower explicit non-write entries carve out broader writable roots. // More specific write entries still remain writable because they appear // as separate WritableRoot values and are checked independently. // Preserve symlink path components that live under the writable root // so downstream sandboxes can still mask the symlink inode itself. // Example: if `/.codex -> /decoy`, bwrap must still see // `/.codex`, not only the resolved `/decoy`. read_only_subpaths.extend( resolved_entries .iter() .filter(|entry| !entry.access.can_write()) .filter(|entry| !self.can_write_path_with_cwd(entry.path.as_path(), cwd)) .filter_map(|entry| { let effective_path = normalize_effective_absolute_path(entry.path.clone()); // Preserve the literal in-root path whenever the // carveout itself lives under this writable root, even // if following symlinks would resolve back to the root // or escape outside it. Downstream sandboxes need that // raw path so they can mask the symlink inode itself. // Examples: // - `/linked-private -> /decoy-private` // - `/linked-private -> /tmp/outside-private` // - `/alias-root -> ` let raw_carveout_path = if preserve_raw_carveout_paths { if entry.path == root { None } else if entry.path.as_path().starts_with(root.as_path()) { Some(entry.path.clone()) } else { raw_writable_roots.iter().find_map(|raw_root| { let suffix = entry .path .as_path() .strip_prefix(raw_root.as_path()) .ok()?; if suffix.as_os_str().is_empty() { return None; } Some(root.join(suffix)) }) } } else { None }; if let Some(raw_carveout_path) = raw_carveout_path { return Some(raw_carveout_path); } if effective_path == root || !effective_path.as_path().starts_with(root.as_path()) { return None; } Some(effective_path) }), ); WritableRoot { protected_metadata_names, root, // Preserve literal in-root protected paths like `.git` and // `.codex` so downstream sandboxes can still detect and mask // the symlink itself instead of only its resolved target. read_only_subpaths: dedup_absolute_paths( read_only_subpaths, /*normalize_effective_paths*/ false, ), } }) .collect() } /// Returns explicit unreadable roots resolved against the provided cwd. pub fn get_unreadable_roots_with_cwd(&self, cwd: &Path) -> Vec { if !matches!(self.kind, FileSystemSandboxKind::Restricted) { return Vec::new(); } let root = AbsolutePathBuf::from_absolute_path(cwd) .ok() .map(|cwd| absolute_root_path_for_cwd(&cwd)); dedup_absolute_paths( self.resolved_entries_with_cwd(cwd) .iter() .filter(|entry| entry.access == FileSystemAccessMode::None) .filter(|entry| !self.can_read_path_with_cwd(entry.path.as_path(), cwd)) // Restricted policies already deny reads outside explicit allow roots, // so materializing the filesystem root here would erase narrower // readable carveouts when downstream sandboxes apply deny masks last. .filter(|entry| root.as_ref() != Some(&entry.path)) .map(|entry| entry.path.clone()) .collect(), /*normalize_effective_paths*/ true, ) } /// Returns unreadable glob patterns resolved against the provided cwd. pub fn get_unreadable_globs_with_cwd(&self, cwd: &Path) -> Vec { 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::>(); patterns.sort(); patterns.dedup(); patterns } pub fn to_legacy_sandbox_policy( &self, network_policy: NetworkSandboxPolicy, cwd: &Path, ) -> io::Result { Ok(match self.kind { FileSystemSandboxKind::ExternalSandbox => SandboxPolicy::ExternalSandbox { network_access: if network_policy.is_enabled() { NetworkAccess::Enabled } else { NetworkAccess::Restricted }, }, FileSystemSandboxKind::Unrestricted => { if network_policy.is_enabled() { SandboxPolicy::DangerFullAccess } else { SandboxPolicy::ExternalSandbox { network_access: NetworkAccess::Restricted, } } } FileSystemSandboxKind::Restricted => { let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); let has_full_disk_write_access = self.has_full_disk_write_access(); let mut workspace_root_writable = false; let mut writable_roots = Vec::new(); let mut tmpdir_writable = false; let mut slash_tmp_writable = false; let mut unbridgeable_root_write = false; 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) { workspace_root_writable = true; } else { writable_roots.push(path.clone()); } } } FileSystemPath::Special { value } => match value { FileSystemSpecialPath::Root => match entry.access { FileSystemAccessMode::None => {} FileSystemAccessMode::Read => {} FileSystemAccessMode::Write => { unbridgeable_root_write = true; } }, FileSystemSpecialPath::Minimal => {} FileSystemSpecialPath::ProjectRoots { subpath } => { if subpath.is_none() && entry.access.can_write() { workspace_root_writable = true; } else if let Some(path) = resolve_file_system_special_path(value, cwd_absolute.as_ref()) && entry.access.can_write() { writable_roots.push(path); } } FileSystemSpecialPath::Tmpdir => { if entry.access.can_write() { tmpdir_writable = true; } } FileSystemSpecialPath::SlashTmp => { if entry.access.can_write() { slash_tmp_writable = true; } } FileSystemSpecialPath::Unknown { .. } => {} }, } } if has_full_disk_write_access { return Ok(if network_policy.is_enabled() { SandboxPolicy::DangerFullAccess } else { SandboxPolicy::ExternalSandbox { network_access: NetworkAccess::Restricted, } }); } if workspace_root_writable { SandboxPolicy::WorkspaceWrite { writable_roots: dedup_absolute_paths( writable_roots, /*normalize_effective_paths*/ false, ), network_access: network_policy.is_enabled(), exclude_tmpdir_env_var: !tmpdir_writable, exclude_slash_tmp: !slash_tmp_writable, } } else if unbridgeable_root_write || !writable_roots.is_empty() || tmpdir_writable || slash_tmp_writable { return Err(io::Error::new( io::ErrorKind::InvalidInput, "permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly", )); } else { SandboxPolicy::ReadOnly { network_access: network_policy.is_enabled(), } } } }) } fn resolved_entries_with_cwd(&self, cwd: &Path) -> Vec { let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); self.entries .iter() .filter_map(|entry| { resolve_entry_path(&entry.path, cwd_absolute.as_ref()).map(|path| { ResolvedFileSystemEntry { path, access: entry.access, } }) }) .collect() } fn semantic_signature(&self, cwd: &Path) -> FileSystemSemanticSignature { FileSystemSemanticSignature { has_full_disk_read_access: self.has_full_disk_read_access(), has_full_disk_write_access: self.has_full_disk_write_access(), include_platform_defaults: self.include_platform_defaults(), readable_roots: sorted_absolute_paths(self.get_readable_roots_with_cwd(cwd)), writable_roots: sorted_writable_roots(self.get_writable_roots_with_cwd(cwd)), unreadable_roots: sorted_absolute_paths(self.get_unreadable_roots_with_cwd(cwd)), unreadable_globs: self.get_unreadable_globs_with_cwd(cwd), } } } impl From<&SandboxPolicy> for NetworkSandboxPolicy { fn from(value: &SandboxPolicy) -> Self { if value.has_full_network_access() { NetworkSandboxPolicy::Enabled } else { NetworkSandboxPolicy::Restricted } } } impl From<&SandboxPolicy> for FileSystemSandboxPolicy { fn from(value: &SandboxPolicy) -> Self { match value { SandboxPolicy::DangerFullAccess => FileSystemSandboxPolicy::unrestricted(), SandboxPolicy::ExternalSandbox { .. } => FileSystemSandboxPolicy::external_sandbox(), SandboxPolicy::ReadOnly { .. } => { FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }]) } SandboxPolicy::WorkspaceWrite { writable_roots, exclude_tmpdir_env_var, exclude_slash_tmp, .. } => FileSystemSandboxPolicy::workspace_write( writable_roots, *exclude_tmpdir_env_var, *exclude_slash_tmp, ), } } } fn resolve_file_system_path( path: &FileSystemPath, cwd: Option<&AbsolutePathBuf>, ) -> Option { match path { FileSystemPath::Path { path } => Some(path.clone()), FileSystemPath::GlobPattern { .. } => None, FileSystemPath::Special { value } => resolve_file_system_special_path(value, cwd), } } fn resolve_entry_path( path: &FileSystemPath, cwd: Option<&AbsolutePathBuf>, ) -> Option { match path { FileSystemPath::Special { value: FileSystemSpecialPath::Root, } => cwd.map(absolute_root_path_for_cwd), _ => resolve_file_system_path(path, cwd), } } fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option { if path.is_absolute() { AbsolutePathBuf::from_absolute_path(path).ok() } else { Some(AbsolutePathBuf::from_absolute_path(cwd).ok()?.join(path)) } } /// Returns true when two config paths refer to the same exact target before /// any prefix matching is applied. /// /// This is intentionally narrower than full path resolution: it only answers /// the "can one entry shadow another at the same specificity?" question used /// by `has_write_narrowing_entries`. fn file_system_paths_share_target(left: &FileSystemPath, right: &FileSystemPath) -> bool { match (left, right) { (FileSystemPath::Path { path: left }, FileSystemPath::Path { path: right }) => { left == right } (FileSystemPath::Special { value: left }, FileSystemPath::Special { value: right }) => { special_paths_share_target(left, right) } (FileSystemPath::Path { path }, FileSystemPath::Special { value }) | (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, } } /// Compares special-path tokens that resolve to the same concrete target /// without needing a cwd. fn special_paths_share_target(left: &FileSystemSpecialPath, right: &FileSystemSpecialPath) -> bool { match (left, right) { (FileSystemSpecialPath::Root, FileSystemSpecialPath::Root) | (FileSystemSpecialPath::Minimal, FileSystemSpecialPath::Minimal) | (FileSystemSpecialPath::Tmpdir, FileSystemSpecialPath::Tmpdir) | (FileSystemSpecialPath::SlashTmp, FileSystemSpecialPath::SlashTmp) => true, ( FileSystemSpecialPath::ProjectRoots { subpath: left }, FileSystemSpecialPath::ProjectRoots { subpath: right }, ) => left == right, ( FileSystemSpecialPath::Unknown { path: left, subpath: left_subpath, }, FileSystemSpecialPath::Unknown { path: right, subpath: right_subpath, }, ) => left == right && left_subpath == right_subpath, _ => false, } } /// Matches cwd-independent special paths against absolute `Path` entries when /// they name the same location. /// /// We intentionally only fold the special paths whose concrete meaning is /// stable without a cwd, such as `/` and `/tmp`. fn special_path_matches_absolute_path( value: &FileSystemSpecialPath, path: &AbsolutePathBuf, ) -> bool { match value { FileSystemSpecialPath::Root => path.as_path().parent().is_none(), FileSystemSpecialPath::SlashTmp => path.as_path() == Path::new("/tmp"), _ => false, } } /// Orders resolved entries so the most specific path wins first, then applies /// the access tie-breaker from [`FileSystemAccessMode`]. fn resolved_entry_precedence(entry: &ResolvedFileSystemEntry) -> (usize, FileSystemAccessMode) { let specificity = entry.path.as_path().components().count(); (specificity, entry.access) } fn absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf { let root = cwd .as_path() .ancestors() .last() .unwrap_or_else(|| panic!("cwd must have a filesystem root")); AbsolutePathBuf::from_absolute_path(root) .unwrap_or_else(|err| panic!("cwd root must be an absolute path: {err}")) } fn normalized_and_canonical_candidates(path: &Path) -> Vec { // 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, candidate: PathBuf) { if !candidates.iter().any(|existing| existing == &candidate) { candidates.push(candidate); } } fn build_glob_matcher(pattern: &str) -> Option { // 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>, ) -> Option { match value { FileSystemSpecialPath::Root | FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => None, FileSystemSpecialPath::ProjectRoots { subpath } => { let cwd = cwd?; match subpath.as_ref() { Some(subpath) => Some(AbsolutePathBuf::resolve_path_against_base( subpath, cwd.as_path(), )), None => Some(cwd.clone()), } } FileSystemSpecialPath::Tmpdir => { let tmpdir = std::env::var_os("TMPDIR")?; if tmpdir.is_empty() { None } else { let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?; Some(tmpdir) } } FileSystemSpecialPath::SlashTmp => { #[allow(clippy::expect_used)] let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute"); if !slash_tmp.as_path().is_dir() { return None; } Some(slash_tmp) } } } fn dedup_absolute_paths( paths: Vec, normalize_effective_paths: bool, ) -> Vec { let mut deduped = Vec::with_capacity(paths.len()); let mut seen = HashSet::new(); for path in paths { let dedup_path = if normalize_effective_paths { normalize_effective_absolute_path(path) } else { path }; if seen.insert(dedup_path.to_path_buf()) { deduped.push(dedup_path); } } deduped } fn sorted_absolute_paths(mut paths: Vec) -> Vec { paths.sort_by(|left, right| left.as_path().cmp(right.as_path())); paths } fn sorted_writable_roots(mut roots: Vec) -> Vec { for root in &mut roots { root.read_only_subpaths = sorted_absolute_paths(std::mem::take(&mut root.read_only_subpaths)); root.protected_metadata_names.sort(); root.protected_metadata_names.dedup(); } roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path())); roots } fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf { let raw_path = path.to_path_buf(); for ancestor in raw_path.ancestors() { if std::fs::symlink_metadata(ancestor).is_err() { continue; } let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else { continue; }; let Ok(suffix) = raw_path.strip_prefix(ancestor) else { continue; }; if let Ok(normalized_path) = AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix)) { return normalized_path; } } path } pub(crate) fn default_read_only_subpaths_for_writable_root( writable_root: &AbsolutePathBuf, protect_missing_dot_codex: bool, ) -> Vec { let mut subpaths: Vec = Vec::new(); let top_level_git = writable_root.join(PROTECTED_METADATA_GIT_PATH_NAME); // This applies to typical repos (directory .git), worktrees/submodules // (file .git with gitdir pointer), and bare repos when the gitdir is the // writable root itself. let top_level_git_is_file = top_level_git.as_path().is_file(); let top_level_git_is_dir = top_level_git.as_path().is_dir(); let should_protect_top_level = top_level_git_is_dir || top_level_git_is_file; if should_protect_top_level { if top_level_git_is_file && is_git_pointer_file(&top_level_git) && let Some(gitdir) = resolve_gitdir_from_file(&top_level_git) { subpaths.push(gitdir); } subpaths.push(top_level_git); } let top_level_agents = writable_root.join(PROTECTED_METADATA_AGENTS_PATH_NAME); if top_level_agents.as_path().is_dir() { subpaths.push(top_level_agents); } // Keep top-level project metadata under .codex read-only to the agent by // default. For the workspace root itself, protect it even before the // directory exists so first-time creation still goes through the // protected-path approval flow. let top_level_codex = writable_root.join(PROTECTED_METADATA_CODEX_PATH_NAME); if protect_missing_dot_codex || top_level_codex.as_path().is_dir() { subpaths.push(top_level_codex); } dedup_absolute_paths(subpaths, /*normalize_effective_paths*/ false) } /// Rebuilds the filesystem policy that legacy sandbox runtimes enforce for a /// concrete cwd. /// /// Unlike [`FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd`], this /// intentionally does not add symbolic project-root metadata carveouts. Legacy /// runtime expansion only protected `.git`/`.agents` when those paths already /// existed, so missing-path carveouts still require direct profile enforcement. fn legacy_runtime_file_system_policy_for_cwd( sandbox_policy: &SandboxPolicy, cwd: &Path, ) -> FileSystemSandboxPolicy { let SandboxPolicy::WorkspaceWrite { writable_roots, exclude_tmpdir_env_var, exclude_slash_tmp, .. } = sandbox_policy else { return FileSystemSandboxPolicy::from(sandbox_policy); }; let mut entries = vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, ]; if !*exclude_slash_tmp { entries.push(FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::SlashTmp, }, access: FileSystemAccessMode::Write, }); } if !*exclude_tmpdir_env_var { entries.push(FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Tmpdir, }, access: FileSystemAccessMode::Write, }); } entries.extend( writable_roots .iter() .cloned() .map(|path| FileSystemSandboxEntry { path: FileSystemPath::Path { path }, access: FileSystemAccessMode::Write, }), ); if let Ok(cwd_root) = AbsolutePathBuf::from_absolute_path(cwd) { for protected_path in default_read_only_subpaths_for_writable_root( &cwd_root, /*protect_missing_dot_codex*/ true, ) { append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path); } } for writable_root in writable_roots { for protected_path in default_read_only_subpaths_for_writable_root( writable_root, /*protect_missing_dot_codex*/ false, ) { append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path); } } FileSystemSandboxPolicy::restricted(entries) } fn append_default_read_only_project_root_subpath_if_no_explicit_rule( entries: &mut Vec, subpath: impl Into, ) { append_default_read_only_entry_if_no_explicit_rule( entries, FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(subpath.into())), }, ); } fn append_default_read_only_path_if_no_explicit_rule( entries: &mut Vec, path: AbsolutePathBuf, ) { append_default_read_only_entry_if_no_explicit_rule(entries, FileSystemPath::Path { path }); } fn append_default_read_only_entry_if_no_explicit_rule( entries: &mut Vec, path: FileSystemPath, ) { if entries .iter() .any(|entry| file_system_paths_share_target(&entry.path, &path)) { return; } entries.push(FileSystemSandboxEntry { path, access: FileSystemAccessMode::Read, }); } fn has_explicit_resolved_path_entry( entries: &[ResolvedFileSystemEntry], path: &AbsolutePathBuf, ) -> bool { entries.iter().any(|entry| &entry.path == path) } fn metadata_path_name(name: &OsStr) -> Option<&'static str> { PROTECTED_METADATA_PATH_NAMES .iter() .copied() .find(|metadata_name| name == OsStr::new(metadata_name)) } fn metadata_child_of_writable_root( policy: &FileSystemSandboxPolicy, target: &Path, cwd: &Path, ) -> Option<(AbsolutePathBuf, &'static str)> { policy .resolved_entries_with_cwd(cwd) .iter() .filter(|entry| entry.access.can_write()) .filter_map(|entry| { let relative_path = target.strip_prefix(entry.path.as_path()).ok()?; let first_component = relative_path.components().next()?; let metadata_name = metadata_path_name(first_component.as_os_str())?; Some((entry.path.join(metadata_name), metadata_name)) }) .next() } fn protected_metadata_names_for_writable_root( policy: &FileSystemSandboxPolicy, root: &AbsolutePathBuf, raw_writable_roots: &[&AbsolutePathBuf], cwd: &Path, ) -> Vec { let mut protected_names = Vec::new(); for metadata_name in PROTECTED_METADATA_PATH_NAMES { let mut metadata_paths = vec![root.join(*metadata_name)]; metadata_paths.extend( raw_writable_roots .iter() .map(|raw_root| raw_root.join(*metadata_name)), ); if metadata_paths .iter() .all(|metadata_path| !policy.can_write_path_with_cwd(metadata_path.as_path(), cwd)) { protected_names.push((*metadata_name).to_string()); } } protected_names } fn protected_metadata_names_need_direct_runtime_enforcement( policy: &FileSystemSandboxPolicy, legacy_policy: &SandboxPolicy, cwd: &Path, ) -> bool { let legacy_roots = legacy_policy.get_writable_roots_with_cwd(cwd); policy .get_writable_roots_with_cwd(cwd) .into_iter() .any(|writable_root| { let Some(legacy_root) = legacy_roots .iter() .find(|candidate| candidate.root == writable_root.root) else { return !writable_root.protected_metadata_names.is_empty(); }; writable_root .protected_metadata_names .iter() .any(|metadata_name| { let metadata_path = writable_root.root.join(metadata_name); !legacy_root .read_only_subpaths .iter() .any(|subpath| subpath == &metadata_path) }) }) } fn has_explicit_write_entry_for_metadata_path( policy: &FileSystemSandboxPolicy, protected_metadata_path: &AbsolutePathBuf, target: &Path, cwd: &Path, ) -> bool { policy.resolved_entries_with_cwd(cwd).iter().any(|entry| { entry.access.can_write() && target.starts_with(entry.path.as_path()) && entry .path .as_path() .starts_with(protected_metadata_path.as_path()) }) } fn is_git_pointer_file(path: &AbsolutePathBuf) -> bool { path.as_path().is_file() && path.as_path().file_name() == Some(OsStr::new(PROTECTED_METADATA_GIT_PATH_NAME)) } fn resolve_gitdir_from_file(dot_git: &AbsolutePathBuf) -> Option { let contents = match std::fs::read_to_string(dot_git.as_path()) { Ok(contents) => contents, Err(err) => { error!( "Failed to read {path} for gitdir pointer: {err}", path = dot_git.as_path().display() ); return None; } }; let trimmed = contents.trim(); let (_, gitdir_raw) = match trimmed.split_once(':') { Some((prefix, gitdir_raw)) if prefix.trim() == "gitdir" => (prefix, gitdir_raw), Some(_) => { error!( "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: `.", path = dot_git.as_path().display() ); return None; } None => { error!( "Expected {path} to contain a gitdir pointer, but it did not match `gitdir: `.", path = dot_git.as_path().display() ); return None; } }; let gitdir_raw = gitdir_raw.trim(); if gitdir_raw.is_empty() { error!( "Expected {path} to contain a gitdir pointer, but it was empty.", path = dot_git.as_path().display() ); return None; } let base = match dot_git.as_path().parent() { Some(base) => base, None => { error!( "Unable to resolve parent directory for {path}.", path = dot_git.as_path().display() ); return None; } }; let gitdir_path = AbsolutePathBuf::resolve_path_against_base(gitdir_raw, base); if !gitdir_path.as_path().exists() { error!( "Resolved gitdir path {path} does not exist.", path = gitdir_path.as_path().display() ); return None; } Some(gitdir_path) } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[cfg(unix)] use std::fs; use std::path::Path; use tempfile::TempDir; #[cfg(unix)] const SYMLINKED_TMPDIR_TEST_ENV: &str = "CODEX_PROTOCOL_TEST_SYMLINKED_TMPDIR"; #[cfg(unix)] fn symlink_dir(original: &Path, link: &Path) -> std::io::Result<()> { std::os::unix::fs::symlink(original, link) } #[test] fn unknown_special_paths_are_ignored_by_legacy_bridge() -> std::io::Result<()> { let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::unknown( ":future_special_path", /*subpath*/ None, ), }, access: FileSystemAccessMode::Write, }, ]); let sandbox_policy = policy.to_legacy_sandbox_policy( NetworkSandboxPolicy::Restricted, Path::new("/tmp/workspace"), )?; assert_eq!( sandbox_policy, SandboxPolicy::ReadOnly { network_access: false, } ); Ok(()) } #[cfg(unix)] #[test] fn writable_roots_proactively_protect_missing_dot_codex() { let cwd = TempDir::new().expect("tempdir"); let expected_root = AbsolutePathBuf::from_absolute_path( cwd.path().canonicalize().expect("canonicalize cwd"), ) .expect("absolute canonical root"); let expected_dot_codex = expected_root.join(".codex"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }]); let writable_roots = policy.get_writable_roots_with_cwd(cwd.path()); assert_eq!(writable_roots.len(), 1); assert_eq!(writable_roots[0].root, expected_root); assert!( writable_roots[0] .read_only_subpaths .contains(&expected_dot_codex) ); } #[test] fn legacy_workspace_write_projection_preserves_symbolic_project_root() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; assert_eq!( FileSystemSandboxPolicy::from(&policy), FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(".git".into())), }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(".agents".into())), }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(".codex".into())), }, access: FileSystemAccessMode::Read, }, ]) ); } #[test] fn legacy_current_working_directory_special_path_deserializes_as_project_roots() -> serde_json::Result<()> { let value = serde_json::json!({ "kind": "current_working_directory", }); let special_path = serde_json::from_value::(value)?; assert_eq!( special_path, FileSystemSpecialPath::project_roots(/*subpath*/ None) ); assert_eq!( serde_json::to_value(&special_path)?, serde_json::json!({ "kind": "project_roots", }) ); Ok(()) } #[cfg(unix)] #[test] fn writable_roots_skip_default_dot_codex_when_explicit_user_rule_exists() { let cwd = TempDir::new().expect("tempdir"); let expected_root = AbsolutePathBuf::from_absolute_path( cwd.path().canonicalize().expect("canonicalize cwd"), ) .expect("absolute canonical root"); let explicit_dot_codex = expected_root.join(".codex"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: explicit_dot_codex.clone(), }, access: FileSystemAccessMode::Write, }, ]); let writable_roots = policy.get_writable_roots_with_cwd(cwd.path()); let workspace_root = writable_roots .iter() .find(|root| root.root == expected_root) .expect("workspace writable root"); assert!( !workspace_root .protected_metadata_names .contains(&".codex".to_string()), "explicit .codex rule should remove the metadata-name protection" ); assert!( !workspace_root .read_only_subpaths .contains(&explicit_dot_codex), "explicit .codex rule should win over the default protected carveout" ); assert!( policy.can_write_path_with_cwd( explicit_dot_codex.join("config.toml").as_path(), cwd.path() ) ); } #[test] fn filesystem_policy_blocks_protected_metadata_path_writes_by_default() { let cwd = TempDir::new().expect("tempdir"); let dot_git_config = cwd.path().join(".git").join("config"); let dot_agents_config = cwd.path().join(".agents").join("config"); let dot_codex_config = cwd.path().join(".codex").join("config.toml"); let root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd"); let file_system_policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: root }, access: FileSystemAccessMode::Write, }]); assert!(!file_system_policy.can_write_path_with_cwd(&dot_git_config, cwd.path())); assert!(!file_system_policy.can_write_path_with_cwd(&dot_agents_config, cwd.path())); assert!(!file_system_policy.can_write_path_with_cwd(&dot_codex_config, cwd.path())); let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd.path()); assert_eq!(writable_roots.len(), 1); assert_eq!( writable_roots[0].protected_metadata_names, vec![ ".git".to_string(), ".agents".to_string(), ".codex".to_string(), ] ); assert!(!writable_roots[0].is_path_writable(&dot_git_config)); assert!(!writable_roots[0].is_path_writable(&dot_agents_config)); assert!(!writable_roots[0].is_path_writable(&dot_codex_config)); } #[test] fn legacy_workspace_write_projection_accepts_relative_cwd() { let relative_cwd = Path::new("workspace"); let expected_root = AbsolutePathBuf::from_absolute_path( std::env::current_dir() .expect("current dir") .join(relative_cwd), ) .expect("absolute root"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, relative_cwd); let mut expected_entries = vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, ]; expected_entries.extend(PROTECTED_METADATA_PATH_NAMES.iter().map(|name| { FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some((*name).into())), }, access: FileSystemAccessMode::Read, } })); expected_entries.extend( default_read_only_subpaths_for_writable_root( &expected_root, /*protect_missing_dot_codex*/ true, ) .into_iter() .map(|path| FileSystemSandboxEntry { path: FileSystemPath::Path { path }, access: FileSystemAccessMode::Read, }), ); assert_eq!( file_system_policy, FileSystemSandboxPolicy::restricted(expected_entries) ); assert_eq!( forbidden_agent_metadata_write( Path::new(".git/config"), relative_cwd, &file_system_policy, ), Some(".git") ); assert!( !file_system_policy .can_write_path_with_cwd(Path::new(".codex/config.toml"), relative_cwd,) ); assert!( !file_system_policy.can_write_path_with_cwd( Path::new(".agents/skills/example/SKILL.md"), relative_cwd, ) ); } #[cfg(unix)] #[test] fn effective_runtime_roots_preserve_symlinked_paths() { let cwd = TempDir::new().expect("tempdir"); let real_root = cwd.path().join("real"); let link_root = cwd.path().join("link"); let blocked = real_root.join("blocked"); let codex_dir = real_root.join(".codex"); fs::create_dir_all(&blocked).expect("create blocked"); fs::create_dir_all(&codex_dir).expect("create .codex"); symlink_dir(&real_root, &link_root).expect("create symlinked root"); let link_root = AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); let link_blocked = link_root.join("blocked"); let expected_root = link_root.clone(); let expected_blocked = link_blocked.clone(); let expected_codex = link_root.join(".codex"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_root }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_blocked }, access: FileSystemAccessMode::None, }, ]); assert_eq!( policy.get_unreadable_roots_with_cwd(cwd.path()), vec![expected_blocked.clone()] ); let writable_roots = policy.get_writable_roots_with_cwd(cwd.path()); assert_eq!(writable_roots.len(), 1); assert_eq!(writable_roots[0].root, expected_root); assert!( writable_roots[0] .read_only_subpaths .contains(&expected_blocked) ); assert!( writable_roots[0] .read_only_subpaths .contains(&expected_codex) ); } #[cfg(unix)] #[test] fn project_roots_special_path_preserves_symlinked_root() { let cwd = TempDir::new().expect("tempdir"); let real_root = cwd.path().join("real"); let link_root = cwd.path().join("link"); let blocked = real_root.join("blocked"); let agents_dir = real_root.join(".agents"); let codex_dir = real_root.join(".codex"); fs::create_dir_all(&blocked).expect("create blocked"); fs::create_dir_all(&agents_dir).expect("create .agents"); fs::create_dir_all(&codex_dir).expect("create .codex"); symlink_dir(&real_root, &link_root).expect("create symlinked cwd"); let link_blocked = AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked"); let expected_root = AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); let expected_blocked = link_blocked.clone(); let expected_agents = expected_root.join(".agents"); let expected_codex = expected_root.join(".codex"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Minimal, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_blocked }, access: FileSystemAccessMode::None, }, ]); assert_eq!( policy.get_readable_roots_with_cwd(&link_root), vec![expected_root.clone()] ); assert_eq!( policy.get_unreadable_roots_with_cwd(&link_root), vec![expected_blocked.clone()] ); let writable_roots = policy.get_writable_roots_with_cwd(&link_root); assert_eq!(writable_roots.len(), 1); assert_eq!(writable_roots[0].root, expected_root); assert!( writable_roots[0] .read_only_subpaths .contains(&expected_blocked) ); assert!( writable_roots[0] .read_only_subpaths .contains(&expected_agents) ); assert!( writable_roots[0] .read_only_subpaths .contains(&expected_codex) ); } #[cfg(unix)] #[test] fn writable_roots_preserve_symlinked_protected_subpaths() { let cwd = TempDir::new().expect("tempdir"); let root = cwd.path().join("root"); let decoy = root.join("decoy-codex"); let dot_codex = root.join(".codex"); fs::create_dir_all(&decoy).expect("create decoy"); symlink_dir(&decoy, &dot_codex).expect("create .codex symlink"); let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root"); let expected_dot_codex = AbsolutePathBuf::from_absolute_path( root.as_path() .canonicalize() .expect("canonicalize root") .join(".codex"), ) .expect("absolute .codex symlink"); let unexpected_decoy = AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy")) .expect("absolute canonical decoy"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Path { path: root }, access: FileSystemAccessMode::Write, }]); let writable_roots = policy.get_writable_roots_with_cwd(cwd.path()); assert_eq!(writable_roots.len(), 1); assert_eq!( writable_roots[0].read_only_subpaths, vec![expected_dot_codex] ); assert!( !writable_roots[0] .read_only_subpaths .contains(&unexpected_decoy) ); } #[cfg(unix)] #[test] fn writable_roots_preserve_explicit_symlinked_carveouts_under_symlinked_roots() { let cwd = TempDir::new().expect("tempdir"); let real_root = cwd.path().join("real"); let link_root = cwd.path().join("link"); let decoy = real_root.join("decoy-private"); let linked_private = real_root.join("linked-private"); fs::create_dir_all(&decoy).expect("create decoy"); symlink_dir(&real_root, &link_root).expect("create symlinked root"); symlink_dir(&decoy, &linked_private).expect("create linked-private symlink"); let link_root = AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); let link_private = link_root.join("linked-private"); let expected_root = link_root.clone(); let expected_linked_private = link_private.clone(); let unexpected_decoy = AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy")) .expect("absolute canonical decoy"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_root }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_private }, access: FileSystemAccessMode::None, }, ]); let writable_roots = policy.get_writable_roots_with_cwd(cwd.path()); assert_eq!(writable_roots.len(), 1); assert_eq!(writable_roots[0].root, expected_root); assert_eq!( writable_roots[0].read_only_subpaths, vec![expected_linked_private] ); assert!( !writable_roots[0] .read_only_subpaths .contains(&unexpected_decoy) ); } #[cfg(unix)] #[test] fn writable_roots_preserve_explicit_symlinked_carveouts_that_escape_root() { let cwd = TempDir::new().expect("tempdir"); let real_root = cwd.path().join("real"); let link_root = cwd.path().join("link"); let decoy = cwd.path().join("outside-private"); let linked_private = real_root.join("linked-private"); fs::create_dir_all(&decoy).expect("create decoy"); fs::create_dir_all(&real_root).expect("create real root"); symlink_dir(&real_root, &link_root).expect("create symlinked root"); symlink_dir(&decoy, &linked_private).expect("create linked-private symlink"); let link_root = AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); let link_private = link_root.join("linked-private"); let expected_root = link_root.clone(); let expected_linked_private = link_private.clone(); let unexpected_decoy = AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy")) .expect("absolute canonical decoy"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_root }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_private }, access: FileSystemAccessMode::None, }, ]); let writable_roots = policy.get_writable_roots_with_cwd(cwd.path()); assert_eq!(writable_roots.len(), 1); assert_eq!(writable_roots[0].root, expected_root); assert_eq!( writable_roots[0].read_only_subpaths, vec![expected_linked_private] ); assert!( !writable_roots[0] .read_only_subpaths .contains(&unexpected_decoy) ); } #[cfg(unix)] #[test] fn writable_roots_preserve_explicit_symlinked_carveouts_that_alias_root() { let cwd = TempDir::new().expect("tempdir"); let root = cwd.path().join("root"); let alias = root.join("alias-root"); fs::create_dir_all(&root).expect("create root"); symlink_dir(&root, &alias).expect("create alias symlink"); let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root"); let alias = root.join("alias-root"); let expected_root = AbsolutePathBuf::from_absolute_path( root.as_path().canonicalize().expect("canonicalize root"), ) .expect("absolute canonical root"); let expected_alias = expected_root.join("alias-root"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Path { path: root }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: alias }, access: FileSystemAccessMode::None, }, ]); let writable_roots = policy.get_writable_roots_with_cwd(cwd.path()); assert_eq!(writable_roots.len(), 1); assert_eq!(writable_roots[0].root, expected_root); assert_eq!(writable_roots[0].read_only_subpaths, vec![expected_alias]); } #[cfg(unix)] #[test] fn tmpdir_special_path_preserves_symlinked_tmpdir() { if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() { let output = std::process::Command::new(std::env::current_exe().expect("test binary")) .env(SYMLINKED_TMPDIR_TEST_ENV, "1") .arg("--exact") .arg("permissions::tests::tmpdir_special_path_preserves_symlinked_tmpdir") .output() .expect("run tmpdir subprocess test"); assert!( output.status.success(), "tmpdir subprocess test failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); return; } let cwd = TempDir::new().expect("tempdir"); let real_tmpdir = cwd.path().join("real-tmpdir"); let link_tmpdir = cwd.path().join("link-tmpdir"); let blocked = real_tmpdir.join("blocked"); let codex_dir = real_tmpdir.join(".codex"); fs::create_dir_all(&blocked).expect("create blocked"); fs::create_dir_all(&codex_dir).expect("create .codex"); symlink_dir(&real_tmpdir, &link_tmpdir).expect("create symlinked tmpdir"); let link_blocked = AbsolutePathBuf::from_absolute_path(link_tmpdir.join("blocked")).expect("link blocked"); let expected_root = AbsolutePathBuf::from_absolute_path(&link_tmpdir).expect("absolute symlinked tmpdir"); let expected_blocked = link_blocked.clone(); let expected_codex = expected_root.join(".codex"); unsafe { std::env::set_var("TMPDIR", &link_tmpdir); } let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Tmpdir, }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: link_blocked }, access: FileSystemAccessMode::None, }, ]); assert_eq!( policy.get_unreadable_roots_with_cwd(cwd.path()), vec![expected_blocked.clone()] ); let writable_roots = policy.get_writable_roots_with_cwd(cwd.path()); assert_eq!(writable_roots.len(), 1); assert_eq!(writable_roots[0].root, expected_root); assert!( writable_roots[0] .read_only_subpaths .contains(&expected_blocked) ); assert!( writable_roots[0] .read_only_subpaths .contains(&expected_codex) ); } #[test] fn resolve_access_with_cwd_uses_most_specific_entry() { let cwd = TempDir::new().expect("tempdir"); let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); let docs_private = AbsolutePathBuf::resolve_path_against_base("docs/private", cwd.path()); let docs_private_public = AbsolutePathBuf::resolve_path_against_base("docs/private/public", cwd.path()); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs.clone() }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs_private.clone(), }, access: FileSystemAccessMode::None, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs_private_public.clone(), }, access: FileSystemAccessMode::Write, }, ]); assert_eq!( policy.resolve_access_with_cwd(cwd.path(), cwd.path()), FileSystemAccessMode::Write ); assert_eq!( policy.resolve_access_with_cwd(docs.as_path(), cwd.path()), FileSystemAccessMode::Read ); assert_eq!( policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()), FileSystemAccessMode::None ); assert_eq!( policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()), FileSystemAccessMode::Write ); } #[test] fn split_only_nested_carveouts_need_direct_runtime_enforcement() { let cwd = TempDir::new().expect("tempdir"); let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs }, access: FileSystemAccessMode::Read, }, ]); assert!( policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),) ); let legacy_workspace_write = legacy_runtime_file_system_policy_for_cwd( &SandboxPolicy::new_workspace_write_policy(), cwd.path(), ); assert!( legacy_workspace_write .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),), "metadata-name protections must stay in the direct enforcement path even when legacy concrete read-only paths match" ); } #[test] fn legacy_projection_runtime_enforcement_ignores_entry_order() { let cwd = TempDir::new().expect("tempdir"); let legacy_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; let legacy_order = legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path()); let mut reordered_entries = legacy_order.entries.clone(); reordered_entries.reverse(); let reordered = FileSystemSandboxPolicy::restricted(reordered_entries); assert!( legacy_order.is_semantically_equivalent_to(&reordered, cwd.path()), "entry order should not affect filesystem semantics" ); assert_eq!( legacy_order .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()), reordered .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()), "entry order should not affect direct-enforcement classification" ); } #[test] fn missing_symbolic_metadata_carveouts_need_direct_runtime_enforcement() { let cwd = TempDir::new().expect("tempdir"); let legacy_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; let profile_projection = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&legacy_policy, cwd.path()); assert!( profile_projection .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()), "symbolic .git/.agents carveouts protect missing paths that legacy sandboxes cannot represent" ); let legacy_runtime_projection = legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd.path()); assert!( legacy_runtime_projection .needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path()), "metadata-name protections are outside the legacy SandboxPolicy writable-root contract" ); } #[test] fn root_write_with_read_only_child_is_not_full_disk_write() { let cwd = TempDir::new().expect("tempdir"); let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs.clone() }, access: FileSystemAccessMode::Read, }, ]); assert!(!policy.has_full_disk_write_access()); assert_eq!( policy.resolve_access_with_cwd(docs.as_path(), cwd.path()), FileSystemAccessMode::Read ); assert!( policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),) ); assert!( policy .to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd.path()) .is_err() ); } #[test] fn root_deny_does_not_materialize_as_unreadable_root() { let cwd = TempDir::new().expect("tempdir"); let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); let expected_docs = AbsolutePathBuf::from_absolute_path( canonicalize_preserving_symlinks(cwd.path()) .expect("canonicalize cwd") .join("docs"), ) .expect("canonical docs"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::None, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs.clone() }, access: FileSystemAccessMode::Read, }, ]); assert_eq!( policy.resolve_access_with_cwd(docs.as_path(), cwd.path()), FileSystemAccessMode::Read ); assert_eq!( policy.get_readable_roots_with_cwd(cwd.path()), vec![expected_docs] ); assert!(policy.get_unreadable_roots_with_cwd(cwd.path()).is_empty()); } #[test] fn duplicate_root_deny_prevents_full_disk_write_access() { let cwd = TempDir::new().expect("tempdir"); let root = AbsolutePathBuf::from_absolute_path(cwd.path()) .map(|cwd| absolute_root_path_for_cwd(&cwd)) .expect("resolve filesystem root"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::None, }, ]); assert!(!policy.has_full_disk_write_access()); assert_eq!( policy.resolve_access_with_cwd(root.as_path(), cwd.path()), FileSystemAccessMode::None ); } #[test] fn same_specificity_write_override_keeps_full_disk_write_access() { let cwd = TempDir::new().expect("tempdir"); let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs.clone() }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: docs.clone() }, access: FileSystemAccessMode::Write, }, ]); assert!(policy.has_full_disk_write_access()); assert_eq!( policy.resolve_access_with_cwd(docs.as_path(), cwd.path()), FileSystemAccessMode::Write ); } #[test] fn with_additional_readable_roots_skips_existing_effective_access() { let cwd = TempDir::new().expect("tempdir"); let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Read, }]); let actual = policy .clone() .with_additional_readable_roots(cwd.path(), std::slice::from_ref(&cwd_root)); assert_eq!(actual, policy); } #[test] fn with_additional_writable_roots_skips_existing_effective_access() { let cwd = TempDir::new().expect("tempdir"); let cwd_root = AbsolutePathBuf::from_absolute_path(cwd.path()).expect("absolute cwd"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }]); let actual = policy .clone() .with_additional_writable_roots(cwd.path(), std::slice::from_ref(&cwd_root)); assert_eq!(actual, policy); } #[test] fn with_additional_writable_roots_adds_new_root() { let temp_dir = TempDir::new().expect("tempdir"); let cwd = temp_dir.path().join("workspace"); let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra")) .expect("resolve extra root"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }]); let actual = policy.with_additional_writable_roots(&cwd, std::slice::from_ref(&extra)); assert_eq!( actual, FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: extra }, access: FileSystemAccessMode::Write, }, ]) ); } #[test] fn with_additional_legacy_workspace_writable_roots_protects_metadata() { let temp_dir = TempDir::new().expect("tempdir"); let extra = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("extra")) .expect("resolve extra root"); std::fs::create_dir_all(extra.join(".git")).expect("create .git dir"); let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }]); let actual = policy.with_additional_legacy_workspace_writable_roots(std::slice::from_ref(&extra)); assert_eq!( actual, FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: extra.clone() }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: extra.join(".git") }, access: FileSystemAccessMode::Read, }, ]) ); } #[test] fn file_system_access_mode_orders_by_conflict_precedence() { 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" ); } #[test] fn preserving_deny_entries_keeps_unrestricted_policy_enforceable() { let deny_entry = unreadable_glob_entry("/tmp/project/**/*.env".to_string()); let mut existing = FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); existing.glob_scan_max_depth = Some(2); let mut replacement = FileSystemSandboxPolicy::unrestricted(); replacement.preserve_deny_read_restrictions_from(&existing); let mut expected = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }, deny_entry, ]); expected.glob_scan_max_depth = Some(2); assert_eq!(replacement, expected); } 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())); } }