diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 11959d0c02..b1ae513a60 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -32,6 +32,7 @@ use std::process::Command; use crate::linux_run_main::synthetic_mount_registry_root; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; +use codex_protocol::permissions::FileSystemPolicyProjection; use codex_protocol::permissions::is_protected_metadata_name; use codex_protocol::protocol::FileSystemAccessMode; use codex_protocol::protocol::FileSystemPath; @@ -240,11 +241,13 @@ pub(crate) fn create_bwrap_command_args( command_cwd: &Path, options: BwrapOptions, ) -> Result { - let unreadable_globs = - file_system_sandbox_policy.get_unreadable_globs_with_cwd(sandbox_policy_cwd); // Full disk write normally skips bwrap, but unreadable glob patterns still // need concrete bwrap masks for the matches expanded below. - if file_system_sandbox_policy.has_full_disk_write_access() && unreadable_globs.is_empty() { + if file_system_sandbox_policy.has_full_disk_write_access() + && file_system_sandbox_policy + .get_unreadable_globs_with_cwd(sandbox_policy_cwd) + .is_empty() + { return if options.network_mode == BwrapNetworkMode::FullAccess { Ok(BwrapArgs { args: command, @@ -418,18 +421,16 @@ fn create_filesystem_args( .mask_wslg_distro .then_some(Path::new(WSLG_DISTRO_ROOT)), )?; - let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd); + let resolved = file_system_sandbox_policy + .resolve_for_enforcement(cwd, FileSystemPolicyProjection::LinuxMounts)?; // Bubblewrap requires bind mount targets to exist. Skip missing writable // roots so mixed-platform configs can keep harmless paths for other // environments without breaking Linux command startup. - let mut writable_roots = file_system_sandbox_policy - .get_writable_roots_with_cwd_inheriting_root_metadata(cwd) - .into_iter() - .filter(|writable_root| writable_root.root.as_path().exists()) - .collect::>(); + let mut writable_roots = resolved.writable_roots; + writable_roots.retain(|writable_root| writable_root.root.as_path().exists()); if writable_roots.is_empty() - && file_system_sandbox_policy.has_full_disk_write_access() - && !unreadable_globs.is_empty() + && resolved.has_full_disk_write_access + && !resolved.unreadable_globs.is_empty() { writable_roots.push(WritableRoot { root: AbsolutePathBuf::from_absolute_path("/")?, @@ -470,8 +471,8 @@ fn create_filesystem_args( (!resolved.as_path().exists()).then(|| resolved.into_path_buf()) }) .collect(); - let mut unreadable_roots = file_system_sandbox_policy - .get_unreadable_roots_with_cwd_preserving_symlinks(cwd) + let mut unreadable_roots = resolved + .unreadable_roots .into_iter() .map(AbsolutePathBuf::into_path_buf) .collect::>(); @@ -480,7 +481,7 @@ fn create_filesystem_args( // core tool helpers still evaluate the original patterns directly at read time. unreadable_roots.extend( expand_unreadable_globs_with_ripgrep( - &unreadable_globs, + &resolved.unreadable_globs, cwd, || { find_pre_sandbox_executable_in_path( @@ -489,9 +490,7 @@ fn create_filesystem_args( cwd, ) }, - options - .glob_scan_max_depth - .or(file_system_sandbox_policy.glob_scan_max_depth), + options.glob_scan_max_depth.or(resolved.glob_scan_max_depth), |_| {}, )? .into_iter() @@ -500,7 +499,7 @@ fn create_filesystem_args( unreadable_roots.sort(); unreadable_roots.dedup(); - let mut args = if file_system_sandbox_policy.has_full_disk_read_access() { + let mut args = if resolved.has_full_disk_read_access { // Read-only root, then mount a minimal device tree. // In bubblewrap (`bubblewrap.c`, `SETUP_MOUNT_DEV`), `--dev /dev` // creates the standard minimal nodes: null, zero, full, random, @@ -523,12 +522,12 @@ fn create_filesystem_args( "/dev".to_string(), ]; - let mut readable_roots: BTreeSet = file_system_sandbox_policy - .get_readable_roots_with_cwd(cwd) + let mut readable_roots: BTreeSet = resolved + .readable_roots .into_iter() .map(PathBuf::from) .collect(); - if file_system_sandbox_policy.include_platform_defaults() { + if resolved.include_platform_defaults { readable_roots.extend( LINUX_PLATFORM_DEFAULT_READ_ROOTS .iter() diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 3ba283b373..2a1982d6bc 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -24,7 +24,7 @@ use crate::launcher::initialize_bwrap_launcher; use crate::launcher::preferred_bwrap_supports_argv0; use crate::proxy_routing::activate_proxy_routes_in_netns; use crate::proxy_routing::prepare_host_proxy_route_spec; -use crate::seccomp::apply_permission_profile_to_current_thread; +use crate::seccomp::apply_sandbox_policies_to_current_thread; use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::PermissionProfile; @@ -202,8 +202,9 @@ pub fn run_main() -> ! { } } let proxy_routing_active = allow_network_for_proxy; - if let Err(e) = apply_permission_profile_to_current_thread( - &permission_profile, + if let Err(e) = apply_sandbox_policies_to_current_thread( + &file_system_sandbox_policy, + network_sandbox_policy, &sandbox_policy_cwd, /*apply_landlock_fs*/ false, managed_network.as_ref(), @@ -248,8 +249,9 @@ pub fn run_main() -> ! { } if file_system_sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy { - if let Err(e) = apply_permission_profile_to_current_thread( - &permission_profile, + if let Err(e) = apply_sandbox_policies_to_current_thread( + &file_system_sandbox_policy, + network_sandbox_policy, &sandbox_policy_cwd, /*apply_landlock_fs*/ false, managed_network.as_ref(), @@ -316,8 +318,9 @@ pub fn run_main() -> ! { } // Legacy pipeline: apply in-process restrictions without bubblewrap. - if let Err(e) = apply_permission_profile_to_current_thread( - &permission_profile, + if let Err(e) = apply_sandbox_policies_to_current_thread( + &file_system_sandbox_policy, + network_sandbox_policy, &sandbox_policy_cwd, /*apply_landlock_fs*/ true, managed_network.as_ref(), diff --git a/codex-rs/linux-sandbox/src/seccomp.rs b/codex-rs/linux-sandbox/src/seccomp.rs index 67cc724209..7c9f588b1b 100644 --- a/codex-rs/linux-sandbox/src/seccomp.rs +++ b/codex-rs/linux-sandbox/src/seccomp.rs @@ -10,7 +10,7 @@ use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::error::SandboxErr; -use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -42,15 +42,14 @@ use seccompiler::apply_filter; /// /// Filesystem restrictions are normally handled by bubblewrap. The legacy /// Landlock implementation is only selected by `apply_landlock_fs`. -pub(crate) fn apply_permission_profile_to_current_thread( - permission_profile: &PermissionProfile, +pub(crate) fn apply_sandbox_policies_to_current_thread( + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, cwd: &Path, apply_landlock_fs: bool, managed_network: Option<&ManagedNetworkSandboxContext>, proxy_routing_active: bool, ) -> Result<()> { - let (file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); let network_seccomp_mode = network_seccomp_mode( network_sandbox_policy, managed_network.is_some(), diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index f8554fe24d..f23c47f061 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -25,6 +25,7 @@ use crate::protocol::SandboxPolicy; use crate::protocol::WritableRoot; mod deny_read_validator; +mod enforcement; mod local_aliases; mod target; mod windows_glob; @@ -33,6 +34,8 @@ use local_aliases::LocalPolicyContext; pub use deny_read_validator::DenyReadValidator; pub use deny_read_validator::DenyReadViolation; +pub use enforcement::FileSystemPolicyProjection; +pub use enforcement::ResolvedFileSystemPermissions; pub use local_aliases::LocalFileSystemPolicyMatcher; pub use windows_glob::WindowsDenyReadGlobScan; pub use windows_glob::windows_deny_read_glob_scan; @@ -244,27 +247,6 @@ pub struct FileSystemSandboxPolicy { pub entries: Vec, } -#[derive(Clone, Copy)] -enum WritableRootPathResolution { - Effective, - PreserveMutableComponents, -} - -#[derive(Clone, Copy)] -enum RootMetadataWriteMounts { - Separate, - InheritWritableRoot, -} - -impl WritableRootPathResolution { - fn resolve(self, path: AbsolutePathBuf) -> AbsolutePathBuf { - match self { - Self::Effective => normalize_effective_absolute_path(path), - Self::PreserveMutableComponents => path.normalize_system_aliases().unwrap_or(path), - } - } -} - /// Serialized filesystem policy used at legacy string-based seams. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[schemars(rename = "FileSystemSandboxPolicy")] @@ -301,17 +283,6 @@ struct PreparedFileSystemEntry<'a> { effective_path: AbsolutePathBuf, } -#[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 { native_cwd: Option, @@ -690,6 +661,7 @@ impl FileSystemSandboxPolicy { self.has_denied_read_restrictions_for_convention(Some(PathConvention::native())) } + /// Returns whether this policy denies reads on the selected executor. fn has_denied_read_restrictions_for_convention( &self, convention: Option, @@ -1440,15 +1412,21 @@ impl FileSystemSandboxPolicy { return true; } - self.semantic_signature(cwd) - != legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd) - .semantic_signature(cwd) + !self.is_semantically_equivalent_to( + &legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd), + 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) + let Ok(signature) = self.semantic_signature(cwd) else { + return false; + }; + other + .semantic_signature(cwd) + .is_ok_and(|other| signature == other) } /// Returns the explicit readable roots resolved against the provided cwd. @@ -1463,16 +1441,23 @@ impl FileSystemSandboxPolicy { return Vec::new(); }; + Self::readable_roots(&self.resolved_entries_with_cwd(cwd), &matching) + } + + fn readable_roots( + entries: &[ResolvedFileSystemEntry], + matching: &LocalFileSystemPolicyMatcher<'_>, + ) -> Vec { dedup_absolute_paths( - self.resolved_entries_with_cwd(cwd) - .into_iter() + entries + .iter() .filter(|entry| entry.access.can_read()) .filter(|entry| { matching .resolve_access(&PathUri::from_abs_path(&entry.path)) .can_read() }) - .map(|entry| entry.path) + .map(|entry| entry.path.clone()) .collect(), /*normalize_effective_paths*/ true, ) @@ -1481,11 +1466,7 @@ impl FileSystemSandboxPolicy { /// 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 { - self.get_writable_roots_with_cwd_impl( - cwd, - WritableRootPathResolution::Effective, - RootMetadataWriteMounts::Separate, - ) + self.get_writable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::Native) } /// Omits redundant root-metadata mounts when the filesystem root is writable. @@ -1497,11 +1478,7 @@ impl FileSystemSandboxPolicy { &self, cwd: &Path, ) -> Vec { - self.get_writable_roots_with_cwd_impl( - cwd, - WritableRootPathResolution::Effective, - RootMetadataWriteMounts::InheritWritableRoot, - ) + self.get_writable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::LinuxMounts) } /// Reports configured writable roots for diagnostics without inspecting the filesystem. @@ -1539,18 +1516,13 @@ impl FileSystemSandboxPolicy { &self, cwd: &Path, ) -> Vec { - self.get_writable_roots_with_cwd_impl( - cwd, - WritableRootPathResolution::PreserveMutableComponents, - RootMetadataWriteMounts::Separate, - ) + self.get_writable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::PreserveMutableRoots) } fn get_writable_roots_with_cwd_impl( &self, cwd: &Path, - path_resolution: WritableRootPathResolution, - root_metadata_mounts: RootMetadataWriteMounts, + projection: FileSystemPolicyProjection, ) -> Vec { if self.has_full_disk_write_access() { return Vec::new(); @@ -1569,6 +1541,15 @@ impl FileSystemSandboxPolicy { let Ok(matching) = self.prepare_local_matching(&local_context.as_context()) else { return Vec::new(); }; + Self::writable_roots(cwd, &resolved_entries, &matching, projection) + } + + fn writable_roots( + cwd: &Path, + resolved_entries: &[ResolvedFileSystemEntry], + matching: &LocalFileSystemPolicyMatcher<'_>, + projection: FileSystemPolicyProjection, + ) -> Vec { // Resolve precedence and filesystem aliases once per entry, rather than repeating // that work for every writable root while collecting its read-only carveouts. let effective_entries: Vec<&ResolvedFileSystemEntry> = resolved_entries @@ -1588,23 +1569,25 @@ impl FileSystemSandboxPolicy { } // Include resolved gitdirs in the entries used to carve out broader grants. // Seatbelt grants are independent, and later bubblewrap binds cover earlier mounts. - let include_resolved_gitdirs = match path_resolution { - WritableRootPathResolution::Effective => cfg!(target_os = "linux"), - WritableRootPathResolution::PreserveMutableComponents => cfg!(target_os = "macos"), + let include_resolved_gitdirs = match projection { + FileSystemPolicyProjection::Native | FileSystemPolicyProjection::LinuxMounts => { + cfg!(target_os = "linux") + } + FileSystemPolicyProjection::PreserveMutableRoots => cfg!(target_os = "macos"), }; let resolved_gitdir_entries: Vec = if include_resolved_gitdirs { effective_entries .iter() .filter(|entry| entry.access.can_write()) .filter_map(|entry| { - let dot_git = path_resolution - .resolve(entry.path.clone()) + let dot_git = projection + .resolve_writable_path(entry.path.clone()) .join(PROTECTED_METADATA_GIT_PATH_NAME); is_git_pointer_file(&dot_git) .then(|| resolve_gitdir_from_file(&dot_git)) .flatten() }) - .filter(|path| !has_explicit_resolved_path_entry(&resolved_entries, path)) + .filter(|path| !has_explicit_resolved_path_entry(resolved_entries, path)) .map(|path| ResolvedFileSystemEntry { path, access: FileSystemAccessMode::Read, @@ -1617,7 +1600,7 @@ impl FileSystemSandboxPolicy { .into_iter() .map(|entry| PreparedFileSystemEntry { entry, - effective_path: path_resolution.resolve(entry.path.clone()), + effective_path: projection.resolve_writable_path(entry.path.clone()), }) .chain(resolved_gitdir_entries.iter().map(|entry| { PreparedFileSystemEntry { @@ -1629,18 +1612,14 @@ impl FileSystemSandboxPolicy { .canonicalize() .ok() .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) - .unwrap_or_else(|| { - WritableRootPathResolution::Effective.resolve(entry.path.clone()) - }), + .unwrap_or_else(|| normalize_effective_absolute_path(entry.path.clone())), } })) .collect(); - let inherits_root_metadata = matches!( - root_metadata_mounts, - RootMetadataWriteMounts::InheritWritableRoot - ) && prepared_entries.iter().any(|entry| { - entry.entry.access.can_write() && entry.entry.path.as_path().parent().is_none() - }); + let inherits_root_metadata = projection == FileSystemPolicyProjection::LinuxMounts + && prepared_entries.iter().any(|entry| { + entry.entry.access.can_write() && entry.entry.path.as_path().parent().is_none() + }); let writable_entries: Vec<&PreparedFileSystemEntry<'_>> = prepared_entries .iter() .filter(|entry| entry.entry.access.can_write()) @@ -1654,7 +1633,7 @@ impl FileSystemSandboxPolicy { let effective_cwd = AbsolutePathBuf::from_absolute_path(cwd) .ok() - .map(|cwd| path_resolution.resolve(cwd)); + .map(|cwd| projection.resolve_writable_path(cwd)); dedup_absolute_paths( writable_entries @@ -1679,12 +1658,12 @@ impl FileSystemSandboxPolicy { .map(|entry| &entry.entry.path) .collect(); let protected_metadata_names = - protected_metadata_names_for_writable_root(&matching, &root, &raw_writable_roots); + protected_metadata_names_for_writable_root(matching, &root, &raw_writable_roots); let protect_missing_dot_codex = effective_cwd.as_ref() == Some(&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)) + .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 @@ -1761,37 +1740,7 @@ impl FileSystemSandboxPolicy { /// 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)); - let local_context = LocalPolicyContext::new(cwd); - let matching = local_context - .as_ref() - .and_then(|context| self.prepare_local_matching(&context.as_context()).ok()); - - dedup_absolute_paths( - self.resolved_entries_with_cwd(cwd) - .iter() - .filter(|entry| entry.access == FileSystemAccessMode::Deny) - .filter(|entry| { - !matching.as_ref().is_some_and(|matching| { - matching - .resolve_access(&PathUri::from_abs_path(&entry.path)) - .can_read() - }) - }) - // 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, - ) + self.get_unreadable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::Native) } /// Includes literal denies alongside their resolved targets so Linux can reject a denial @@ -1800,16 +1749,62 @@ impl FileSystemSandboxPolicy { &self, cwd: &Path, ) -> Vec { - let mut roots = self.get_unreadable_roots_with_cwd(cwd); - if matches!(self.kind, FileSystemSandboxKind::Restricted) { - for entry in self.resolved_entries_with_cwd(cwd) { - if entry.access == FileSystemAccessMode::Deny - && entry.path.as_path().parent().is_some() - && !self.can_read_local_path_with_cwd(entry.path.as_path(), cwd) - && !roots.contains(&entry.path) - { - roots.push(entry.path); - } + self.get_unreadable_roots_with_cwd_impl(cwd, FileSystemPolicyProjection::LinuxMounts) + } + + fn get_unreadable_roots_with_cwd_impl( + &self, + cwd: &Path, + projection: FileSystemPolicyProjection, + ) -> Vec { + if !matches!(self.kind, FileSystemSandboxKind::Restricted) { + return Vec::new(); + } + + let local_context = LocalPolicyContext::new(cwd); + let matching = local_context + .as_ref() + .and_then(|context| self.prepare_local_matching(&context.as_context()).ok()); + Self::unreadable_roots( + cwd, + &self.resolved_entries_with_cwd(cwd), + matching.as_ref(), + projection, + ) + } + + fn unreadable_roots( + cwd: &Path, + entries: &[ResolvedFileSystemEntry], + matching: Option<&LocalFileSystemPolicyMatcher<'_>>, + projection: FileSystemPolicyProjection, + ) -> Vec { + let root = AbsolutePathBuf::from_absolute_path(cwd) + .ok() + .map(|cwd| absolute_root_path_for_cwd(&cwd)); + let denied: Vec<_> = entries + .iter() + .filter(|entry| entry.access == FileSystemAccessMode::Deny) + .filter(|entry| { + !matching.is_some_and(|matching| { + matching + .resolve_access(&PathUri::from_abs_path(&entry.path)) + .can_read() + }) + }) + // A root denial is the default, not a mask that should hide narrower readable grants. + .filter(|entry| root.as_ref() != Some(&entry.path)) + .map(|entry| entry.path.clone()) + .collect(); + if projection != FileSystemPolicyProjection::LinuxMounts { + return dedup_absolute_paths(denied, /*normalize_effective_paths*/ true); + } + // A mount must protect both the configured name and the target it resolves to now. + let mut roots = + dedup_absolute_paths(denied.clone(), /*normalize_effective_paths*/ true); + for path in denied { + if path.as_path().parent().is_some() && !roots.contains(&path) { + roots.push(path); } } roots @@ -2028,16 +2023,16 @@ impl FileSystemSandboxPolicy { .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), - } + fn semantic_signature(&self, cwd: &Path) -> io::Result { + let resolved = self.resolve_for_enforcement(cwd, FileSystemPolicyProjection::Native)?; + Ok(ResolvedFileSystemPermissions { + readable_roots: sorted_absolute_paths(resolved.readable_roots), + writable_roots: sorted_writable_roots(resolved.writable_roots), + unreadable_roots: sorted_absolute_paths(resolved.unreadable_roots), + // Compatibility compares access rules, independently of the backend's scan limit. + glob_scan_max_depth: None, + ..resolved + }) } } diff --git a/codex-rs/protocol/src/permissions/enforcement.rs b/codex-rs/protocol/src/permissions/enforcement.rs new file mode 100644 index 0000000000..5c7a89c04f --- /dev/null +++ b/codex-rs/protocol/src/permissions/enforcement.rs @@ -0,0 +1,91 @@ +//! Projects filesystem permissions for each sandbox, preserving mutable paths where the backend binds them. + +use super::FileSystemSandboxPolicy; +use super::LocalPolicyContext; +use super::normalize_effective_absolute_path; +use crate::protocol::WritableRoot; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::io; +use std::path::Path; + +/// How a sandbox binds filesystem roots after access and precedence are resolved. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FileSystemPolicyProjection { + /// Resolve roots and carveouts to their effective native paths. + Native, + /// Normalize trusted system aliases, leaving mutable components for the sandbox to bind. + PreserveMutableRoots, + /// Inherit root metadata writes from the root mount and retain literal paths for read denials. + LinuxMounts, +} + +impl FileSystemPolicyProjection { + pub(super) fn resolve_writable_path(self, path: AbsolutePathBuf) -> AbsolutePathBuf { + match self { + Self::Native | Self::LinuxMounts => normalize_effective_absolute_path(path), + Self::PreserveMutableRoots => path.normalize_system_aliases().unwrap_or(path), + } + } +} + +/// Filesystem permissions resolved on the host that will enforce them. +/// +/// Roots retain policy order. Deny globs remain patterns for the sandbox to enforce or expand. +/// Resolving these paths does not pin their targets; the sandbox still binds them at launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedFileSystemPermissions { + pub has_full_disk_read_access: bool, + pub has_full_disk_write_access: bool, + pub include_platform_defaults: bool, + pub readable_roots: Vec, + pub writable_roots: Vec, + pub unreadable_roots: Vec, + pub unreadable_globs: Vec, + pub glob_scan_max_depth: Option, +} + +impl FileSystemSandboxPolicy { + /// Resolve the roots for one enforcement operation after binding executor-owned paths. + pub fn resolve_for_enforcement( + &self, + cwd: &Path, + projection: FileSystemPolicyProjection, + ) -> io::Result { + let mut resolved = ResolvedFileSystemPermissions { + 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: Vec::new(), + writable_roots: Vec::new(), + unreadable_roots: Vec::new(), + unreadable_globs: self.get_unreadable_globs_with_cwd(cwd), + glob_scan_max_depth: self.glob_scan_max_depth, + }; + if resolved.has_full_disk_read_access && resolved.has_full_disk_write_access { + return Ok(resolved); + } + let entries = self.resolved_entries_with_cwd(cwd); + if entries.is_empty() { + return Ok(resolved); + } + + let context = LocalPolicyContext::new(cwd); + let matching = context + .as_ref() + .and_then(|context| self.prepare_local_matching(&context.as_context()).ok()); + if let Some(matching) = &matching { + if !resolved.has_full_disk_read_access { + resolved.readable_roots = Self::readable_roots(&entries, matching); + } + if !resolved.has_full_disk_write_access + && entries.iter().any(|entry| entry.access.can_write()) + { + resolved.writable_roots = Self::writable_roots(cwd, &entries, matching, projection); + } + } + // If the matcher couldn't be prepared, withhold grants but keep explicit denials. + resolved.unreadable_roots = + Self::unreadable_roots(cwd, &entries, matching.as_ref(), projection); + Ok(resolved) + } +} diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index 22dcf91632..265849e53d 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -117,7 +117,7 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli permissions: &permissions, sandbox: SandboxType::None, enforce_managed_network: false, - environment_id: None, + environment_id: Some("remote"), network: None, sandbox_policy_cwd: &cwd_uri, sandbox_exe: None, @@ -128,6 +128,10 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli assert_eq!(exec_request.cwd, cwd_uri); assert_eq!(exec_request.sandbox_policy_cwd, cwd_uri); + assert_eq!( + exec_request.network_environment_id.as_deref(), + Some("remote") + ); assert_eq!( exec_request.permission_profile.file_system_sandbox_policy(), FileSystemSandboxPolicy::unrestricted() diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs index 0f8701a167..5a33bcb730 100644 --- a/codex-rs/sandboxing/src/seatbelt.rs +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -3,11 +3,10 @@ use codex_network_proxy::NetworkProxy; use codex_network_proxy::PROXY_URL_ENV_KEYS; use codex_network_proxy::has_proxy_url_env_vars; use codex_network_proxy::proxy_url_env_value; +use codex_protocol::permissions::FileSystemPolicyProjection; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::permissions::PROTECTED_METADATA_PATH_NAMES; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::WritableRoot; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -603,40 +602,18 @@ fn seatbelt_protected_metadata_name_regex(root: &AbsolutePathBuf, name: &str) -> } } -fn protected_metadata_names_for_writable_root( - file_system_sandbox_policy: &FileSystemSandboxPolicy, - writable_root: &WritableRoot, - cwd: &Path, -) -> Vec { - let mut names = writable_root.protected_metadata_names.clone(); - for name in PROTECTED_METADATA_PATH_NAMES { - if names.iter().any(|existing| existing == name) { - continue; - } - let path = writable_root.root.join(*name); - if !file_system_sandbox_policy.can_write_local_path_with_cwd(path.as_path(), cwd) { - names.push((*name).to_string()); - } - } - names -} - -fn build_seatbelt_unreadable_glob_policy( - file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &Path, -) -> String { +fn build_seatbelt_unreadable_glob_policy(unreadable_globs: &[String]) -> String { // Seatbelt does not understand the filesystem policy's glob syntax directly. // Convert each unreadable pattern into anchored read/write denies. Also deny // renaming directories that could contain matches so the protected files // cannot be moved beyond the path where the glob applies. - let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd); if unreadable_globs.is_empty() { return String::new(); } let mut policy_components = Vec::new(); for pattern in unreadable_globs { let mut patterns = BTreeSet::from([pattern.clone()]); - if let Some(pattern) = canonicalize_glob_static_prefix_for_sandbox(&pattern) { + if let Some(pattern) = canonicalize_glob_static_prefix_for_sandbox(pattern) { patterns.insert(pattern); } for pattern in patterns { @@ -892,18 +869,23 @@ pub(crate) fn create_seatbelt_command_args_with_profile( extra_allow_unix_sockets, } = args; - let unreadable_roots = - file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd); - let mut writable_roots = file_system_sandbox_policy - .get_writable_roots_with_cwd_preserving_mutable_paths(sandbox_policy_cwd); - let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); - let scratch_reads = if include_platform_defaults && profile == MacosSeatbeltProfile::Process { + let mut resolved_filesystem_permissions = file_system_sandbox_policy + .resolve_for_enforcement( + sandbox_policy_cwd, + FileSystemPolicyProjection::PreserveMutableRoots, + ) + .map_err(|err| SeatbeltPreparationError::FileSystem(err.to_string()))?; + let include_process_defaults = resolved_filesystem_permissions.include_platform_defaults + && profile == MacosSeatbeltProfile::Process; + let scratch_reads = if include_process_defaults { let (reads, writes) = scratch::scratch_access_roots( file_system_sandbox_policy, sandbox_policy_cwd, - &writable_roots, + &resolved_filesystem_permissions, )?; - writable_roots.extend(writes); + resolved_filesystem_permissions + .writable_roots + .extend(writes); reads } else { Vec::new() @@ -915,7 +897,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile( // Protect ancestors of read-only paths so renaming a writable directory // cannot move its descendants outside their policy carveouts. let mut protected_ancestors = BTreeSet::new(); - for writable_root in &writable_roots { + for writable_root in &resolved_filesystem_permissions.writable_roots { let root = normalize_path_for_sandbox(writable_root.root.as_path()) .unwrap_or_else(|| writable_root.root.clone()); for path in &writable_root.read_only_subpaths { @@ -940,8 +922,8 @@ pub(crate) fn create_seatbelt_command_args_with_profile( .map(|(index, path)| (format!("PROTECTED_ANCESTOR_{index}"), path.into_path_buf())) .collect(); let (file_write_policy, file_write_dir_params) = - if file_system_sandbox_policy.has_full_disk_write_access() { - if unreadable_roots.is_empty() { + if resolved_filesystem_permissions.has_full_disk_write_access { + if resolved_filesystem_permissions.unreadable_roots.is_empty() { // Allegedly, this is more permissive than `(allow file-write*)`. ( r#"(allow file-write* (regex #"^/"))"#.to_string(), @@ -952,7 +934,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile( SeatbeltAccessKind::Write, vec![SeatbeltAccessRoot { root: root_absolute_path(), - excluded_subpaths: unreadable_roots.clone(), + excluded_subpaths: resolved_filesystem_permissions.unreadable_roots.clone(), protected_metadata_names: Vec::new(), }], /*allowed_symlinked_codex_home*/ None, @@ -961,14 +943,11 @@ pub(crate) fn create_seatbelt_command_args_with_profile( } else { build_seatbelt_access_policy( SeatbeltAccessKind::Write, - writable_roots + resolved_filesystem_permissions + .writable_roots .into_iter() .map(|root| SeatbeltAccessRoot { - protected_metadata_names: protected_metadata_names_for_writable_root( - file_system_sandbox_policy, - &root, - sandbox_policy_cwd, - ), + protected_metadata_names: root.protected_metadata_names, root: root.root, excluded_subpaths: root.read_only_subpaths, }) @@ -978,8 +957,8 @@ pub(crate) fn create_seatbelt_command_args_with_profile( }; let (file_read_policy, file_read_dir_params) = - if file_system_sandbox_policy.has_full_disk_read_access() { - if unreadable_roots.is_empty() { + if resolved_filesystem_permissions.has_full_disk_read_access { + if resolved_filesystem_permissions.unreadable_roots.is_empty() { ( "; allow read-only file operations\n(allow file-read*)".to_string(), Vec::new(), @@ -989,7 +968,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile( SeatbeltAccessKind::Read, vec![SeatbeltAccessRoot { root: root_absolute_path(), - excluded_subpaths: unreadable_roots, + excluded_subpaths: resolved_filesystem_permissions.unreadable_roots, protected_metadata_names: Vec::new(), }], /*allowed_symlinked_codex_home*/ None, @@ -1002,11 +981,12 @@ pub(crate) fn create_seatbelt_command_args_with_profile( } else { let (policy, params) = build_seatbelt_access_policy( SeatbeltAccessKind::Read, - file_system_sandbox_policy - .get_readable_roots_with_cwd(sandbox_policy_cwd) + resolved_filesystem_permissions + .readable_roots .into_iter() .map(|root| SeatbeltAccessRoot { - excluded_subpaths: unreadable_roots + excluded_subpaths: resolved_filesystem_permissions + .unreadable_roots .iter() .filter(|path| path.as_path().starts_with(root.as_path())) .cloned() @@ -1039,25 +1019,25 @@ pub(crate) fn create_seatbelt_command_args_with_profile( dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); let deny_read_policy = - build_seatbelt_unreadable_glob_policy(file_system_sandbox_policy, sandbox_policy_cwd); + build_seatbelt_unreadable_glob_policy(&resolved_filesystem_permissions.unreadable_globs); let mut policy_sections = vec![ MACOS_SEATBELT_BASE_POLICY.to_string(), file_read_policy, file_write_policy, network_policy, ]; - if file_system_sandbox_policy.has_full_disk_read_access() { + if resolved_filesystem_permissions.has_full_disk_read_access { policy_sections.push(MACOS_SEATBELT_PREFERENCES_POLICY.to_string()); } - if include_platform_defaults { + if resolved_filesystem_permissions.include_platform_defaults { policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); - if profile == MacosSeatbeltProfile::Process { - policy_sections.push("(allow file-read* (subpath \"/Applications\"))".to_string()); - } + } + if include_process_defaults { + policy_sections.push("(allow file-read* (subpath \"/Applications\"))".to_string()); } // Network grants and Unix-socket allowlists must never reopen the // privileged app-server RPC transport to filesystem-restricted commands. - if !file_system_sandbox_policy.has_full_disk_write_access() { + if !resolved_filesystem_permissions.has_full_disk_write_access { let directory = codex_uds::shared_daemon_socket_directory() .map_err(|error| SeatbeltPreparationError::FileSystem(error.to_string()))?; policy_sections.push(daemon::protection_policy(&directory)?); @@ -1078,7 +1058,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile( // These fcntls mutate files through read-only descriptors, bypassing // file-write* and file-ioctl. Even deny-default needs this explicit deny. // F_MAKECOMPRESSED = 80, F_TRANSFEREXTENTS = 110. - if !file_system_sandbox_policy.has_full_disk_write_access() { + if !resolved_filesystem_permissions.has_full_disk_write_access { policy_sections.push("(deny system-fcntl (fcntl-command 80 110))".to_string()); } diff --git a/codex-rs/sandboxing/src/seatbelt_scratch.rs b/codex-rs/sandboxing/src/seatbelt_scratch.rs index 6fb13192f4..ad17bb5cd9 100644 --- a/codex-rs/sandboxing/src/seatbelt_scratch.rs +++ b/codex-rs/sandboxing/src/seatbelt_scratch.rs @@ -3,10 +3,11 @@ use super::SeatbeltAccessRoot; use super::SeatbeltPreparationError; -use super::protected_metadata_names_for_writable_root; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::PROTECTED_METADATA_PATH_NAMES; +use codex_protocol::permissions::ResolvedFileSystemPermissions; use codex_protocol::protocol::WritableRoot; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Path; @@ -14,19 +15,19 @@ use std::path::Path; pub(super) fn scratch_access_roots( policy: &FileSystemSandboxPolicy, cwd: &Path, - writable_roots: &[WritableRoot], + filesystem: &ResolvedFileSystemPermissions, ) -> Result<(Vec, Vec), SeatbeltPreparationError> { - let unreadable = policy.get_unreadable_roots_with_cwd(cwd); - let mut read_only = policy - .get_readable_roots_with_cwd(cwd) - .into_iter() + let mut read_only = filesystem + .readable_roots + .iter() .filter(|path| !policy.can_write_local_path_with_cwd(path.as_path(), cwd)) + .cloned() .collect::>(); - read_only.extend(unreadable.iter().cloned()); - for root in writable_roots { + read_only.extend(filesystem.unreadable_roots.iter().cloned()); + for root in &filesystem.writable_roots { read_only.extend(root.read_only_subpaths.iter().cloned()); read_only.extend( - protected_metadata_names_for_writable_root(policy, root, cwd) + root.protected_metadata_names .iter() .map(|name| root.root.join(name)), ); @@ -54,6 +55,16 @@ pub(super) fn scratch_access_roots( // Explicit restrictions at or above a scratch root, and metadata // belonging to narrower project roots, also constrain the default. root.read_only_subpaths.extend(read_only.iter().cloned()); + for name in PROTECTED_METADATA_PATH_NAMES { + if !root + .protected_metadata_names + .iter() + .any(|existing| existing == name) + && !policy.can_write_local_path_with_cwd(root.root.join(*name).as_path(), cwd) + { + root.protected_metadata_names.push((*name).to_string()); + } + } root }) .collect(); @@ -61,7 +72,7 @@ pub(super) fn scratch_access_roots( .into_iter() .map(|root| SeatbeltAccessRoot { root, - excluded_subpaths: unreadable.clone(), + excluded_subpaths: filesystem.unreadable_roots.clone(), protected_metadata_names: Vec::new(), }) .collect(); diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index b5034bc220..ed35aaab1c 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -5,7 +5,6 @@ use super::MACOS_SEATBELT_BASE_POLICY; use super::MacosSeatbeltProfile; use super::ProxyPolicyInputs; use super::UnixDomainSocketPolicy; -use super::build_seatbelt_unreadable_glob_policy; use super::create_seatbelt_command_args; use super::create_seatbelt_command_args_for_legacy_policy; use super::create_seatbelt_command_args_with_profile; @@ -1174,30 +1173,56 @@ fn unreadable_glob_policy_includes_canonicalized_static_prefix() { fs::create_dir(&real_root).expect("create real root"); symlink(&real_root, &link_root).expect("create symlinked root"); - let canonical_root = real_root.canonicalize().expect("canonicalize real root"); - for suffix in ["**/*.env", "{app,service}/*.env", r"secret\?.env"] { - let pattern = format!("{}/{suffix}", link_root.display()); - let canonical_pattern = format!("{}/{suffix}", canonical_root.display()); - let expected_regex = seatbelt_regex_for_unreadable_glob(&canonical_pattern) - .expect("canonical glob should compile"); - let mut policy = FileSystemSandboxPolicy::default(); - policy.entries.push(FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { pattern }, - access: FileSystemAccessMode::Deny, - missing_path_behavior: None, - }); - - let seatbelt_policy = build_seatbelt_unreadable_glob_policy(&policy, temp_dir.path()); - - assert!( - seatbelt_policy.contains(&format!(r#"(deny file-read* (regex #"{expected_regex}"))"#)), - "expected canonicalized glob regex in policy:\n{seatbelt_policy}" + let real_root = real_root.canonicalize().expect("canonicalize real root"); + fs::create_dir(real_root.join("app")).expect("create app directory"); + for (suffix, file) in [ + ("**/*.env", "app/token.env"), + ("{app,service}/*.env", "app/token.env"), + (r"secret\?.env", "secret?.env"), + ] { + let denied = real_root.join(file); + let sibling = denied.with_file_name("editable.txt"); + fs::write(&denied, "secret").expect("write denied file"); + fs::write(&sibling, "public").expect("write sibling"); + let mut policy = restricted_write_policy(&[&real_root]); + policy.entries.push(FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: format!("link-root/{suffix}"), + }, + FileSystemAccessMode::Deny, + )); + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: vec![ + "/bin/sh".into(), + "-c".into(), + r#"/bin/cat "$1" && printf edited > "$1" && ! /bin/cat "$2" && ! (printf leaked > "$2")"#.into(), + "sh".into(), + sibling.display().to_string(), + denied.display().to_string(), + ], + file_system_sandbox_policy: &policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: temp_dir.path(), + enforce_managed_network: false, + managed_network: None, + environment_id: None, + network: None, + extra_allow_unix_sockets: &[], + }) + .expect("generate Seatbelt command"); + let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + .args(args) + .current_dir(&real_root) + .output() + .expect("run Seatbelt command"); + assert!(output.status.success(), "{suffix}: {output:?}"); + assert_eq!( + fs::read_to_string(&denied).expect("read denied file"), + "secret" ); - assert!( - seatbelt_policy.contains(&format!( - r#"(deny file-write* (regex #"{expected_regex}"))"# - )), - "expected canonicalized glob write deny in policy:\n{seatbelt_policy}" + assert_eq!( + fs::read_to_string(&sibling).expect("read sibling"), + "edited" ); } }