diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 2821de5a8b..d40d9eb29e 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -49,8 +49,8 @@ use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path::normalize_for_path_comparison; use schemars::JsonSchema; @@ -641,15 +641,19 @@ pub struct GhostSnapshotToml { } impl ConfigToml { - /// Derive the effective sandbox policy from the configuration. - pub async fn derive_sandbox_policy( + /// Derive the effective permission profile from legacy sandbox config. + /// + /// Call this only after ruling out `default_permissions`: named + /// `[permissions]` profiles must be compiled through the permissions + /// profile pipeline, not reconstructed from `sandbox_mode`. + pub async fn derive_permission_profile( &self, sandbox_mode_override: Option, profile_sandbox_mode: Option, windows_sandbox_level: WindowsSandboxLevel, active_project: Option<&ProjectConfig>, permission_profile_constraint: Option<&crate::Constrained>, - ) -> SandboxPolicy { + ) -> PermissionProfile { let sandbox_mode_was_explicit = sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() || self.sandbox_mode.is_some(); @@ -677,50 +681,53 @@ impl ConfigToml { }) }) .unwrap_or_default(); - let mut sandbox_policy = match resolved_sandbox_mode { - SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), + let effective_sandbox_mode = if cfg!(target_os = "windows") + // If the experimental Windows sandbox is enabled, do not force a downgrade. + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) + { + SandboxMode::ReadOnly + } else { + resolved_sandbox_mode + }; + + let permission_profile = match effective_sandbox_mode { + SandboxMode::ReadOnly => PermissionProfile::read_only(), SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { Some(SandboxWorkspaceWrite { writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, - }) => SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), - network_access: *network_access, - exclude_tmpdir_env_var: *exclude_tmpdir_env_var, - exclude_slash_tmp: *exclude_slash_tmp, - }, - None => SandboxPolicy::new_workspace_write_policy(), + }) => { + let network_policy = if *network_access { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + }; + PermissionProfile::workspace_write_with( + writable_roots, + network_policy, + *exclude_tmpdir_env_var, + *exclude_slash_tmp, + ) + } + None => PermissionProfile::workspace_write(), }, - SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess, + SandboxMode::DangerFullAccess => PermissionProfile::Disabled, }; - let downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| { - if cfg!(target_os = "windows") - // If the experimental Windows sandbox is enabled, do not force a downgrade. - && windows_sandbox_level == WindowsSandboxLevel::Disabled - && matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. }) - { - *policy = SandboxPolicy::new_read_only_policy(); - } - }; - if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) { - downgrade_workspace_write_if_unsupported(&mut sandbox_policy); - } if !sandbox_mode_was_explicit && let Some(constraint) = permission_profile_constraint - && let Err(err) = constraint.can_set(&PermissionProfile::from_legacy_sandbox_policy( - &sandbox_policy, - )) + && let Err(err) = constraint.can_set(&permission_profile) { tracing::warn!( error = %err, "default sandbox policy is disallowed by requirements; falling back to required default" ); - sandbox_policy = SandboxPolicy::new_read_only_policy(); - downgrade_workspace_write_if_unsupported(&mut sandbox_policy); + PermissionProfile::read_only() + } else { + permission_profile } - sandbox_policy } /// Resolves the cwd to an existing project, or returns None if ConfigToml diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e25c83e58d..21a86dcadd 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -133,6 +133,34 @@ fn http_mcp(url: &str) -> McpServerConfig { } } +async fn derive_legacy_sandbox_policy_for_test( + cfg: &ConfigToml, + sandbox_mode_override: Option, + profile_sandbox_mode: Option, + windows_sandbox_level: WindowsSandboxLevel, + active_project: Option<&ProjectConfig>, + permission_profile_constraint: Option<&Constrained>, +) -> SandboxPolicy { + let permission_profile = cfg + .derive_permission_profile( + sandbox_mode_override, + profile_sandbox_mode, + windows_sandbox_level, + active_project, + permission_profile_constraint, + ) + .await; + permission_profile + .to_legacy_sandbox_policy(Path::new("/")) + .unwrap_or_else(|err| { + tracing::warn!( + error = %err, + "derived permission profile cannot be represented as a legacy sandbox policy; falling back to read-only" + ); + SandboxPolicy::new_read_only_policy() + }) +} + #[tokio::test] async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?; @@ -1630,15 +1658,15 @@ network_access = false # This should be ignored. let sandbox_full_access_cfg = toml::from_str::(sandbox_full_access) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; - let resolution = sandbox_full_access_cfg - .derive_sandbox_policy( - sandbox_mode_override, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - /*active_project*/ None, - /*permission_profile_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &sandbox_full_access_cfg, + sandbox_mode_override, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + /*active_project*/ None, + /*permission_profile_constraint*/ None, + ) + .await; assert_eq!(resolution, SandboxPolicy::DangerFullAccess); let sandbox_read_only = r#" @@ -1651,15 +1679,15 @@ network_access = true # This should be ignored. let sandbox_read_only_cfg = toml::from_str::(sandbox_read_only) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; - let resolution = sandbox_read_only_cfg - .derive_sandbox_policy( - sandbox_mode_override, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - /*active_project*/ None, - /*permission_profile_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &sandbox_read_only_cfg, + sandbox_mode_override, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + /*active_project*/ None, + /*permission_profile_constraint*/ None, + ) + .await; assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); let writable_root = test_absolute_path("/my/workspace"); @@ -1683,15 +1711,15 @@ trust_level = "trusted" let sandbox_workspace_write_cfg = toml::from_str::(&sandbox_workspace_write) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; - let resolution = sandbox_workspace_write_cfg - .derive_sandbox_policy( - sandbox_mode_override, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - /*active_project*/ None, - /*permission_profile_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &sandbox_workspace_write_cfg, + sandbox_mode_override, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + /*active_project*/ None, + /*permission_profile_constraint*/ None, + ) + .await; if cfg!(target_os = "windows") { assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { @@ -1723,15 +1751,15 @@ exclude_slash_tmp = true let sandbox_workspace_write_cfg = toml::from_str::(&sandbox_workspace_write) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; - let resolution = sandbox_workspace_write_cfg - .derive_sandbox_policy( - sandbox_mode_override, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - /*active_project*/ None, - /*permission_profile_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &sandbox_workspace_write_cfg, + sandbox_mode_override, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + /*active_project*/ None, + /*permission_profile_constraint*/ None, + ) + .await; if cfg!(target_os = "windows") { assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { @@ -1748,7 +1776,7 @@ exclude_slash_tmp = true } #[tokio::test] -async fn legacy_sandbox_mode_config_builds_split_policies_without_drift() -> std::io::Result<()> { +async fn legacy_sandbox_mode_builds_profiles_with_compatible_projection() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = test_absolute_path("/tmp/legacy-extra-root"); @@ -1793,26 +1821,91 @@ exclude_slash_tmp = true ) .await?; - let sandbox_policy = &config.legacy_sandbox_policy(); + let sandbox_policy = config.legacy_sandbox_policy(); + let file_system_policy = config.permissions.file_system_sandbox_policy(); + let network_policy = config.permissions.network_sandbox_policy(); + assert_eq!( - config.permissions.file_system_sandbox_policy(), - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd.path()), - "case `{name}` should preserve filesystem semantics from legacy config" - ); - assert_eq!( - config.permissions.network_sandbox_policy(), - NetworkSandboxPolicy::from(sandbox_policy), + network_policy, + NetworkSandboxPolicy::from(&sandbox_policy), "case `{name}` should preserve network semantics from legacy config" ); assert_eq!( - config - .permissions - .file_system_sandbox_policy() - .to_legacy_sandbox_policy(config.permissions.network_sandbox_policy(), cwd.path()) + file_system_policy + .to_legacy_sandbox_policy(network_policy, cwd.path()) .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), - sandbox_policy.clone(), - "case `{name}` should round-trip through split policies without drift" + sandbox_policy, + "case `{name}` should preserve its legacy compatibility projection" ); + + match name.as_str() { + "danger-full-access" | "read-only" => { + assert_eq!( + file_system_policy, + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &sandbox_policy, + cwd.path() + ), + "case `{name}` should match the legacy filesystem projection exactly" + ); + } + "workspace-write" => { + if cfg!(target_os = "windows") { + assert_eq!( + sandbox_policy, + SandboxPolicy::new_read_only_policy(), + "legacy workspace-write should keep the existing Windows downgrade when \ + the experimental Windows sandbox is disabled" + ); + assert_eq!( + file_system_policy, + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &sandbox_policy, + cwd.path() + ), + "downgraded workspace-write should match the legacy read-only projection" + ); + continue; + } + assert!( + file_system_policy + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }) + ); + assert!( + file_system_policy + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: extra_root.clone(), + }, + access: FileSystemAccessMode::Write, + }) + ); + for subpath in [".git", ".agents", ".codex"] { + assert!( + file_system_policy + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some( + subpath.into() + )), + }, + access: FileSystemAccessMode::Read, + }), + "case `{name}` should preserve `{subpath}` as a symbolic project-root \ + metadata carveout" + ); + } + } + _ => unreachable!("unexpected test case `{name}`"), + } } Ok(()) @@ -6310,15 +6403,15 @@ trust_level = "untrusted" trust_level: Some(TrustLevel::Untrusted), }; - let resolution = cfg - .derive_sandbox_policy( - /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - Some(&active_project), - /*permission_profile_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &cfg, + /*sandbox_mode_override*/ None, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + Some(&active_project), + /*permission_profile_constraint*/ None, + ) + .await; // Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade) if cfg!(target_os = "windows") { @@ -6367,15 +6460,15 @@ async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() - } })?; - let resolution = cfg - .derive_sandbox_policy( - /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - Some(&active_project), - Some(&constrained), - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &cfg, + /*sandbox_mode_override*/ None, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + Some(&active_project), + Some(&constrained), + ) + .await; assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); Ok(()) @@ -6423,15 +6516,15 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb }, )?; - let resolution = cfg - .derive_sandbox_policy( - /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - Some(&active_project), - Some(&constrained), - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &cfg, + /*sandbox_mode_override*/ None, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + Some(&active_project), + Some(&constrained), + ) + .await; if cfg!(target_os = "windows") { assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 63af876ac9..9b7d23b041 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1976,8 +1976,13 @@ impl Config { ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); - let mut sandbox_policy = cfg - .derive_sandbox_policy( + // No named `[permissions]` profile is active, but permissions + // should still flow through the canonical profile representation. + // Derive the old `sandbox_mode` defaults as a profile first, then + // keep a legacy-compatible projection only for the remaining code + // paths that still speak `SandboxPolicy`. + let mut permission_profile = cfg + .derive_permission_profile( sandbox_mode, config_profile.sandbox_mode, windows_sandbox_level, @@ -1985,24 +1990,46 @@ impl Config { Some(&constrained_permission_profile), ) .await; - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { - for path in &additional_writable_roots { - if !writable_roots.iter().any(|existing| existing == path) { - writable_roots.push(path.clone()); - } - } + // The legacy-derived profiles above are expected to be + // representable as `SandboxPolicy`. This guard keeps the old safe + // fallback behavior if future changes make this branch derive a + // profile with split-only filesystem semantics, such as root write + // with carveouts or writes that are not expressible as + // workspace-write roots. + if let Err(err) = permission_profile.to_legacy_sandbox_policy(resolved_cwd.as_path()) { + tracing::warn!( + error = %err, + "derived permission profile cannot be represented as a legacy sandbox policy; falling back to read-only" + ); + permission_profile = PermissionProfile::read_only(); + } + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + // `additional_writable_roots` is a legacy workspace-write knob. It + // only applies when the derived managed profile has workspace-style + // write access to the project roots; read-only, disabled, external, + // and future non-workspace profiles must not silently grow extra + // write access. + if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) + && file_system_sandbox_policy.can_write_path_with_cwd( + resolved_cwd.as_path(), + resolved_cwd.as_path(), + ) + && !file_system_sandbox_policy.has_full_disk_write_access() + { + // Keep legacy behavior for extra writable roots while storing + // the result as the canonical permission profile. Explicit + // extra roots are concrete paths, so their metadata carveouts + // are also concrete rather than symbolic `:project_roots` + // entries. + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); + permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &file_system_sandbox_policy, + network_sandbox_policy, + ); } - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &sandbox_policy, - resolved_cwd.as_path(), - ); - let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); - let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ); ( configured_network_proxy_config, permission_profile, diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 3692e31ee7..2db07a0fce 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -111,7 +111,11 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { } fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()) + FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ) } fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 5554b594d1..f2adf3d049 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -24,7 +24,10 @@ use std::process::Command; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; +use codex_protocol::protocol::FileSystemAccessMode; +use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxPolicy; +use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::WritableRoot; use codex_utils_absolute_path::AbsolutePathBuf; use globset::GlobBuilder; @@ -258,6 +261,35 @@ fn create_filesystem_args( read_only_subpaths: Vec::new(), }); } + let missing_auto_metadata_read_only_project_root_subpaths: HashSet = + file_system_sandbox_policy + .entries + .iter() + .filter(|entry| entry.access == FileSystemAccessMode::Read) + .filter_map(|entry| { + let FileSystemPath::Special { + value: + FileSystemSpecialPath::ProjectRoots { + subpath: Some(subpath), + }, + } = &entry.path + else { + return None; + }; + // Missing `.codex` remains protected so first-time project config + // creation still goes through the protected-path approval flow. + // Only the automatic repo-metadata read masks are skipped here: + // user-authored `read` rules for other subpaths and `none` rules + // should keep their normal bwrap behavior, which can mask the + // first missing component to prevent creation under writable roots. + let project_subpath = subpath.as_path(); + if project_subpath != Path::new(".git") && project_subpath != Path::new(".agents") { + return None; + } + let resolved = AbsolutePathBuf::resolve_path_against_base(subpath, cwd); + (!resolved.as_path().exists()).then(|| resolved.into_path_buf()) + }) + .collect(); let mut unreadable_roots = file_system_sandbox_policy .get_unreadable_roots_with_cwd(cwd) .into_iter() @@ -410,6 +442,7 @@ fn create_filesystem_args( .iter() .map(|path| path.as_path().to_path_buf()) .filter(|path| !unreadable_paths.contains(path)) + .filter(|path| !missing_auto_metadata_read_only_project_root_subpaths.contains(path)) .collect(); if let Some(target) = &symlink_target { read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target); @@ -1396,6 +1429,106 @@ mod tests { ); } + #[test] + fn skips_missing_project_root_metadata_carveouts_except_codex() { + let temp_dir = TempDir::new().expect("temp dir"); + let 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, + }, + ]); + + let args = + create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + let dot_git = path_to_string(&temp_dir.path().join(".git")); + let dot_agents = path_to_string(&temp_dir.path().join(".agents")); + let dot_codex = path_to_string(&temp_dir.path().join(".codex")); + + assert!(!args.args.iter().any(|arg| arg == &dot_git)); + assert!(!args.args.iter().any(|arg| arg == &dot_agents)); + assert!( + args.args + .windows(3) + .any(|window| { window == ["--ro-bind", "/dev/null", dot_codex.as_str()] }) + ); + } + + #[test] + fn missing_user_project_root_subpath_rules_are_still_enforced() { + let temp_dir = TempDir::new().expect("temp dir"); + let 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(".vscode".into())), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some(".secrets".into())), + }, + access: FileSystemAccessMode::None, + }, + ]); + + let args = + create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); + let dot_vscode = path_to_string(&temp_dir.path().join(".vscode")); + let dot_secrets = path_to_string(&temp_dir.path().join(".secrets")); + + assert!( + args.args + .windows(3) + .any(|window| { window == ["--ro-bind", "/dev/null", dot_vscode.as_str()] }) + ); + assert!( + args.args + .windows(3) + .any(|window| { window == ["--ro-bind", "/dev/null", dot_secrets.as_str()] }) + ); + } + #[test] fn mounts_dev_before_writable_dev_binds() { let sandbox_policy = SandboxPolicy::WorkspaceWrite { @@ -1427,7 +1560,7 @@ mod tests { "/".to_string(), // Mask the default protected .codex subpath under that writable // root. Because the root is `/` in this test, the carveout path - // appears as `/.codex`. + // appears at the filesystem root. "--ro-bind".to_string(), "/dev/null".to_string(), "/.codex".to_string(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 8f285159db..735131c5f5 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -408,55 +408,33 @@ impl PermissionProfile { /// The returned profile contains symbolic `:project_roots` entries that /// must be resolved against the active permission root before enforcement. pub fn workspace_write() -> Self { + Self::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ) + } + + /// Managed workspace-write filesystem access with the legacy + /// `sandbox_workspace_write` knobs applied directly to the profile. + /// + /// The returned profile contains symbolic `:project_roots` entries that + /// must be resolved against the active permission root before enforcement. + pub fn workspace_write_with( + writable_roots: &[AbsolutePathBuf], + network: NetworkSandboxPolicy, + exclude_tmpdir_env_var: bool, + exclude_slash_tmp: bool, + ) -> Self { + let file_system = FileSystemSandboxPolicy::workspace_write( + writable_roots, + exclude_tmpdir_env_var, + exclude_slash_tmp, + ); Self::Managed { - file_system: ManagedFileSystemPermissions::Restricted { - 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, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::SlashTmp, - }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Tmpdir, - }, - 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, - }, - ], - glob_scan_max_depth: None, - }, - network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system), + network, } } @@ -503,7 +481,15 @@ impl PermissionProfile { pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self { Self::from_runtime_permissions_with_enforcement( SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy), - &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy), + &FileSystemSandboxPolicy::from(sandbox_policy), + NetworkSandboxPolicy::from(sandbox_policy), + ) + } + + pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self { + Self::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy), + &FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd), NetworkSandboxPolicy::from(sandbox_policy), ) } diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index 7d389a9519..2ce5194e06 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -412,57 +412,65 @@ impl FileSystemSandboxPolicy { }) } - /// Converts a legacy sandbox policy into a cwd-independent filesystem policy. - /// - /// `WorkspaceWrite` uses symbolic project-root entries so callers can keep - /// the profile independent of the concrete root until it is resolved for a - /// turn or command. - pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self { - let mut file_system_policy = Self::from(sandbox_policy); - let SandboxPolicy::WorkspaceWrite { - writable_roots, - exclude_tmpdir_env_var, - exclude_slash_tmp, - .. - } = sandbox_policy - else { - return file_system_policy; - }; + /// 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, + }]; - prune_read_entries_under_writable_roots( - &mut file_system_policy.entries, - &legacy_non_cwd_writable_roots( - writable_roots, - *exclude_tmpdir_env_var, - *exclude_slash_tmp, - ), + 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 file_system_policy.entries, - ".git", - ); - append_default_read_only_project_root_subpath_if_no_explicit_rule( - &mut file_system_policy.entries, - ".agents", - ); - append_default_read_only_project_root_subpath_if_no_explicit_rule( - &mut file_system_policy.entries, - ".codex", - ); + 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 file_system_policy.entries, - protected_path, - ); + append_default_read_only_path_if_no_explicit_rule(&mut entries, protected_path); } } - file_system_policy + FileSystemSandboxPolicy::restricted(entries) } /// Converts a legacy sandbox policy into an equivalent filesystem policy @@ -475,12 +483,6 @@ impl FileSystemSandboxPolicy { 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 { - let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); - prune_read_entries_under_writable_roots( - &mut file_system_policy.entries, - &legacy_writable_roots, - ); - 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, @@ -635,6 +637,44 @@ impl FileSystemSandboxPolicy { 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, @@ -649,7 +689,7 @@ impl FileSystemSandboxPolicy { }; self.semantic_signature(cwd) - != FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&legacy_policy, cwd) + != legacy_runtime_file_system_policy_for_cwd(&legacy_policy, cwd) .semantic_signature(cwd) } @@ -1008,47 +1048,11 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy { exclude_tmpdir_env_var, exclude_slash_tmp, .. - } => { - 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, - }), - ); - FileSystemSandboxPolicy::restricted(entries) - } + } => FileSystemSandboxPolicy::workspace_write( + writable_roots, + *exclude_tmpdir_env_var, + *exclude_slash_tmp, + ), } } } @@ -1337,6 +1341,87 @@ fn default_read_only_subpaths_for_writable_root( 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, @@ -1373,58 +1458,6 @@ fn append_default_read_only_entry_if_no_explicit_rule( }); } -fn prune_read_entries_under_writable_roots( - entries: &mut Vec, - legacy_writable_roots: &[WritableRoot], -) { - entries.retain(|entry| { - if entry.access != FileSystemAccessMode::Read { - return true; - } - - match &entry.path { - FileSystemPath::Path { path } => !legacy_writable_roots - .iter() - .any(|root| root.is_path_writable(path.as_path())), - FileSystemPath::GlobPattern { .. } | FileSystemPath::Special { .. } => true, - } - }); -} - -fn legacy_non_cwd_writable_roots( - writable_roots: &[AbsolutePathBuf], - exclude_tmpdir_env_var: bool, - exclude_slash_tmp: bool, -) -> Vec { - let mut roots: Vec = writable_roots.to_vec(); - - if cfg!(unix) - && !exclude_slash_tmp - && let Ok(slash_tmp) = AbsolutePathBuf::from_absolute_path("/tmp") - && slash_tmp.as_path().is_dir() - { - roots.push(slash_tmp); - } - - if !exclude_tmpdir_env_var - && let Some(tmpdir) = std::env::var_os("TMPDIR") - && !tmpdir.is_empty() - && let Ok(tmpdir_path) = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)) - { - roots.push(tmpdir_path); - } - - dedup_absolute_paths(roots, /*normalize_effective_paths*/ true) - .into_iter() - .map(|root| WritableRoot { - read_only_subpaths: default_read_only_subpaths_for_writable_root( - &root, /*protect_missing_dot_codex*/ false, - ), - root, - }) - .collect() -} - fn has_explicit_resolved_path_entry( entries: &[ResolvedFileSystemEntry], path: &AbsolutePathBuf, @@ -1576,7 +1609,7 @@ mod tests { }; assert_eq!( - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy), + FileSystemSandboxPolicy::from(&policy), FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1729,6 +1762,24 @@ mod tests { }, 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, + }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: expected_dot_codex, @@ -2177,7 +2228,7 @@ mod tests { policy.needs_direct_runtime_enforcement(NetworkSandboxPolicy::Restricted, cwd.path(),) ); - let legacy_workspace_write = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + let legacy_workspace_write = legacy_runtime_file_system_policy_for_cwd( &SandboxPolicy::new_workspace_write_policy(), cwd.path(), ); @@ -2196,8 +2247,7 @@ mod tests { exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; - let legacy_order = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&legacy_policy, cwd.path()); + 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); @@ -2212,6 +2262,33 @@ mod tests { ); } + #[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()), + "true legacy runtime expansion should still classify as legacy-compatible" + ); + } + #[test] fn root_write_with_read_only_child_is_not_full_disk_write() { let cwd = TempDir::new().expect("tempdir"); @@ -2402,6 +2479,47 @@ mod tests { ); } + #[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); diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index b691485746..4962b1490a 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -828,7 +828,12 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { ); assert!( policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_0"), - "expected cwd .codex carveout in policy:\n{policy_text}", + "expected cwd metadata carveouts in policy:\n{policy_text}", + ); + assert!( + policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_1") + && policy_text.contains("WRITABLE_ROOT_0_EXCLUDED_2"), + "expected symbolic cwd .git/.agents carveouts in policy:\n{policy_text}", ); assert!( policy_text.contains("WRITABLE_ROOT_1_EXCLUDED_0") @@ -854,6 +859,20 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .join(".codex") .display() ), + format!( + "-DWRITABLE_ROOT_0_EXCLUDED_1={}", + cwd.canonicalize() + .expect("canonicalize cwd") + .join(".git") + .display() + ), + format!( + "-DWRITABLE_ROOT_0_EXCLUDED_2={}", + cwd.canonicalize() + .expect("canonicalize cwd") + .join(".agents") + .display() + ), format!( "-DWRITABLE_ROOT_1={}", vulnerable_root_canonical.to_string_lossy() @@ -1194,7 +1213,7 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { .map(|p| p.to_string_lossy().to_string()); let tempdir_policy_entry = if tmpdir_env_var.is_some() { - r#" (require-all (subpath (param "WRITABLE_ROOT_2")) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_1"))) )"# + r#" (require-all (subpath (param "WRITABLE_ROOT_2")) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_2_EXCLUDED_2"))) (require-not (subpath (param "WRITABLE_ROOT_2_EXCLUDED_2"))) )"# } else { "" }; @@ -1203,13 +1222,13 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { // Note that the policy includes: // - the base policy, // - read-only access to the filesystem, - // - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2. + // - write access to WRITABLE_ROOT_0 (but not its metadata subpaths), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2. let expected_policy = format!( r#"{MACOS_SEATBELT_BASE_POLICY} ; allow read-only file operations (allow file-read*) (allow file-write* -(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry} +(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1"))) (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_2"))) (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_2"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry} ) "#, @@ -1230,6 +1249,10 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { "-DWRITABLE_ROOT_0_EXCLUDED_1={}", dot_codex_canonical.to_string_lossy() ), + format!( + "-DWRITABLE_ROOT_0_EXCLUDED_2={}", + vulnerable_root_canonical.join(".agents").to_string_lossy() + ), format!( "-DWRITABLE_ROOT_1={}", PathBuf::from("/tmp") @@ -1247,6 +1270,10 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { )); expected_args.push(format!( "-DWRITABLE_ROOT_2_EXCLUDED_1={}", + vulnerable_root_canonical.join(".agents").to_string_lossy() + )); + expected_args.push(format!( + "-DWRITABLE_ROOT_2_EXCLUDED_2={}", dot_codex_canonical.to_string_lossy() )); }