diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 6b4d783f53b9..893b0cfc280d 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -45,10 +45,13 @@ pub(crate) use codex_sandboxing::is_likely_sandbox_denied; #[cfg(test)] use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox; use codex_sandboxing::record_filesystem_sandbox_violation; +#[cfg(test)] use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; +#[cfg(test)] use codex_sandboxing::resolve_windows_restricted_token_filesystem_overrides; #[cfg(test)] use codex_sandboxing::unsupported_windows_restricted_token_sandbox_reason; +#[cfg(any(test, target_os = "windows"))] use codex_sandboxing::windows_sandbox_uses_elevated_backend; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -379,7 +382,7 @@ pub fn build_exec_request( expiration, capture_policy, }; - let mut exec_req = manager + let request = manager .transform(SandboxTransformRequest { command, permissions: permission_profile, @@ -393,38 +396,13 @@ pub fn build_exec_request( windows_sandbox_level, windows_sandbox_private_desktop, }) - .map(|request| { - let windows_sandbox_workspace_roots = if windows_sandbox_workspace_roots.is_empty() { - vec![sandbox_cwd.clone()] - } else { - windows_sandbox_workspace_roots.to_vec() - }; - ExecRequest::from_sandbox_exec_request( - request, - options, - windows_sandbox_workspace_roots, - ) - }) .map_err(CodexErr::from)?; - let use_windows_elevated_backend = - windows_sandbox_uses_elevated_backend(exec_req.windows_sandbox_level); - exec_req.windows_sandbox_filesystem_overrides = if use_windows_elevated_backend { - resolve_windows_elevated_filesystem_overrides( - exec_req.sandbox, - &exec_req.permission_profile, - sandbox_cwd, - use_windows_elevated_backend, - ) + let windows_sandbox_workspace_roots = if windows_sandbox_workspace_roots.is_empty() { + vec![sandbox_cwd.clone()] } else { - resolve_windows_restricted_token_filesystem_overrides( - exec_req.sandbox, - &exec_req.permission_profile, - sandbox_cwd, - exec_req.windows_sandbox_level, - ) - } - .map_err(CodexErr::UnsupportedOperation)?; - Ok(exec_req) + windows_sandbox_workspace_roots.to_vec() + }; + ExecRequest::from_sandbox_exec_request(request, options, windows_sandbox_workspace_roots) } pub(crate) async fn execute_exec_request( diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index db9159613f50..f25320ac34b0 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -19,14 +19,19 @@ use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; use codex_network_proxy::RemoteNetworkProxyLaunchConfig; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::error::CodexErr; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::models::PermissionProfile; pub use codex_protocol::models::SandboxPermissions; use codex_sandboxing::SandboxExecRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::WindowsSandboxFilesystemOverrides; +use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; +use codex_sandboxing::resolve_windows_restricted_token_filesystem_overrides; +use codex_sandboxing::windows_sandbox_uses_elevated_backend; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; +use codex_utils_string::truncate_middle_with_token_budget; use std::collections::HashMap; #[derive(Debug)] @@ -112,7 +117,7 @@ impl ExecRequest { request: SandboxExecRequest, options: ExecOptions, windows_sandbox_workspace_roots: Vec, - ) -> Self { + ) -> Result { let SandboxExecRequest { command, cwd, @@ -131,6 +136,36 @@ impl ExecRequest { expiration, capture_policy, } = options; + let windows_sandbox_filesystem_overrides = if sandbox == SandboxType::WindowsRestrictedToken + { + let sandbox_policy_cwd = windows_sandbox_policy_cwd + .to_abs_path() + .map_err(|err| CodexErr::InvalidRequest(format!("invalid sandbox cwd: {err}")))?; + let use_windows_elevated_backend = + windows_sandbox_uses_elevated_backend(windows_sandbox_level); + if use_windows_elevated_backend { + resolve_windows_elevated_filesystem_overrides( + sandbox, + &permission_profile, + &sandbox_policy_cwd, + use_windows_elevated_backend, + ) + } else { + resolve_windows_restricted_token_filesystem_overrides( + sandbox, + &permission_profile, + &sandbox_policy_cwd, + windows_sandbox_level, + ) + } + .map_err(|error| { + CodexErr::UnsupportedOperation( + truncate_middle_with_token_budget(&error, /*max_tokens*/ 900).0, + ) + })? + } else { + None + }; let network_sandbox_policy = permission_profile.network_sandbox_policy(); if !network_sandbox_policy.is_enabled() { env.insert( @@ -142,7 +177,7 @@ impl ExecRequest { if sandbox == SandboxType::MacosSeatbelt { env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); } - Self { + Ok(Self { command, cwd, env, @@ -157,13 +192,13 @@ impl ExecRequest { windows_sandbox_level, windows_sandbox_private_desktop, permission_profile, - windows_sandbox_filesystem_overrides: None, + windows_sandbox_filesystem_overrides, arg0, exec_server_sandbox: None, exec_server_enforce_managed_network: false, exec_server_managed_network: None, exec_server_network_proxy: None, - } + }) } } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 499e0549959e..621cd83f3296 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -981,7 +981,7 @@ impl CoreShellCommandExecutor { exec_request, options, self.windows_sandbox_workspace_roots.clone(), - ); + )?; if let Some(network) = exec_request.network.as_ref() { network .apply_to_env_for_optional_environment( diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 2d20334f6446..6d5d89a6cc1e 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -453,11 +453,7 @@ impl<'a> SandboxAttempt<'a> { .iter() .map(PathUri::to_abs_path) .collect::>>()?; - Ok(crate::sandboxing::ExecRequest::from_sandbox_exec_request( - request, - options, - workspace_roots, - )) + crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options, workspace_roots) } pub fn env_for_exec_server( @@ -487,8 +483,11 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map_err(CodexErr::from)?; - let mut exec_request = - crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options, Vec::new()); + let mut exec_request = crate::sandboxing::ExecRequest::from_sandbox_exec_request( + request, + options, + Vec::new(), + )?; exec_request.exec_server_managed_network = managed_network; if self.sandbox_requested { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 2cfdec5b8e2c..79b007dce56c 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -5,6 +5,8 @@ use codex_network_proxy::ManagedNetworkSandboxContext; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; @@ -202,6 +204,107 @@ fn deny_read_blocks_explicit_escalation_and_policy_bypass() { ); } +#[test] +fn windows_sandbox_env_preserves_denied_reads_or_rejects_unsupported_backend() { + let temp_dir = tempfile::TempDir::new().expect("create sandbox workspace"); + let cwd = AbsolutePathBuf::from_absolute_path( + dunce::canonicalize(temp_dir.path()).expect("canonicalize sandbox workspace"), + ) + .expect("absolute sandbox workspace"); + let denied_path = cwd.join("blocked"); + std::fs::create_dir_all(denied_path.as_path()).expect("create denied directory"); + let denied_path = AbsolutePathBuf::from_absolute_path( + dunce::canonicalize(denied_path.as_path()).expect("canonicalize denied directory"), + ) + .expect("absolute denied directory"); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_path.clone(), + }, + access: FileSystemAccessMode::Deny, + missing_path_behavior: None, + }, + ]); + let permissions = codex_protocol::models::PermissionProfile::from_runtime_permissions( + &file_system_policy, + NetworkSandboxPolicy::Restricted, + ); + let cwd_uri = PathUri::from_abs_path(&cwd); + let manager = SandboxManager::new(); + let mut attempt = SandboxAttempt { + sandbox: SandboxType::WindowsRestrictedToken, + sandbox_requested: true, + permissions: &permissions, + exec_server_permissions: &permissions, + enforce_managed_network: false, + manager: &manager, + sandbox_cwd: &cwd_uri, + workspace_roots: std::slice::from_ref(&cwd_uri), + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Elevated, + windows_sandbox_private_desktop: false, + network_denial_cancellation_token: None, + network_proxy: None, + }; + let command = || SandboxCommand { + program: "cmd.exe".into(), + args: vec!["/C".to_string(), "echo sandboxed".to_string()], + cwd: cwd_uri.clone(), + env: HashMap::new(), + managed_network: None, + additional_permissions: None, + }; + let options = || crate::sandboxing::ExecOptions { + expiration: crate::exec::ExecExpiration::DefaultTimeout, + capture_policy: crate::exec::ExecCapturePolicy::ShellTool, + }; + + let request = attempt + .env_for( + command(), + options(), + /*network*/ None, + /*environment_id*/ None, + ) + .expect("prepare elevated Windows sandbox request"); + let overrides = request + .windows_sandbox_filesystem_overrides + .expect("elevated Windows sandbox should preserve deny-read overrides"); + assert_eq!(overrides.additional_deny_read_paths, vec![denied_path]); + assert_eq!(request.windows_sandbox_workspace_roots, vec![cwd]); + + attempt.windows_sandbox_level = + codex_protocol::config_types::WindowsSandboxLevel::RestrictedToken; + let error = attempt + .env_for( + command(), + options(), + /*network*/ None, + /*environment_id*/ None, + ) + .expect_err("restricted-token Windows sandbox cannot enforce deny-read restrictions"); + assert_eq!( + error.to_string(), + "unsupported operation: windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed" + ); +} + #[test] fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { let cwd: AbsolutePathBuf = std::env::current_dir() diff --git a/codex-rs/core/tests/suite/windows_sandbox.rs b/codex-rs/core/tests/suite/windows_sandbox.rs index e38fa60b842d..b0b5d0375ff3 100644 --- a/codex-rs/core/tests/suite/windows_sandbox.rs +++ b/codex-rs/core/tests/suite/windows_sandbox.rs @@ -4,6 +4,7 @@ use codex_core::exec::ExecParams; use codex_core::exec::process_exec_tool_call; use codex_core::sandboxing::SandboxPermissions; use codex_core::windows_sandbox::sandbox_setup_is_complete; +use codex_features::Feature; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::models::PermissionProfile; @@ -14,7 +15,16 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use core_test_support::PathExt; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; +use serde_json::json; use serial_test::serial; use std::collections::HashMap; use std::ffi::OsString; @@ -383,3 +393,156 @@ async fn windows_elevated_enforces_deny_read_and_protects_setup_marker() -> anyh ); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial(codex_home)] +async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() -> anyhow::Result<()> +{ + let codex_home = + codex_home_for_windows_sandbox_test("windows-elevated-tool-runtime-deny-read-codex-home")?; + let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str()); + stage_windows_sandbox_helpers()?; + + let configured_codex_home = dunce::canonicalize(codex_home.path())?.abs(); + let builder = test_codex() + .with_windows_cmd_shell() + .with_config(move |config| { + config.codex_home = configured_codex_home; + config.set_windows_elevated_sandbox_enabled(true); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow unified exec"); + + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::Deny, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: config.cwd.join("exact-secret.txt"), + }, + access: FileSystemAccessMode::Deny, + missing_path_behavior: None, + }, + ]); + config + .permissions + .set_permission_profile(PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + )) + .expect("set managed deny-read permission profile"); + }); + let harness = TestCodexHarness::with_builder(builder).await?; + harness + .write_file("secret.env", "glob secret should remain private\n") + .await?; + harness + .write_file("exact-secret.txt", "exact secret should remain private\n") + .await?; + harness.write_file("public.txt", "public ok\n").await?; + + let command = concat!( + "(type secret.env 1>NUL 2>NUL && echo GLOB-READ || echo GLOB-DENIED) & ", + "(type exact-secret.txt 1>NUL 2>NUL && echo EXACT-READ || echo EXACT-DENIED) & ", + "type public.txt" + ); + let shell_call_id = "windows-managed-deny-read-shell-command"; + let unified_call_id = "windows-managed-deny-read-exec-command"; + let shell_args = json!({ + "command": command, + "timeout_ms": 30_000, + "login": false, + }); + let unified_args = json!({ + "cmd": command, + "yield_time_ms": 30_000, + "tty": false, + "login": false, + }); + mount_sse_sequence( + harness.server(), + vec![ + sse(vec![ + ev_response_created("resp-windows-shell-deny-read"), + ev_function_call( + shell_call_id, + "shell_command", + &serde_json::to_string(&shell_args)?, + ), + ev_completed("resp-windows-shell-deny-read"), + ]), + sse(vec![ + ev_response_created("resp-windows-unified-deny-read"), + ev_function_call( + unified_call_id, + "exec_command", + &serde_json::to_string(&unified_args)?, + ), + ev_completed("resp-windows-unified-deny-read"), + ]), + sse(vec![ + ev_assistant_message("msg-windows-deny-read", "done"), + ev_completed("resp-windows-deny-read-complete"), + ]), + ], + ) + .await; + + let permission_profile = harness + .test() + .config + .permissions + .effective_permission_profile(); + harness + .submit_with_permission_profile("read the sandbox fixtures", permission_profile) + .await?; + + for (tool_name, call_id) in [ + ("shell_command", shell_call_id), + ("exec_command", unified_call_id), + ] { + let output = harness.function_call_stdout(call_id).await; + assert!( + output.contains("GLOB-DENIED"), + "{tool_name} should reject glob-denied reads: {output:?}" + ); + assert!( + output.contains("EXACT-DENIED"), + "{tool_name} should reject exact-path-denied reads: {output:?}" + ); + assert!( + output.contains("public ok"), + "{tool_name} should preserve allowed reads: {output:?}" + ); + assert!( + !output.contains("GLOB-READ") && !output.contains("glob secret"), + "{tool_name} leaked glob-denied file contents: {output:?}" + ); + assert!( + !output.contains("EXACT-READ") && !output.contains("exact secret"), + "{tool_name} leaked exact-path-denied file contents: {output:?}" + ); + } + + Ok(()) +} diff --git a/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs b/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs index e2e04ccd1bbe..b3ad915419b7 100644 --- a/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs +++ b/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs @@ -52,9 +52,21 @@ pub fn resolve_windows_deny_read_paths( return Ok(paths); }; - for pattern in unreadable_globs { + let scan_plans = unreadable_globs + .iter() + .map(|pattern| { + let scan_plan = glob_scan_plan(pattern, file_system_sandbox_policy.glob_scan_max_depth); + if scan_plan.max_depth.is_none() && scan_plan.root.parent().is_none() { + return Err(format!( + "unreadable glob `{pattern}` cannot be safely expanded from a filesystem root without `glob_scan_max_depth`; configure `glob_scan_max_depth` or use a non-root directory prefix" + )); + } + Ok(scan_plan) + }) + .collect::, String>>()?; + + for scan_plan in scan_plans { let mut seen_scan_dirs = HashSet::new(); - let scan_plan = glob_scan_plan(&pattern, file_system_sandbox_policy.glob_scan_max_depth); collect_existing_glob_matches( &scan_plan.root, &matcher, @@ -269,6 +281,35 @@ mod tests { ); } + #[test] + fn root_recursive_globs_without_depth_fail_before_expansion() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute cwd"); + let root = cwd.as_path().ancestors().last().expect("filesystem root"); + let pattern = root.join("**").join("*.env").display().to_string(); + let policy = + FileSystemSandboxPolicy::restricted(vec![unreadable_glob_entry(pattern.clone())]); + + assert_eq!( + resolve_windows_deny_read_paths(&policy, &cwd).expect_err("unbounded root glob"), + format!( + "unreadable glob `{pattern}` cannot be safely expanded from a filesystem root without `glob_scan_max_depth`; configure `glob_scan_max_depth` or use a non-root directory prefix" + ) + ); + } + + #[test] + fn configured_depth_bounds_root_recursive_glob_scans() { + let tmp = TempDir::new().expect("tempdir"); + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute cwd"); + let root = cwd.as_path().ancestors().last().expect("filesystem root"); + let pattern = root.join("**").join("*.env").display().to_string(); + let scan_plan = glob_scan_plan(&pattern, Some(2)); + + assert_eq!(scan_plan.root, root); + assert_eq!(scan_plan.max_depth, Some(2)); + } + #[test] fn exact_missing_paths_are_preserved() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index ad72c10497fa..3ef968aa57e5 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -16,6 +16,7 @@ use std::sync::OnceLock; use crate::allow::AllowDenyPaths; use crate::allow::compute_allow_paths_for_permissions; +use crate::deny_read_resolver::resolve_windows_deny_read_paths; use crate::helper_materialization::bundled_executable_path_for_exe; use crate::helper_materialization::helper_bin_dir; use crate::identity::sandbox_setup_is_complete; @@ -233,6 +234,8 @@ pub fn run_setup_refresh( else { return Ok(()); }; + let deny_read_paths = + setup_refresh_deny_read_paths(permission_profile, workspace_roots, command_cwd)?; run_setup_refresh_inner( SandboxSetupRequest { permissions: &permissions, @@ -241,7 +244,10 @@ pub fn run_setup_refresh( codex_home, proxy_enforced, }, - SetupRootOverrides::default(), + SetupRootOverrides { + deny_read_paths: Some(deny_read_paths), + ..SetupRootOverrides::default() + }, /*offline_proxy_settings_override*/ None, ) } @@ -271,6 +277,8 @@ pub fn run_setup_refresh_with_extra_read_roots( else { return Ok(()); }; + let deny_read_paths = + setup_refresh_deny_read_paths(permission_profile, workspace_roots, command_cwd)?; let mut read_roots = gather_read_roots(command_cwd, &permissions, env_map, codex_home); read_roots.extend(extra_read_roots); run_setup_refresh_inner( @@ -285,13 +293,32 @@ pub fn run_setup_refresh_with_extra_read_roots( read_roots: Some(read_roots), read_roots_include_platform_defaults: false, write_roots: Some(Vec::new()), - deny_read_paths: None, + deny_read_paths: Some(deny_read_paths), deny_write_paths: None, }, /*offline_proxy_settings_override*/ None, ) } +fn setup_refresh_deny_read_paths( + permission_profile: &PermissionProfile, + workspace_roots: &[AbsolutePathBuf], + command_cwd: &Path, +) -> Result> { + let (mut file_system, _) = permission_profile.to_runtime_permissions(); + file_system.remove_skip_missing_path_entries(); + let file_system = file_system.materialize_project_roots_with_workspace_roots(workspace_roots); + let command_cwd = AbsolutePathBuf::from_absolute_path(command_cwd)?; + resolve_windows_deny_read_paths(&file_system, &command_cwd) + .map(|paths| { + paths + .into_iter() + .map(AbsolutePathBuf::into_path_buf) + .collect() + }) + .map_err(|err| anyhow!(err)) +} + fn run_setup_refresh_inner( request: SandboxSetupRequest<'_>, overrides: SetupRootOverrides, @@ -1298,8 +1325,14 @@ mod tests { use crate::setup_error::SetupErrorReport; use crate::setup_error::extract_failure; use crate::setup_error::write_setup_error_report; + use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; + use codex_protocol::permissions::project_roots_glob_pattern; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -1556,6 +1589,93 @@ mod tests { } } + #[test] + fn setup_refresh_preserves_workspace_scoped_deny_read_paths() { + let tmp = TempDir::new().expect("tempdir"); + let workspace_root = tmp.path().join("workspace"); + let command_cwd = tmp.path().join("command-cwd"); + let denied_glob_match = workspace_root.join("app").join("secret.env"); + fs::create_dir_all(&command_cwd).expect("create command cwd"); + fs::create_dir_all(denied_glob_match.parent().expect("glob parent")) + .expect("create glob parent"); + fs::write(&denied_glob_match, "secret").expect("write denied glob match"); + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Read, + ), + FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some( + "private".to_string(), + )), + }, + FileSystemAccessMode::Deny, + ), + FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("**/*.env")), + }, + FileSystemAccessMode::Deny, + ), + ], + glob_scan_max_depth: None, + }, + network: NetworkSandboxPolicy::Restricted, + }; + + let deny_read_paths = super::setup_refresh_deny_read_paths( + &permission_profile, + workspace_roots_for(&workspace_root).as_slice(), + &command_cwd, + ) + .expect("resolve refresh deny-read paths"); + + assert_eq!( + deny_read_paths.into_iter().collect::>(), + [ + dunce::canonicalize(&workspace_root) + .expect("canonicalize workspace root") + .join("private"), + denied_glob_match, + ] + .into_iter() + .collect() + ); + } + + #[test] + fn setup_refresh_rejects_invalid_deny_read_globs() { + let tmp = TempDir::new().expect("tempdir"); + let workspace_root = tmp.path().join("workspace"); + fs::create_dir_all(&workspace_root).expect("create workspace"); + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("[z-a]")), + }, + FileSystemAccessMode::Deny, + )], + glob_scan_max_depth: None, + }, + network: NetworkSandboxPolicy::Restricted, + }; + + let err = super::setup_refresh_deny_read_paths( + &permission_profile, + workspace_roots_for(&workspace_root).as_slice(), + &workspace_root, + ) + .expect_err("invalid deny-read glob"); + + assert!(err.to_string().contains("invalid deny-read glob pattern")); + } + #[test] fn loopback_proxy_url_parsing_supports_common_forms() { assert_eq!(