diff --git a/codex-rs/sandboxing/src/effective_filesystem_permissions.rs b/codex-rs/sandboxing/src/effective_filesystem_permissions.rs new file mode 100644 index 0000000000..d0f5f5c7ed --- /dev/null +++ b/codex-rs/sandboxing/src/effective_filesystem_permissions.rs @@ -0,0 +1,187 @@ +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxKind; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::ReadDenyMatcher; +use codex_protocol::permissions::project_roots_glob_pattern; +use codex_protocol::protocol::WritableRoot; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::fmt; +use std::path::Path; + +/// Context needed to evaluate an already-materialized filesystem policy. +pub struct FilesystemPermissionsContext<'a> { + /// Resolves cwd-sensitive policy mechanics such as filesystem root and + /// relative candidate paths. It is not workspace-root authority. + pub policy_evaluation_cwd: &'a AbsolutePathBuf, +} + +/// The outer filesystem access mode represented by effective permissions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilesystemPermissionsMode { + Restricted, + Unrestricted, + External, +} + +/// A deny-read glob accepted by the existing runtime matcher. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedDenyGlob { + pattern: String, +} + +impl ValidatedDenyGlob { + pub fn pattern(&self) -> &str { + &self.pattern + } +} + +/// Effective filesystem enforcement facts derived from a permission profile. +/// +/// This internal representation centralizes effective roots, writable carveouts, +/// protected metadata, and read-deny matching before platform-specific lowering. +pub struct EffectiveFilesystemPermissions { + pub mode: FilesystemPermissionsMode, + pub readable_roots: Vec, + pub writable_roots: Vec, + pub unreadable_roots: Vec, + pub unreadable_globs: Vec, + pub include_platform_defaults: bool, + pub glob_scan_max_depth: Option, + file_system_policy: FileSystemSandboxPolicy, + policy_evaluation_cwd: AbsolutePathBuf, + read_deny_matcher: Option, +} + +impl fmt::Debug for EffectiveFilesystemPermissions { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EffectiveFilesystemPermissions") + .field("mode", &self.mode) + .field("readable_roots", &self.readable_roots) + .field("writable_roots", &self.writable_roots) + .field("unreadable_roots", &self.unreadable_roots) + .field("unreadable_globs", &self.unreadable_globs) + .field("include_platform_defaults", &self.include_platform_defaults) + .field("glob_scan_max_depth", &self.glob_scan_max_depth) + .finish_non_exhaustive() + } +} + +impl EffectiveFilesystemPermissions { + /// Derives effective filesystem enforcement facts for platform consumers. + /// + /// Callers must pass an effective `PermissionProfile` after runtime grants and + /// runtime workspace roots have been applied. Symbolic workspace-root entries + /// are rejected at this boundary rather than resolved from a working directory. + pub fn from_profile( + permission_profile: &PermissionProfile, + context: FilesystemPermissionsContext<'_>, + ) -> Result { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if contains_unmaterialized_workspace_roots(&file_system_policy) { + return Err(FilesystemPermissionsError::UnmaterializedWorkspaceRoots); + } + let read_deny_matcher = + ReadDenyMatcher::try_new(&file_system_policy, context.policy_evaluation_cwd.as_path()) + .map_err(FilesystemPermissionsError::InvalidDenyGlob)?; + let mode = match file_system_policy.kind { + FileSystemSandboxKind::Restricted => FilesystemPermissionsMode::Restricted, + FileSystemSandboxKind::Unrestricted => FilesystemPermissionsMode::Unrestricted, + FileSystemSandboxKind::ExternalSandbox => FilesystemPermissionsMode::External, + }; + let readable_roots = + file_system_policy.get_readable_roots_with_cwd(context.policy_evaluation_cwd.as_path()); + let writable_roots = + file_system_policy.get_writable_roots_with_cwd(context.policy_evaluation_cwd.as_path()); + let unreadable_roots = file_system_policy + .get_unreadable_roots_with_cwd(context.policy_evaluation_cwd.as_path()); + let unreadable_globs = file_system_policy + .get_unreadable_globs_with_cwd(context.policy_evaluation_cwd.as_path()) + .into_iter() + .map(|pattern| ValidatedDenyGlob { pattern }) + .collect(); + let include_platform_defaults = file_system_policy.include_platform_defaults(); + let glob_scan_max_depth = file_system_policy.glob_scan_max_depth; + + Ok(Self { + mode, + readable_roots, + writable_roots, + unreadable_roots, + unreadable_globs, + include_platform_defaults, + glob_scan_max_depth, + file_system_policy, + policy_evaluation_cwd: context.policy_evaluation_cwd.clone(), + read_deny_matcher, + }) + } + + /// Returns whether a read is permitted after applying explicit read denies. + pub fn can_read(&self, path: &Path) -> bool { + self.file_system_policy + .can_read_path_with_cwd(path, self.policy_evaluation_cwd.as_path()) + && !self.is_read_denied(path) + } + + /// Returns whether a write is permitted, including protected metadata rules. + pub fn can_write(&self, path: &Path) -> bool { + self.file_system_policy + .can_write_path_with_cwd(path, self.policy_evaluation_cwd.as_path()) + } + + /// Returns whether `path` is matched by an explicit deny-read entry. + pub fn is_read_denied(&self, path: &Path) -> bool { + self.read_deny_matcher + .as_ref() + .is_some_and(|matcher| matcher.is_read_denied(path)) + } + + pub fn has_full_disk_read_access(&self) -> bool { + self.file_system_policy.has_full_disk_read_access() + } + + pub fn has_full_disk_write_access(&self) -> bool { + self.file_system_policy.has_full_disk_write_access() + } +} + +/// An error deriving filesystem enforcement facts from a permission profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FilesystemPermissionsError { + UnmaterializedWorkspaceRoots, + InvalidDenyGlob(String), +} + +impl fmt::Display for FilesystemPermissionsError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnmaterializedWorkspaceRoots => formatter.write_str( + "effective filesystem permissions require workspace roots to be materialized from runtime workspace roots", + ), + Self::InvalidDenyGlob(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for FilesystemPermissionsError {} + +fn contains_unmaterialized_workspace_roots(file_system_policy: &FileSystemSandboxPolicy) -> bool { + let workspace_glob_prefix = project_roots_glob_pattern(Path::new("")); + file_system_policy + .entries + .iter() + .any(|entry| match &entry.path { + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { .. }, + } => true, + FileSystemPath::GlobPattern { pattern } => pattern.starts_with(&workspace_glob_prefix), + FileSystemPath::Path { .. } | FileSystemPath::Special { .. } => false, + }) +} + +#[cfg(test)] +#[path = "effective_filesystem_permissions_tests.rs"] +mod tests; diff --git a/codex-rs/sandboxing/src/effective_filesystem_permissions_tests.rs b/codex-rs/sandboxing/src/effective_filesystem_permissions_tests.rs new file mode 100644 index 0000000000..fb061f46bf --- /dev/null +++ b/codex-rs/sandboxing/src/effective_filesystem_permissions_tests.rs @@ -0,0 +1,254 @@ +use super::EffectiveFilesystemPermissions; +use super::FilesystemPermissionsContext; +use super::FilesystemPermissionsError; +use super::FilesystemPermissionsMode; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxKind; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::ReadDenyMatcher; +use codex_protocol::permissions::project_roots_glob_pattern; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; + +fn absolute_path(path: &Path) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") +} + +fn derive_effective( + permission_profile: &PermissionProfile, + cwd: &AbsolutePathBuf, +) -> super::EffectiveFilesystemPermissions { + EffectiveFilesystemPermissions::from_profile( + permission_profile, + FilesystemPermissionsContext { + policy_evaluation_cwd: cwd, + }, + ) + .expect("valid effective filesystem permissions") +} + +fn assert_projected_fields_match_policy( + permission_profile: &PermissionProfile, + cwd: &AbsolutePathBuf, +) -> super::EffectiveFilesystemPermissions { + let effective = derive_effective(permission_profile, cwd); + let policy = permission_profile.file_system_sandbox_policy(); + let expected_mode = match policy.kind { + FileSystemSandboxKind::Restricted => FilesystemPermissionsMode::Restricted, + FileSystemSandboxKind::Unrestricted => FilesystemPermissionsMode::Unrestricted, + FileSystemSandboxKind::ExternalSandbox => FilesystemPermissionsMode::External, + }; + + assert_eq!(effective.mode, expected_mode); + assert_eq!( + effective.readable_roots, + policy.get_readable_roots_with_cwd(cwd.as_path()) + ); + assert_eq!( + effective.writable_roots, + policy.get_writable_roots_with_cwd(cwd.as_path()) + ); + assert_eq!( + effective.unreadable_roots, + policy.get_unreadable_roots_with_cwd(cwd.as_path()) + ); + assert_eq!( + effective + .unreadable_globs + .iter() + .map(|glob| glob.pattern().to_string()) + .collect::>(), + policy.get_unreadable_globs_with_cwd(cwd.as_path()) + ); + assert_eq!( + effective.include_platform_defaults, + policy.include_platform_defaults() + ); + assert_eq!(effective.glob_scan_max_depth, policy.glob_scan_max_depth); + assert_eq!( + effective.has_full_disk_read_access(), + policy.has_full_disk_read_access() + ); + assert_eq!( + effective.has_full_disk_write_access(), + policy.has_full_disk_write_access() + ); + + effective +} + +#[test] +fn effective_access_modes_preserve_builtin_profile_semantics() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = absolute_path(temp_dir.path()); + + let read_only = assert_projected_fields_match_policy(&PermissionProfile::read_only(), &cwd); + assert_eq!(read_only.mode, FilesystemPermissionsMode::Restricted); + assert_eq!(read_only.can_read(cwd.as_path()), true); + assert_eq!(read_only.can_write(cwd.as_path()), false); + + let unrestricted = assert_projected_fields_match_policy(&PermissionProfile::Disabled, &cwd); + assert_eq!(unrestricted.mode, FilesystemPermissionsMode::Unrestricted); + assert_eq!(unrestricted.can_write(cwd.as_path()), true); + + let external = assert_projected_fields_match_policy( + &PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, + &cwd, + ); + assert_eq!(external.mode, FilesystemPermissionsMode::External); + assert_eq!(external.has_full_disk_write_access(), true); +} + +#[test] +fn effective_workspace_permissions_reject_unmaterialized_runtime_workspace_roots() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = absolute_path(temp_dir.path()); + let unresolved_exact = PermissionProfile::workspace_write(); + let unresolved_glob_policy = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("**/*.env")), + }, + access: FileSystemAccessMode::Deny, + }]); + let unresolved_glob = PermissionProfile::from_runtime_permissions( + &unresolved_glob_policy, + NetworkSandboxPolicy::Restricted, + ); + + for profile in [&unresolved_exact, &unresolved_glob] { + let error = EffectiveFilesystemPermissions::from_profile( + profile, + FilesystemPermissionsContext { + policy_evaluation_cwd: &cwd, + }, + ) + .expect_err("unresolved runtime workspace roots should fail"); + assert_eq!( + error, + FilesystemPermissionsError::UnmaterializedWorkspaceRoots + ); + } +} + +#[test] +fn effective_workspace_permissions_preserve_materialized_workspace_roots() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = absolute_path(temp_dir.path()); + let first_root = cwd.join("first"); + let second_root = cwd.join("second"); + let permission_profile = PermissionProfile::workspace_write() + .materialize_project_roots_with_workspace_roots(&[first_root.clone(), second_root.clone()]); + let effective = assert_projected_fields_match_policy(&permission_profile, &cwd); + + assert_eq!(effective.can_write(first_root.join("src").as_path()), true); + assert_eq!(effective.can_write(second_root.join("src").as_path()), true); +} + +#[test] +fn effective_permissions_preserve_nested_carveouts_and_read_denies() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = absolute_path(temp_dir.path()); + let workspace = cwd.join("workspace"); + let read_only_child = workspace.join("generated"); + let denied_child = workspace.join("private"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: workspace.clone(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_only_child.clone(), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_child.clone(), + }, + access: FileSystemAccessMode::Deny, + }, + ]); + let permission_profile = + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted); + let effective = assert_projected_fields_match_policy(&permission_profile, &cwd); + + assert_eq!( + effective.can_write(workspace.join("file.txt").as_path()), + true + ); + assert_eq!( + effective.can_write(read_only_child.join("file.txt").as_path()), + false + ); + assert_eq!(effective.can_write(workspace.join(".git").as_path()), false); + assert_eq!( + effective.can_read(denied_child.join("secret.txt").as_path()), + false + ); +} + +#[cfg(unix)] +#[test] +fn effective_permissions_preserve_symlinked_writable_roots() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = absolute_path(temp_dir.path()); + let target = cwd.join("target"); + let link = cwd.join("linked-workspace"); + std::fs::create_dir_all(target.as_path()).expect("create target"); + std::os::unix::fs::symlink(target.as_path(), link.as_path()).expect("create symlink"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { path: link.clone() }, + access: FileSystemAccessMode::Write, + }]); + let permission_profile = + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted); + let effective = assert_projected_fields_match_policy(&permission_profile, &cwd); + + assert_eq!( + effective.can_write(link.join("file.txt").as_path()), + policy.can_write_path_with_cwd(link.join("file.txt").as_path(), cwd.as_path()) + ); +} + +#[test] +fn effective_permissions_preserve_accepted_deny_glob_matching() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = absolute_path(temp_dir.path()); + let pattern = cwd.join("secret[.txt").to_string_lossy().into_owned(); + let denied_path = cwd.join("secret[.txt"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: cwd.clone() }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { pattern }, + access: FileSystemAccessMode::Deny, + }, + ]); + let permission_profile = + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted); + let effective = assert_projected_fields_match_policy(&permission_profile, &cwd); + let current_matcher = ReadDenyMatcher::try_new(&policy, cwd.as_path()) + .expect("accepted pattern") + .expect("deny matcher"); + + assert_eq!(effective.is_read_denied(denied_path.as_path()), true); + assert_eq!( + effective.is_read_denied(denied_path.as_path()), + current_matcher.is_read_denied(denied_path.as_path()) + ); + assert_eq!(effective.can_read(denied_path.as_path()), false); +} diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index c70393db8a..e04f4d5168 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,5 +1,6 @@ #[cfg(target_os = "linux")] mod bwrap; +mod effective_filesystem_permissions; pub mod landlock; mod manager; pub mod policy_transforms; @@ -10,6 +11,11 @@ pub mod seatbelt; pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; +pub use effective_filesystem_permissions::EffectiveFilesystemPermissions; +pub use effective_filesystem_permissions::FilesystemPermissionsContext; +pub use effective_filesystem_permissions::FilesystemPermissionsError; +pub use effective_filesystem_permissions::FilesystemPermissionsMode; +pub use effective_filesystem_permissions::ValidatedDenyGlob; pub use manager::SandboxCommand; pub use manager::SandboxExecRequest; pub use manager::SandboxManager;