diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2cb02916f..fa9ec8577 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1649,9 +1649,10 @@ impl CodexMessageProcessor { None => ExecExpiration::DefaultTimeout, } }; + let sandbox_cwd = self.config.cwd.clone(); let exec_params = ExecParams { command, - cwd, + cwd: cwd.clone(), expiration, env, network: started_network_proxy @@ -1672,7 +1673,7 @@ impl CodexMessageProcessor { Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { Ok(()) => { let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from(&policy); + codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, &sandbox_cwd); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); (policy, file_system_sandbox_policy, network_sandbox_policy) @@ -1697,7 +1698,6 @@ impl CodexMessageProcessor { let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); let request_for_task = request.clone(); - let sandbox_cwd = self.config.cwd.clone(); let started_network_proxy_for_task = started_network_proxy; let use_linux_sandbox_bwrap = self.config.features.enabled(Feature::UseLinuxSandboxBwrap); let size = match size.map(crate::command_exec::terminal_size_from_protocol) { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 52411a4d4..ed119aabf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -951,6 +951,11 @@ impl SessionConfiguration { pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult { let mut next_configuration = self.clone(); + let file_system_policy_matches_legacy = self.file_system_sandbox_policy + == FileSystemSandboxPolicy::from_legacy_sandbox_policy( + self.sandbox_policy.get(), + &self.cwd, + ); if let Some(collaboration_mode) = updates.collaboration_mode.clone() { next_configuration.collaboration_mode = collaboration_mode; } @@ -966,18 +971,29 @@ impl SessionConfiguration { if let Some(approval_policy) = updates.approval_policy { next_configuration.approval_policy.set(approval_policy)?; } + let mut sandbox_policy_changed = false; if let Some(sandbox_policy) = updates.sandbox_policy.clone() { next_configuration.sandbox_policy.set(sandbox_policy)?; - next_configuration.file_system_sandbox_policy = - FileSystemSandboxPolicy::from(next_configuration.sandbox_policy.get()); next_configuration.network_sandbox_policy = NetworkSandboxPolicy::from(next_configuration.sandbox_policy.get()); + sandbox_policy_changed = true; } if let Some(windows_sandbox_level) = updates.windows_sandbox_level { next_configuration.windows_sandbox_level = windows_sandbox_level; } + let mut cwd_changed = false; if let Some(cwd) = updates.cwd.clone() { next_configuration.cwd = cwd; + cwd_changed = true; + } + if sandbox_policy_changed || (cwd_changed && file_system_policy_matches_legacy) { + // Preserve richer split policies across cwd-only updates; only + // rederive when the session is already using the legacy bridge. + next_configuration.file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + next_configuration.sandbox_policy.get(), + &next_configuration.cwd, + ); } if let Some(app_server_client_name) = updates.app_server_client_name.clone() { next_configuration.app_server_client_name = Some(app_server_client_name); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index fc6aab120..66e6cd27b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -17,6 +17,13 @@ use crate::tools::format_exec_output_str; use codex_protocol::ThreadId; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; use tracing::Span; @@ -1886,6 +1893,100 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati } } +#[tokio::test] +async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_only_update() { + let mut session_configuration = make_session_configuration_for_tests().await; + let workspace = tempfile::tempdir().expect("create temp dir"); + let project_root = workspace.path().join("project"); + let original_cwd = project_root.join("subdir"); + let docs_dir = original_cwd.join("docs"); + std::fs::create_dir_all(&docs_dir).expect("create docs dir"); + let docs_dir = + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + + session_configuration.cwd = original_cwd; + session_configuration.sandbox_policy = + codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![docs_dir.clone()], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }); + session_configuration.file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs_dir }, + access: FileSystemAccessMode::Read, + }, + ]); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + cwd: Some(project_root), + ..Default::default() + }) + .expect("cwd-only update should succeed"); + + assert_eq!( + updated.file_system_sandbox_policy, + session_configuration.file_system_sandbox_policy + ); +} + +#[tokio::test] +async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_update() { + let mut session_configuration = make_session_configuration_for_tests().await; + let workspace = tempfile::tempdir().expect("create temp dir"); + let project_root = workspace.path().join("project"); + let original_cwd = project_root.join("subdir"); + let docs_dir = original_cwd.join("docs"); + std::fs::create_dir_all(&docs_dir).expect("create docs dir"); + let docs_dir = + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + + session_configuration.cwd = original_cwd; + session_configuration.sandbox_policy = + codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![docs_dir], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }); + session_configuration.file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + session_configuration.sandbox_policy.get(), + &session_configuration.cwd, + ); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + cwd: Some(project_root.clone()), + ..Default::default() + }) + .expect("cwd-only update should succeed"); + + assert_eq!( + updated.file_system_sandbox_policy, + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + updated.sandbox_policy.get(), + &project_root, + ) + ); +} + #[tokio::test] async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let codex_home = tempfile::tempdir().expect("create temp dir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f7ef78b26..91c466550 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2004,7 +2004,8 @@ impl Config { } } } - let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + let file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, &resolved_cwd); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); ( configured_network_proxy_config, @@ -2330,7 +2331,10 @@ impl Config { if effective_sandbox_policy == original_sandbox_policy { file_system_sandbox_policy } else { - FileSystemSandboxPolicy::from(&effective_sandbox_policy) + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &effective_sandbox_policy, + &resolved_cwd, + ) }; let effective_network_sandbox_policy = if effective_sandbox_policy == original_sandbox_policy { diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index d022adbc7..e90daf37f 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -33,7 +33,8 @@ pub async fn spawn_command_under_linux_sandbox

( where P: AsRef, { - let file_system_sandbox_policy = FileSystemSandboxPolicy::from(sandbox_policy); + let file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd); let network_sandbox_policy = NetworkSandboxPolicy::from(sandbox_policy); let args = create_linux_sandbox_command_args_for_policies( command, diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index be32c09cb..7f39a7f6e 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -204,6 +204,7 @@ mod tests { use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::RejectConfig; use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; use tempfile::TempDir; #[test] @@ -405,4 +406,47 @@ mod tests { SafetyCheck::AskUser, ); } + + #[test] + fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path().to_path_buf(); + let blocked_path = cwd.join("docs").join("blocked.txt"); + let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd).unwrap(); + let action = ApplyPatchAction::new_add_for_test(&blocked_path, "".to_string()); + let sandbox_policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Restricted, + }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: docs_absolute, + }, + access: FileSystemAccessMode::Read, + }, + ]); + + assert!(!is_write_patch_constrained_to_writable_paths( + &action, + &file_system_sandbox_policy, + &cwd, + )); + assert_eq!( + assess_patch_safety( + &action, + AskForApproval::OnRequest, + &sandbox_policy, + &file_system_sandbox_policy, + &cwd, + WindowsSandboxLevel::Disabled, + ), + SafetyCheck::AskUser, + ); + } } diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 8550bf8d7..dede3d055 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -331,10 +331,9 @@ pub(crate) fn create_seatbelt_command_args( enforce_managed_network: bool, network: Option<&NetworkProxy>, ) -> Vec { - create_seatbelt_command_args_for_policies_with_extensions( + create_seatbelt_command_args_with_extensions( command, - &FileSystemSandboxPolicy::from(sandbox_policy), - NetworkSandboxPolicy::from(sandbox_policy), + sandbox_policy, sandbox_policy_cwd, enforce_managed_network, network, @@ -410,7 +409,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions( ) -> Vec { create_seatbelt_command_args_for_policies_with_extensions( command, - &FileSystemSandboxPolicy::from(sandbox_policy), + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, enforce_managed_network, @@ -596,6 +595,7 @@ mod tests { use super::normalize_path_for_sandbox; use super::unix_socket_dir_params; use super::unix_socket_policy; + use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::seatbelt_permissions::MacOsAutomationPermission; @@ -865,6 +865,40 @@ sys.exit(0 if allowed else 13) assert!(!policy.contains("(allow user-preference-write)")); } + #[test] + fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = tmp.path().join("workspace"); + fs::create_dir_all(cwd.join("docs")).expect("create docs"); + let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); + let args = create_seatbelt_command_args( + vec!["/bin/true".to_string()], + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![docs.clone()], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, + cwd.as_path(), + false, + None, + ); + + let docs_param = format!("-DWRITABLE_ROOT_0_RO_0={}", docs.as_path().display()); + assert!( + !seatbelt_policy_arg(&args).contains("WRITABLE_ROOT_0_RO_0"), + "legacy workspace-write readable roots under cwd should not become seatbelt carveouts:\n{args:#?}" + ); + assert!( + !args.iter().any(|arg| arg == &docs_param), + "unexpected seatbelt carveout parameter for redundant legacy readable root: {args:#?}" + ); + } + #[test] fn seatbelt_args_default_extension_profile_keeps_preferences_read_access() { let cwd = std::env::temp_dir(); diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 2f1b1b6b3..e6304d2d6 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -233,7 +233,10 @@ fn resolve_sandbox_policies( } } (Some(sandbox_policy), None) => EffectiveSandboxPolicies { - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), + file_system_sandbox_policy: FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &sandbox_policy, + sandbox_policy_cwd, + ), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), sandbox_policy, }, diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index 4e2f2fbad..abe9bc581 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -163,6 +163,34 @@ impl FileSystemSandboxPolicy { } } + /// Converts a legacy sandbox policy into an equivalent filesystem policy + /// for the provided cwd. + /// + /// Legacy `WorkspaceWrite` policies may list readable roots that live + /// under an already-writable root. Those paths were redundant in the + /// legacy model and should not become read-only carveouts when projected + /// into split filesystem policy. + pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self { + let mut file_system_policy = Self::from(sandbox_policy); + if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { + let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); + file_system_policy.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::Special { .. } => true, + } + }); + } + + file_system_policy + } + /// Returns true when filesystem reads are unrestricted. pub fn has_full_disk_read_access(&self) -> bool { match self.kind { @@ -236,7 +264,13 @@ impl FileSystemSandboxPolicy { } let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); - let unreadable_roots = self.get_unreadable_roots_with_cwd(cwd); + let read_only_roots = dedup_absolute_paths( + self.entries + .iter() + .filter(|entry| !entry.access.can_write()) + .filter_map(|entry| resolve_file_system_path(&entry.path, cwd_absolute.as_ref())) + .collect(), + ); let mut writable_roots = Vec::new(); if self.has_root_access(FileSystemAccessMode::can_write) && let Some(cwd_absolute) = cwd_absolute.as_ref() @@ -260,9 +294,13 @@ impl FileSystemSandboxPolicy { .into_iter() .map(|root| { let mut read_only_subpaths = default_read_only_subpaths_for_writable_root(&root); + // Narrower explicit non-write entries carve out broader writable roots. + // More specific write entries still remain writable because they appear + // as separate WritableRoot values and are checked independently. read_only_subpaths.extend( - unreadable_roots + read_only_roots .iter() + .filter(|path| path.as_path() != root.as_path()) .filter(|path| path.as_path().starts_with(root.as_path())) .cloned(), ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 923380199..f9b7e88eb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3217,12 +3217,6 @@ mod tests { use tempfile::NamedTempFile; use tempfile::TempDir; - fn sorted_paths(paths: Vec) -> Vec { - let mut sorted: Vec = paths.into_iter().map(|path| path.to_path_buf()).collect(); - sorted.sort(); - sorted - } - fn sorted_writable_roots(roots: Vec) -> Vec<(PathBuf, Vec)> { let mut sorted_roots: Vec<(PathBuf, Vec)> = roots .into_iter() @@ -3240,6 +3234,53 @@ mod tests { sorted_roots } + fn sandbox_policy_allows_read(policy: &SandboxPolicy, path: &Path, cwd: &Path) -> bool { + if policy.has_full_disk_read_access() { + return true; + } + + policy + .get_readable_roots_with_cwd(cwd) + .iter() + .any(|root| path.starts_with(root.as_path())) + || policy + .get_writable_roots_with_cwd(cwd) + .iter() + .any(|root| path.starts_with(root.root.as_path())) + } + + fn sandbox_policy_allows_write(policy: &SandboxPolicy, path: &Path, cwd: &Path) -> bool { + if policy.has_full_disk_write_access() { + return true; + } + + policy + .get_writable_roots_with_cwd(cwd) + .iter() + .any(|root| root.is_path_writable(path)) + } + + fn sandbox_policy_probe_paths(policy: &SandboxPolicy, cwd: &Path) -> Vec { + let mut paths = vec![cwd.to_path_buf()]; + paths.extend( + policy + .get_readable_roots_with_cwd(cwd) + .into_iter() + .map(|path| path.to_path_buf()), + ); + for root in policy.get_writable_roots_with_cwd(cwd) { + paths.push(root.root.to_path_buf()); + paths.extend( + root.read_only_subpaths + .into_iter() + .map(|path| path.to_path_buf()), + ); + } + paths.sort(); + paths.dedup(); + paths + } + fn assert_same_sandbox_policy_semantics( expected: &SandboxPolicy, actual: &SandboxPolicy, @@ -3261,14 +3302,25 @@ mod tests { actual.include_platform_defaults(), expected.include_platform_defaults() ); - assert_eq!( - sorted_paths(actual.get_readable_roots_with_cwd(cwd)), - sorted_paths(expected.get_readable_roots_with_cwd(cwd)) - ); - assert_eq!( - sorted_writable_roots(actual.get_writable_roots_with_cwd(cwd)), - sorted_writable_roots(expected.get_writable_roots_with_cwd(cwd)) - ); + let mut probe_paths = sandbox_policy_probe_paths(expected, cwd); + probe_paths.extend(sandbox_policy_probe_paths(actual, cwd)); + probe_paths.sort(); + probe_paths.dedup(); + + for path in probe_paths { + assert_eq!( + sandbox_policy_allows_read(actual, &path, cwd), + sandbox_policy_allows_read(expected, &path, cwd), + "read access mismatch for {}", + path.display() + ); + assert_eq!( + sandbox_policy_allows_write(actual, &path, cwd), + sandbox_policy_allows_write(expected, &path, cwd), + "write access mismatch for {}", + path.display() + ); + } } #[test] @@ -3515,6 +3567,67 @@ mod tests { ); } + #[test] + fn restricted_file_system_policy_treats_read_entries_as_read_only_subpaths() { + let cwd = TempDir::new().expect("tempdir"); + let docs = + AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs"); + let docs_public = AbsolutePathBuf::resolve_path_against_base("docs/public", cwd.path()) + .expect("resolve docs/public"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: docs.clone() }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: docs_public.clone(), + }, + access: FileSystemAccessMode::Write, + }, + ]); + + assert!(!policy.has_full_disk_write_access()); + assert_eq!( + sorted_writable_roots(policy.get_writable_roots_with_cwd(cwd.path())), + vec![ + (cwd.path().to_path_buf(), vec![docs.to_path_buf()]), + (docs_public.to_path_buf(), Vec::new()), + ] + ); + } + + #[test] + fn legacy_workspace_write_nested_readable_root_stays_writable() { + let cwd = TempDir::new().expect("tempdir"); + let docs = + AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()).expect("resolve docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![docs], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + assert_eq!( + sorted_writable_roots( + FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, cwd.path()) + .get_writable_roots_with_cwd(cwd.path()) + ), + vec![(cwd.path().to_path_buf(), Vec::new())] + ); + } + #[test] fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() { let cwd = if cfg!(windows) { @@ -3552,6 +3665,8 @@ mod tests { .expect("resolve readable root"); let writable_root = AbsolutePathBuf::resolve_path_against_base("writable", cwd.path()) .expect("resolve writable root"); + let nested_readable_root = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()) + .expect("resolve nested readable root"); let policies = [ SandboxPolicy::DangerFullAccess, SandboxPolicy::ExternalSandbox { @@ -3588,10 +3703,20 @@ mod tests { exclude_tmpdir_env_var: false, exclude_slash_tmp: true, }, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: ReadOnlyAccess::Restricted { + include_platform_defaults: true, + readable_roots: vec![nested_readable_root], + }, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, ]; for expected in policies { - let actual = FileSystemSandboxPolicy::from(&expected) + let actual = FileSystemSandboxPolicy::from_legacy_sandbox_policy(&expected, cwd.path()) .to_legacy_sandbox_policy(NetworkSandboxPolicy::from(&expected), cwd.path()) .expect("legacy bridge should preserve legacy policy semantics");