diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 64993ec903..3a5abc9c9f 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -6,6 +6,8 @@ mod session_id; mod thread_id; mod tool_name; pub use agent_path::AgentPath; +pub use permission_profile_intersection::PermissionIntersectionError; +pub use permission_profile_intersection::intersect_effective_permission_profiles; pub use response_item_id::ResponseItemId; pub use session_id::SessionId; pub use thread_id::RolloutId; @@ -31,6 +33,7 @@ pub mod network_policy; pub mod num_format; pub mod openai_models; pub mod parse_command; +mod permission_profile_intersection; mod permission_profile_snapshot; pub mod permissions; pub mod plan_tool; diff --git a/codex-rs/protocol/src/permission_profile_intersection.rs b/codex-rs/protocol/src/permission_profile_intersection.rs new file mode 100644 index 0000000000..5ee6a1578a --- /dev/null +++ b/codex-rs/protocol/src/permission_profile_intersection.rs @@ -0,0 +1,424 @@ +use std::path::Path; + +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::canonicalize_preserving_symlinks; +use thiserror::Error; + +use crate::models::PermissionProfile; +use crate::permissions::FileSystemAccessMode; +use crate::permissions::FileSystemPath; +use crate::permissions::FileSystemSandboxEntry; +use crate::permissions::FileSystemSandboxKind; +use crate::permissions::FileSystemSandboxPolicy; +use crate::permissions::FileSystemSpecialPath; +use crate::permissions::NetworkSandboxPolicy; +use crate::permissions::PROTECTED_METADATA_PATH_NAMES; +use crate::permissions::ReadDenyMatcher; +use crate::permissions::default_read_only_subpaths_for_writable_root; +use crate::permissions::project_roots_glob_pattern; + +/// A policy cannot be intersected without weakening either input. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum PermissionIntersectionError { + #[error("externally enforced filesystem permissions cannot be intersected safely")] + ExternalSandbox, + #[error("platform-default filesystem permissions cannot be intersected safely")] + PlatformDefaults, + #[error("unsupported permission path: {0}")] + UnsupportedPath(String), +} + +/// Intersects already-effective filesystem permissions and network access. +/// +/// Both profiles must already be materialized for the same local executor and +/// cwd. Concrete grant paths are canonicalized before comparison and in the +/// result, so symlinks cannot acquire authority beyond either input. +/// Unsupported policy shapes fail closed. +pub fn intersect_effective_permission_profiles( + authority: &PermissionProfile, + requested: &PermissionProfile, + cwd: &Path, +) -> Result { + if matches!(authority, PermissionProfile::External { .. }) + || matches!(requested, PermissionProfile::External { .. }) + { + return Err(PermissionIntersectionError::ExternalSandbox); + } + AbsolutePathBuf::from_absolute_path_checked(cwd) + .map_err(|error| PermissionIntersectionError::UnsupportedPath(error.to_string()))?; + for profile in [authority, requested] { + for entry in profile.file_system_sandbox_policy().entries { + match entry.path { + FileSystemPath::Path { path } => { + path.to_abs_path().map_err(|error| { + PermissionIntersectionError::UnsupportedPath(error.to_string()) + })?; + } + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { .. }, + } => { + return Err(PermissionIntersectionError::UnsupportedPath( + "workspace permissions must already be materialized".to_string(), + )); + } + FileSystemPath::GlobPattern { pattern } + if pattern.starts_with(&project_roots_glob_pattern(Path::new(""))) => + { + return Err(PermissionIntersectionError::UnsupportedPath(pattern)); + } + FileSystemPath::GlobPattern { .. } | FileSystemPath::Special { .. } => {} + } + } + } + + let mut authority_policy = authority.file_system_sandbox_policy(); + let mut requested_policy = requested.file_system_sandbox_policy(); + let root_path = FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }; + let network = if authority.network_sandbox_policy().is_enabled() + && requested.network_sandbox_policy().is_enabled() + { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + }; + if matches!(authority, PermissionProfile::Disabled) + && matches!(requested, PermissionProfile::Disabled) + { + return Ok(PermissionProfile::Disabled); + } + if authority_policy == requested_policy { + return Ok(PermissionProfile::from_runtime_permissions( + &authority_policy, + network, + )); + } + if matches!(authority_policy.kind, FileSystemSandboxKind::Unrestricted) { + return Ok(PermissionProfile::from_runtime_permissions( + &requested_policy, + network, + )); + } + if matches!(requested_policy.kind, FileSystemSandboxKind::Unrestricted) { + return Ok(PermissionProfile::from_runtime_permissions( + &authority_policy, + network, + )); + } + + normalize_policy(&mut authority_policy)?; + normalize_policy(&mut requested_policy)?; + let tmpdir = FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir, + }; + let temp_entries = [&authority_policy, &requested_policy].map(|policy| { + policy + .entries + .iter() + .filter(|entry| entry.path == tmpdir) + .collect::>() + }); + let common_temp = match (temp_entries[0].as_slice(), temp_entries[1].as_slice()) { + ([left], [right]) + if [left, right] + .into_iter() + .all(|entry| entry.access.can_write() && !entry.skips_missing_path()) => + { + Some(FileSystemSandboxEntry::new(tmpdir.clone(), left.access)) + } + ([], []) => None, + ([entry], []) | ([], [entry]) + if entry.access.can_write() && !entry.skips_missing_path() => + { + None + } + _ => { + return Err(PermissionIntersectionError::UnsupportedPath( + ":tmpdir restrictions require executor-local bindings".to_string(), + )); + } + }; + for policy in [&mut authority_policy, &mut requested_policy] { + policy.entries.retain(|entry| entry.path != tmpdir); + } + + let authority_denies = ReadDenyMatcher::try_new(&authority_policy, cwd) + .map_err(PermissionIntersectionError::UnsupportedPath)?; + let requested_denies = ReadDenyMatcher::try_new(&requested_policy, cwd) + .map_err(PermissionIntersectionError::UnsupportedPath)?; + let sources = [ + (&authority_policy, authority_denies.as_ref()), + (&requested_policy, requested_denies.as_ref()), + ]; + let mut paths = Vec::new(); + let mut denies = Vec::new(); + let mut glob_depth: Option> = None; + for (policy, _) in sources { + let writable_roots = policy.get_writable_roots_with_cwd(cwd); + for entry in &policy.entries { + if entry.access == FileSystemAccessMode::Deny && !denies.contains(entry) { + denies.push(entry.clone()); + } + if matches!(entry.path, FileSystemPath::GlobPattern { .. }) { + glob_depth = Some(match glob_depth { + None => policy.glob_scan_max_depth, + Some(Some(previous)) => { + policy.glob_scan_max_depth.map(|depth| previous.max(depth)) + } + Some(None) => None, + }); + } + let Some(path) = entry_path(entry, cwd)? else { + continue; + }; + if entry.skips_missing_path() + && (entry.access != FileSystemAccessMode::Read + || !writable_roots.iter().any(|root| { + root.read_only_subpaths.contains(&path) + || default_read_only_subpaths_for_writable_root( + &root.root, /*protect_missing_dot_codex*/ false, + ) + .contains(&path) + || path.parent().as_ref() == Some(&root.root) + && PROTECTED_METADATA_PATH_NAMES.iter().any(|name| { + path.as_path().file_name() == Some(std::ffi::OsStr::new(name)) + }) + })) + { + return Err(PermissionIntersectionError::UnsupportedPath( + "non-metadata optional permissions".to_string(), + )); + } + paths.push(path); + } + paths.extend(policy.get_readable_roots_with_cwd(cwd)); + for root in writable_roots { + paths.push(root.root.clone()); + paths.extend( + PROTECTED_METADATA_PATH_NAMES + .iter() + .map(|name| root.root.join(name)), + ); + paths.extend(root.read_only_subpaths); + } + } + paths.sort_by(|left, right| { + left.as_path() + .components() + .count() + .cmp(&right.as_path().components().count()) + .then_with(|| left.as_path().cmp(right.as_path())) + }); + paths.dedup(); + denies.sort_by_cached_key(|entry| format!("{:?}", entry.path)); + let mut intersection = FileSystemSandboxPolicy::restricted(denies); + intersection.glob_scan_max_depth = glob_depth.flatten(); + let intersection_denies = ReadDenyMatcher::new(&intersection, cwd); + + for path in paths { + let [left, right] = + sources.map(|(policy, denies)| effective_access(policy, denies, path.as_path(), cwd)); + let access = intersect_access(left, right); + let current = effective_access( + &intersection, + intersection_denies.as_ref(), + path.as_path(), + cwd, + ); + let optional_metadata = sources.into_iter().any(|(policy, _)| { + policy.entries.iter().any(|entry| { + entry.skips_missing_path() + && entry_path(entry, cwd).ok().flatten().as_ref() == Some(&path) + }) + }); + let required_restriction = sources.into_iter().any(|(policy, _)| { + policy.entries.iter().any(|entry| { + !entry.skips_missing_path() + && !entry.access.can_write() + && entry_path(entry, cwd).ok().flatten().as_ref() == Some(&path) + }) + }); + if access == current && !optional_metadata && !required_restriction { + continue; + } + let path = if path.as_path().parent().is_none() + && cwd.starts_with(path.as_path()) + && sources + .into_iter() + .all(|(policy, _)| policy.entries.iter().any(|entry| entry.path == root_path)) + { + root_path.clone() + } else { + path.into() + }; + let entry = + if optional_metadata && !required_restriction && access == FileSystemAccessMode::Read { + FileSystemSandboxEntry::skip_missing_path(path, access) + } else { + FileSystemSandboxEntry::new(path, access) + }; + if !intersection.entries.contains(&entry) { + intersection.entries.push(entry); + } + } + if let Some(temp) = common_temp { + intersection.entries.push(temp); + } + Ok(PermissionProfile::from_runtime_permissions( + &intersection, + network, + )) +} + +fn normalize_policy( + policy: &mut FileSystemSandboxPolicy, +) -> Result<(), PermissionIntersectionError> { + if policy.include_platform_defaults() { + return Err(PermissionIntersectionError::PlatformDefaults); + } + for entry in &mut policy.entries { + match &entry.path { + FileSystemPath::Path { path } => { + let path = path.to_abs_path().map_err(|error| { + PermissionIntersectionError::UnsupportedPath(error.to_string()) + })?; + let physical = if entry.access.can_write() + || entry.access == FileSystemAccessMode::Read && !entry.skips_missing_path() + { + path.canonicalize().map_err(|error| { + PermissionIntersectionError::UnsupportedPath(format!( + "{}: {error}", + path.as_path().display() + )) + })? + } else { + physical_path(&path)? + }; + let logical = path.ancestors().find_map(|ancestor| { + std::fs::symlink_metadata(ancestor.as_path()).ok()?; + let preserved = canonicalize_preserving_symlinks(ancestor.as_path()).ok()?; + let suffix = path.as_path().strip_prefix(ancestor.as_path()).ok()?; + Some( + AbsolutePathBuf::from_absolute_path_checked(preserved) + .ok()? + .join(suffix), + ) + }); + if !entry.access.can_write() && logical.as_ref() != Some(&physical) { + return Err(PermissionIntersectionError::UnsupportedPath(format!( + "symlinked restriction: {}", + path.as_path().display() + ))); + } + entry.path = physical.into(); + } + FileSystemPath::GlobPattern { pattern } + if entry.access != FileSystemAccessMode::Deny || entry.skips_missing_path() => + { + return Err(PermissionIntersectionError::UnsupportedPath(format!( + "glob permissions: {pattern}" + ))); + } + FileSystemPath::GlobPattern { .. } => {} + FileSystemPath::Special { + value: FileSystemSpecialPath::Root | FileSystemSpecialPath::Tmpdir, + } => {} + FileSystemPath::Special { + value: FileSystemSpecialPath::SlashTmp, + } => { + if cfg!(unix) { + entry.path = physical_path( + &AbsolutePathBuf::from_absolute_path_checked("/tmp").map_err(|error| { + PermissionIntersectionError::UnsupportedPath(error.to_string()) + })?, + )? + .into(); + } + } + FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + } => return Err(PermissionIntersectionError::PlatformDefaults), + FileSystemPath::Special { value } => { + return Err(PermissionIntersectionError::UnsupportedPath(format!( + "{value:?}" + ))); + } + } + } + Ok(()) +} + +fn entry_path( + entry: &FileSystemSandboxEntry, + cwd: &Path, +) -> Result, PermissionIntersectionError> { + match &entry.path { + FileSystemPath::Path { path } => path + .to_abs_path() + .map(Some) + .map_err(|error| PermissionIntersectionError::UnsupportedPath(error.to_string())), + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + } => cwd + .ancestors() + .last() + .and_then(|root| AbsolutePathBuf::from_absolute_path_checked(root).ok()) + .map(Some) + .ok_or_else(|| PermissionIntersectionError::UnsupportedPath(cwd.display().to_string())), + FileSystemPath::Special { + value: FileSystemSpecialPath::Tmpdir | FileSystemSpecialPath::SlashTmp, + } => Ok(None), + FileSystemPath::Special { value } => Err(PermissionIntersectionError::UnsupportedPath( + format!("{value:?}"), + )), + FileSystemPath::GlobPattern { .. } => Ok(None), + } +} + +fn physical_path(path: &AbsolutePathBuf) -> Result { + for ancestor in path.ancestors() { + if let Ok(physical) = ancestor.canonicalize() + && let Ok(suffix) = path.as_path().strip_prefix(ancestor.as_path()) + { + return Ok(physical.join(suffix)); + } + } + Err(PermissionIntersectionError::UnsupportedPath( + path.as_path().display().to_string(), + )) +} + +fn effective_access( + policy: &FileSystemSandboxPolicy, + denies: Option<&ReadDenyMatcher>, + path: &Path, + cwd: &Path, +) -> FileSystemAccessMode { + if denies.is_some_and(|matcher| matcher.is_read_denied(path)) { + FileSystemAccessMode::Deny + } else if policy.can_write_path_with_cwd(path, cwd) { + FileSystemAccessMode::Write + } else if policy.can_read_path_with_cwd(path, cwd) { + FileSystemAccessMode::Read + } else { + FileSystemAccessMode::Deny + } +} + +fn intersect_access( + left: FileSystemAccessMode, + right: FileSystemAccessMode, +) -> FileSystemAccessMode { + match (left, right) { + (FileSystemAccessMode::Deny, _) | (_, FileSystemAccessMode::Deny) => { + FileSystemAccessMode::Deny + } + (FileSystemAccessMode::Write, FileSystemAccessMode::Write) => FileSystemAccessMode::Write, + (FileSystemAccessMode::Read | FileSystemAccessMode::Write, _) => FileSystemAccessMode::Read, + } +} + +#[cfg(test)] +#[path = "permission_profile_intersection_tests.rs"] +mod tests; diff --git a/codex-rs/protocol/src/permission_profile_intersection_tests.rs b/codex-rs/protocol/src/permission_profile_intersection_tests.rs new file mode 100644 index 0000000000..6245c79713 --- /dev/null +++ b/codex-rs/protocol/src/permission_profile_intersection_tests.rs @@ -0,0 +1,369 @@ +use std::path::Path; + +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +use super::PermissionIntersectionError; +use super::intersect_effective_permission_profiles; +use crate::models::PermissionProfile; +use crate::permissions::FileSystemAccessMode; +use crate::permissions::FileSystemAccessMode::Deny; +use crate::permissions::FileSystemAccessMode::Read; +use crate::permissions::FileSystemAccessMode::Write; +use crate::permissions::FileSystemPath; +use crate::permissions::FileSystemSandboxEntry; +use crate::permissions::FileSystemSandboxPolicy; +use crate::permissions::FileSystemSpecialPath; +use crate::permissions::FileSystemSpecialPath::Minimal; +use crate::permissions::FileSystemSpecialPath::Tmpdir; +use crate::permissions::NetworkSandboxPolicy; +use crate::permissions::NetworkSandboxPolicy::Enabled; +use crate::permissions::NetworkSandboxPolicy::Restricted; +use crate::permissions::ReadDenyMatcher; +use crate::permissions::project_roots_glob_pattern; + +fn absolute(path: &Path) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute test path") +} + +fn canonical(temp: &TempDir) -> AbsolutePathBuf { + absolute(temp.path()) + .canonicalize() + .expect("canonical root") +} + +fn managed(entries: Vec) -> PermissionProfile { + PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(entries), + Restricted, + ) +} + +fn entry(path: &Path, access: FileSystemAccessMode) -> FileSystemSandboxEntry { + FileSystemSandboxEntry::new(absolute(path).into(), access) +} + +fn special(value: FileSystemSpecialPath, access: FileSystemAccessMode) -> FileSystemSandboxEntry { + FileSystemSandboxEntry::new(FileSystemPath::Special { value }, access) +} + +fn skipped(path: FileSystemPath, access: FileSystemAccessMode) -> FileSystemSandboxEntry { + FileSystemSandboxEntry::skip_missing_path(path, access) +} + +fn deny_glob(pattern: &str) -> FileSystemSandboxEntry { + FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: pattern.into(), + }, + Deny, + ) +} + +fn root_read() -> FileSystemSandboxEntry { + FileSystemSandboxPolicy::read_only().entries[0].clone() +} + +fn rooted( + root: &AbsolutePathBuf, + access: FileSystemAccessMode, + entries: impl IntoIterator, +) -> PermissionProfile { + let mut all_entries = vec![root_read(), entry(root.as_path(), access)]; + all_entries.extend(entries); + managed(all_entries) +} + +fn workspace(root: &AbsolutePathBuf, network: NetworkSandboxPolicy) -> PermissionProfile { + PermissionProfile::workspace_write_with( + &[], + network, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ true, + ) + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(root)) +} + +fn intersection( + left: &PermissionProfile, + right: &PermissionProfile, + cwd: &AbsolutePathBuf, +) -> PermissionProfile { + intersect_effective_permission_profiles(left, right, cwd.as_path()).expect("safe intersection") +} + +#[test] +fn effective_workspace_intersection_preserves_network_metadata_and_temp() { + let temp = TempDir::new().expect("workspace"); + let root = canonical(&temp); + let project = root.join("project"); + std::fs::create_dir(project.as_path()).expect("project directory"); + let gitdir = root.join("external-gitdir"); + std::fs::create_dir(gitdir.as_path()).expect("worktree gitdir"); + std::fs::write( + project.join(".git").as_path(), + format!("gitdir: {}", gitdir.as_path().display()), + ) + .expect("worktree git pointer"); + let authority = workspace(&root, Enabled); + let mut requested_policy = workspace(&project, Restricted).file_system_sandbox_policy(); + requested_policy + .entries + .push(skipped(gitdir.clone().into(), Read)); + let requested = PermissionProfile::from_runtime_permissions(&requested_policy, Restricted); + let result = intersection(&authority, &requested, &project); + let policy = result.file_system_sandbox_policy(); + + assert_eq!( + [&root, &project] + .map(|path| policy.resolve_access_with_cwd(path.as_path(), root.as_path())), + [Read, Write] + ); + assert_eq!(result.network_sandbox_policy(), Restricted); + assert!(policy.entries.contains(&special(Tmpdir, Write))); + for name in [".git", ".agents", ".codex"] { + let protected = project.join(name); + assert!(!policy.can_write_path_with_cwd(protected.as_path(), root.as_path())); + assert!(policy.entries.contains(&skipped(protected.into(), Read))); + } + assert!(policy.entries.contains(&skipped(gitdir.into(), Read))); + let strict = rooted( + &project, + Write, + [entry(project.join(".git").as_path(), Read)], + ); + let strict = intersection(&requested, &strict, &project).file_system_sandbox_policy(); + let git_protection = entry(project.join(".git").as_path(), Read); + assert!(strict.entries.contains(&git_protection)); +} + +#[test] +fn exact_denies_nested_read_carveouts_and_reopened_writes_are_preserved() { + let temp = TempDir::new().expect("workspace"); + let root = canonical(&temp); + let shared = root.join("shared"); + let editable = shared.join("editable"); + let left_secret = root.join("left-secret.env"); + let right_secret = root.join("right-secret.token"); + for directory in [&editable, &left_secret, &right_secret] { + std::fs::create_dir_all(directory.as_path()).expect("policy directory"); + } + let left = rooted( + &root, + Write, + [ + entry(shared.as_path(), Read), + entry(editable.as_path(), Write), + entry(left_secret.as_path(), Deny), + deny_glob("**/*.env"), + ], + ); + let mut left_policy = left.file_system_sandbox_policy(); + left_policy.glob_scan_max_depth = Some(2); + let left = PermissionProfile::from_runtime_permissions(&left_policy, Restricted); + let right = rooted( + &root, + Write, + [ + entry(shared.as_path(), Write), + entry(editable.as_path(), Write), + entry(right_secret.as_path(), Deny), + deny_glob("**/*.token"), + ], + ); + let mut right_policy = right.file_system_sandbox_policy(); + right_policy.glob_scan_max_depth = Some(4); + let right = PermissionProfile::from_runtime_permissions(&right_policy, Restricted); + let result = intersection(&left, &right, &root); + let policy = result.file_system_sandbox_policy(); + let denies = ReadDenyMatcher::new(&policy, root.as_path()).expect("merged denies"); + assert_eq!( + [&root, &shared, &editable] + .map(|path| policy.resolve_access_with_cwd(path.as_path(), root.as_path())), + [Write, Read, Write] + ); + assert_eq!( + [ + &left_secret, + &right_secret, + &root.join("credentials.env"), + &root.join("credentials.token"), + ] + .map(|path| denies.is_read_denied(path.as_path())), + [true, true, true, true] + ); + assert_eq!(policy.glob_scan_max_depth, Some(4)); + assert!(policy.entries.contains(&entry(left_secret.as_path(), Deny))); + assert_eq!(result, intersection(&right, &left, &root)); + let unbounded = rooted(&root, Write, [deny_glob("**/*.key")]); + assert_eq!( + intersection(&left, &unbounded, &root) + .file_system_sandbox_policy() + .glob_scan_max_depth, + None + ); +} + +#[test] +fn temporary_directory_restrictions_fail_closed() { + let temp = TempDir::new().expect("workspace"); + let root = canonical(&temp); + let writable = workspace(&root, Restricted); + for access in [Read, Deny] { + let restricted = rooted(&root, Write, [special(Tmpdir, access)]); + assert!(matches!( + intersect_effective_permission_profiles(&restricted, &writable, root.as_path()), + Err(PermissionIntersectionError::UnsupportedPath(_)) + )); + } +} + +#[test] +fn identical_unrestricted_and_disabled_profiles_keep_existing_enforcement() { + let temp = TempDir::new().expect("workspace"); + let root = absolute(temp.path()); + let readonly = PermissionProfile::read_only(); + let disabled = PermissionProfile::Disabled; + assert_eq!(intersection(&disabled, &readonly, &root), readonly); + assert_eq!(intersection(&readonly, &disabled, &root), readonly); + assert_eq!(intersection(&disabled, &disabled, &root), disabled); + let unrestricted = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::unrestricted(), + Enabled, + ); + assert_eq!(intersection(&unrestricted, &readonly, &root), readonly); + + let minimal = managed(vec![special(Minimal, Read)]); + assert_eq!(intersection(&minimal, &minimal, &root), minimal); + let filesystem_root = root.as_path().ancestors().last().unwrap(); + let explicit = managed(vec![entry(filesystem_root, Read)]); + assert!( + !intersection(&explicit, &explicit, &root) + .file_system_sandbox_policy() + .has_full_disk_read_access() + ); +} + +#[test] +fn unsupported_unresolved_and_optional_policy_shapes_fail_closed() { + let temp = TempDir::new().expect("workspace"); + let root = canonical(&temp); + let readonly = PermissionProfile::read_only(); + let external = PermissionProfile::External { network: Enabled }; + for (left, right) in [(&external, &readonly), (&readonly, &external)] { + assert_eq!( + intersect_effective_permission_profiles(left, right, root.as_path()), + Err(PermissionIntersectionError::ExternalSandbox) + ); + } + let unmaterialized = PermissionProfile::workspace_write(); + assert!(matches!( + intersect_effective_permission_profiles(&unmaterialized, &readonly, root.as_path()), + Err(PermissionIntersectionError::UnsupportedPath(_)) + )); + let minimal = managed(vec![special(Minimal, Read)]); + assert_eq!( + intersect_effective_permission_profiles(&minimal, &readonly, root.as_path()), + Err(PermissionIntersectionError::PlatformDefaults) + ); + + let optional_write = rooted(&root, Write, [skipped(root.clone().into(), Write)]); + let invalid_glob = rooted(&root, Write, [deny_glob("**/[z-a]")]); + let optional_glob = rooted(&root, Write, [skipped(deny_glob("**/*.env").path, Deny)]); + let unresolved_pattern = project_roots_glob_pattern(Path::new("**/*.env")); + let unresolved_glob = rooted(&root, Write, [deny_glob(&unresolved_pattern)]); + let duplicate_tmpdir = managed(vec![ + root_read(), + special(Tmpdir, Read), + skipped(FileSystemPath::Special { value: Tmpdir }, Write), + ]); + for unsupported in [ + &optional_write, + &invalid_glob, + &optional_glob, + &unresolved_glob, + &duplicate_tmpdir, + ] { + assert!(matches!( + intersect_effective_permission_profiles(unsupported, &readonly, root.as_path()), + Err(PermissionIntersectionError::UnsupportedPath(_)) + )); + } + assert!(matches!( + intersect_effective_permission_profiles( + &readonly, + &readonly, + Path::new("relative/workspace") + ), + Err(PermissionIntersectionError::UnsupportedPath(_)) + )); +} + +#[cfg(unix)] +#[test] +fn canonical_grants_cannot_escape_through_readable_or_writable_symlinks() { + let temp = TempDir::new().expect("workspace"); + let root = canonical(&temp); + let outside_temp = TempDir::new().expect("outside workspace"); + let outside = canonical(&outside_temp); + let escaped = root.join("escaped"); + let inside = root.join("inside"); + let internal_alias = root.join("internal-alias"); + std::fs::create_dir(inside.as_path()).expect("inside directory"); + std::os::unix::fs::symlink(outside.as_path(), escaped.as_path()).expect("outside symlink"); + std::os::unix::fs::symlink(inside.as_path(), internal_alias.as_path()).expect("inside symlink"); + + let parent = rooted(&root, Write, []); + let child = rooted(&escaped, Write, []); + let policy = intersection(&parent, &child, &root).file_system_sandbox_policy(); + assert!(!policy.can_write_path_with_cwd(outside.as_path(), root.as_path())); + + let parent = managed(vec![entry(root.as_path(), Read)]); + let child = managed(vec![entry(escaped.as_path(), Read)]); + assert!(matches!( + intersect_effective_permission_profiles(&parent, &child, root.as_path()), + Err(PermissionIntersectionError::UnsupportedPath(_)) + )); + + let parent = rooted(&root, Write, []); + let child = rooted(&internal_alias, Write, []); + let policy = intersection(&parent, &child, &root).file_system_sandbox_policy(); + assert!(policy.can_write_path_with_cwd(inside.as_path(), root.as_path())); + assert!( + !policy + .entries + .contains(&entry(internal_alias.as_path(), Write)) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_system_path_aliases_share_the_same_physical_permissions() { + let temp = TempDir::new_in("/tmp").expect("workspace under system path alias"); + let canonical = canonical(&temp); + let alias = absolute( + &Path::new("/").join( + canonical + .as_path() + .strip_prefix("/private") + .expect("macOS alias"), + ), + ); + for access in [Read, Write] { + let left = managed(vec![entry(alias.as_path(), access)]); + let right = managed(vec![entry(canonical.as_path(), access)]); + let policy = intersection(&left, &right, &canonical).file_system_sandbox_policy(); + assert_eq!( + policy.resolve_access_with_cwd(canonical.as_path(), canonical.as_path()), + access + ); + } + let policy = intersection( + &workspace(&alias, Enabled), + &workspace(&canonical, Restricted), + &canonical, + ) + .file_system_sandbox_policy(); + assert!(policy.can_write_path_with_cwd(canonical.as_path(), canonical.as_path())); +}