From 511262b98445efbd7635253a0247611ebb0107b7 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 7 Aug 2026 17:53:43 +0000 Subject: [PATCH] Delegate remote process sandboxing to the executor (#37480) ## What changed - Preserve executor-native working directories, workspace roots, and permission profiles when preparing remote `exec_command` requests instead of resolving them through the host platform. - Send sandbox intent to the remote executor, including a restricted-token default for Windows executors and `-NoProfile` for elevated PowerShell commands. - Reject intercepted cross-platform `apply_patch` commands when filesystem writes are restricted, while continuing to allow them for unsandboxed or full-disk-write profiles. ## Testing Extended remote unified-exec coverage for foreign Windows workspaces, elevated PowerShell, sandbox metadata, and restricted and unrestricted intercepted patches. GitOrigin-RevId: 9fd328879fa2c15594fd58e40b6b32e8ba0847e7 --- codex-rs/apply-patch/src/lib.rs | 2 + .../handlers/unified_exec/exec_command.rs | 43 ++-- codex-rs/core/src/tools/orchestrator.rs | 13 +- codex-rs/core/src/tools/runtimes/mod.rs | 17 +- codex-rs/core/src/tools/runtimes/shell.rs | 2 +- .../core/src/tools/runtimes/unified_exec.rs | 6 +- codex-rs/core/src/tools/sandboxing.rs | 21 +- .../suite/unified_exec_process_events.rs | 224 ++++++++++++++++-- 8 files changed, 284 insertions(+), 44 deletions(-) diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 81536f4a9d..5cd3364ff2 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -25,6 +25,8 @@ use similar::TextDiff; pub use streaming_parser::StreamingPatchParser; use thiserror::Error; +pub use invocation::MaybeApplyPatch; +pub use invocation::maybe_parse_apply_patch; pub use invocation::maybe_parse_apply_patch_verified; pub use invocation::verify_apply_patch_args; pub use standalone_executable::main; diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index b0a1c2778f..d5fd89a210 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -152,23 +152,22 @@ impl ExecCommandHandler { let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); - // A foreign cwd cannot seed the AbsolutePathBufGuard used to resolve relative paths in the - // permissions config below. Consult the configured platform-sandbox requirement before - // deciding whether parsing may continue without that base path. - let sandbox = SandboxManager::new().select_initial( - turn_environment.permission_profile(), - SandboxablePreference::Auto, - turn.windows_sandbox_level, - turn.network.is_some(), - ); + // Remote executors enforce URI-native sandbox policy themselves. Only a host-local + // sandbox needs a native cwd for resolving paths nested in the permissions config. + let requires_host_native_cwd = !environment.is_remote() + && SandboxManager::new().select_initial( + turn_environment.permission_profile(), + SandboxablePreference::Auto, + turn.windows_sandbox_level, + turn.network.is_some(), + ) != SandboxType::None; // `to_abs_path()` alone cannot identify foreign drive paths: `file:///C:/repo` is // representable as `/C:/repo` on POSIX. Require the inferred convention to match too. let cwd_uses_native_convention = cwd.infer_path_convention() == Some(PathConvention::native()); - // TODO(anp): Remove this parsing split once sandboxing supports foreign paths. let native_cwd = match cwd.to_abs_path() { Ok(cwd) if cwd_uses_native_convention => Some(cwd), - _ if sandbox == SandboxType::None => None, + _ if !requires_host_native_cwd => None, Err(err) => return Err(FunctionCallError::RespondToModel(err.to_string())), Ok(_) => { return Err(FunctionCallError::RespondToModel(format!( @@ -183,9 +182,8 @@ impl ExecCommandHandler { parse_arguments_with_base_path(&arguments, native_cwd)? } None => { - // Parsing without a base only skips relative-path resolution inside the - // permissions config. That is safe only for a truly unsandboxed attempt; - // sandboxed attempts fall through and return the conversion error below. + // Foreign executor cwd values cannot seed this host's AbsolutePathBufGuard. + // Sandbox intent and URI-native roots are still sent to the executor. parse_arguments(&arguments)? } }; @@ -239,6 +237,23 @@ impl ExecCommandHandler { ) .map_err(FunctionCallError::RespondToModel)?; let command = resolved_command.command; + if environment.is_remote() + && !cwd_uses_native_convention + && !turn_environment + .permission_profile() + .file_system_sandbox_policy() + .has_full_disk_write_access() + && matches!( + codex_apply_patch::maybe_parse_apply_patch(&command, &cwd), + codex_apply_patch::MaybeApplyPatch::Body(_) + ) + { + // CA-781: patch verification reads executor files before process sandboxing applies. + manager.release_process_id(process_id).await; + return Err(FunctionCallError::RespondToModel( + "cross-platform remote apply_patch is unavailable until executor-side filesystem sandboxing is supported".to_string(), + )); + } let shell_type = resolved_command.shell_type; let command_for_display = codex_shell_command::parse_command::shlex_join(&command); diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 08858dda79..9e611767c4 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -151,8 +151,14 @@ impl ToolOrchestrator { let environment = tool.turn_environment(req); let workspace_roots = environment.workspace_roots(); + let executor_managed_process_sandbox = tool.uses_executor_managed_process_sandbox(req); let permission_profile = environment.permission_profile(); - let permissions = environment.permission_profile_with_workspace_roots(); + let permissions = if executor_managed_process_sandbox { + // Executor-native roots remain symbolic until the executor applies its own sandbox. + permission_profile.clone() + } else { + environment.permission_profile_with_workspace_roots() + }; let file_system_sandbox_policy = permissions.file_system_sandbox_policy(); let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| { default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy) @@ -230,7 +236,7 @@ impl ToolOrchestrator { managed_network_active, ), }; - let initial_sandbox = if sandbox_requested { + let initial_sandbox = if sandbox_requested && !executor_managed_process_sandbox { self.sandbox.select_initial( &permissions, sandbox_preference, @@ -415,7 +421,8 @@ impl ToolOrchestrator { sandbox_preference, managed_network_active, ); - let retry_sandbox = if retry_sandbox_requested { + let retry_sandbox = if retry_sandbox_requested && !executor_managed_process_sandbox + { self.sandbox.select_initial( &permissions, sandbox_preference, diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 9eb4f4b009..fe85cb7153 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -24,7 +24,6 @@ pub(crate) use codex_network_proxy::strip_managed_proxy_env; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_sandboxing::SandboxCommand; -use codex_sandboxing::SandboxType; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::collections::HashMap; @@ -179,11 +178,11 @@ pub(crate) fn apply_zsh_fork_path_prepend( pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox( command: &[String], shell_type: Option<&ShellType>, - sandbox: SandboxType, + sandbox_requested: bool, windows_sandbox_level: WindowsSandboxLevel, ) -> Vec { if shell_type != Some(&ShellType::PowerShell) - || sandbox != SandboxType::WindowsRestrictedToken + || !sandbox_requested || windows_sandbox_level != WindowsSandboxLevel::Elevated || command.is_empty() { @@ -428,7 +427,7 @@ mod disable_powershell_profile_tests { let rewritten = disable_powershell_profile_for_elevated_windows_sandbox( &command, Some(&ShellType::PowerShell), - SandboxType::WindowsRestrictedToken, + /*sandbox_requested*/ true, WindowsSandboxLevel::Elevated, ); @@ -454,7 +453,7 @@ mod disable_powershell_profile_tests { let rewritten = disable_powershell_profile_for_elevated_windows_sandbox( &command, Some(&ShellType::PowerShell), - SandboxType::WindowsRestrictedToken, + /*sandbox_requested*/ true, WindowsSandboxLevel::Elevated, ); @@ -481,7 +480,7 @@ mod disable_powershell_profile_tests { let rewritten = disable_powershell_profile_for_elevated_windows_sandbox( &command, Some(&ShellType::PowerShell), - SandboxType::WindowsRestrictedToken, + /*sandbox_requested*/ true, WindowsSandboxLevel::Elevated, ); @@ -499,7 +498,7 @@ mod disable_powershell_profile_tests { let rewritten = disable_powershell_profile_for_elevated_windows_sandbox( &command, Some(&ShellType::PowerShell), - SandboxType::WindowsRestrictedToken, + /*sandbox_requested*/ true, WindowsSandboxLevel::RestrictedToken, ); @@ -517,7 +516,7 @@ mod disable_powershell_profile_tests { let rewritten = disable_powershell_profile_for_elevated_windows_sandbox( &command, Some(&ShellType::PowerShell), - SandboxType::None, + /*sandbox_requested*/ false, WindowsSandboxLevel::Elevated, ); @@ -535,7 +534,7 @@ mod disable_powershell_profile_tests { let rewritten = disable_powershell_profile_for_elevated_windows_sandbox( &command, Some(&ShellType::Bash), - SandboxType::WindowsRestrictedToken, + /*sandbox_requested*/ true, WindowsSandboxLevel::Elevated, ); diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index e1aca77759..c5f5cf3e7d 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -237,7 +237,7 @@ impl ToolRuntime for ShellRuntime { let command = disable_powershell_profile_for_elevated_windows_sandbox( &command, req.shell_type.as_ref(), - attempt.sandbox, + attempt.sandbox_requested, attempt.windows_sandbox_level, ); let command = if matches!(shell.shell_type, ShellType::PowerShell) { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index d31e2b3d12..3e02e215c7 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -192,6 +192,10 @@ impl<'a> ToolRuntime for UnifiedExecRunt &req.turn_environment } + fn uses_executor_managed_process_sandbox(&self, req: &UnifiedExecRequest) -> bool { + req.turn_environment.environment.is_remote() + } + fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> { Some(&req.sandbox_cwd) } @@ -363,7 +367,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt let command = disable_powershell_profile_for_elevated_windows_sandbox( &command, Some(&req.shell_type), - attempt.sandbox, + attempt.sandbox_requested, attempt.windows_sandbox_level, ); let command = if matches!(req.shell_type, ShellType::PowerShell) { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 39d24aba21..04014405e3 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -15,6 +15,7 @@ use crate::tools::network_approval::NetworkApprovalSpec; use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; @@ -27,6 +28,7 @@ use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_tools::ToolName; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use futures::Future; use serde::Serialize; @@ -360,6 +362,10 @@ pub(crate) enum ToolError { pub(crate) trait ToolRuntime: Approvable + Sandboxable { fn turn_environment<'a>(&self, req: &'a Req) -> &'a TurnEnvironment; + fn uses_executor_managed_process_sandbox(&self, _req: &Req) -> bool { + false + } + fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option { None } @@ -472,11 +478,24 @@ impl<'a> SandboxAttempt<'a> { crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options, Vec::new()); exec_request.exec_server_managed_network = managed_network; if self.sandbox_requested { + // This level comes from the orchestrator's config, so `Disabled` means Windows + // sandboxing is irrelevant on a non-Windows host. A Windows executor would instead + // treat it as unable to enforce the requested sandbox. Select its baseline restricted + // token backend while preserving explicitly configured levels and same-OS behavior. + let windows_sandbox_level = if self.windows_sandbox_level + == WindowsSandboxLevel::Disabled + && self.sandbox_cwd.infer_path_convention() == Some(PathConvention::Windows) + && PathConvention::native() != PathConvention::Windows + { + WindowsSandboxLevel::RestrictedToken + } else { + self.windows_sandbox_level + }; exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), workspace_roots: self.workspace_roots.to_vec(), - windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, windows_sandbox_proxy_settings_mode: None, use_legacy_landlock: self.use_legacy_landlock, diff --git a/codex-rs/core/tests/suite/unified_exec_process_events.rs b/codex-rs/core/tests/suite/unified_exec_process_events.rs index c8f9f24874..99bb5ca806 100644 --- a/codex-rs/core/tests/suite/unified_exec_process_events.rs +++ b/codex-rs/core/tests/suite/unified_exec_process_events.rs @@ -6,10 +6,19 @@ use codex_config::test_support::CloudConfigBundleFixture; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::PermissionProfile; +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::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; use codex_protocol::user_input::UserInput; +use codex_utils_path_uri::PathUri; use core_test_support::managed_network_requirements_loader; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -43,9 +52,14 @@ const REPLAY_OUTPUT_EVENT_COUNT: u64 = 1024; const REPLAY_RETAINED_OUTPUT_SEQ: u64 = 800; #[derive(Debug, Clone, Copy)] +#[cfg_attr(windows, allow(dead_code))] enum PushedExecScenario { Complete, DirectDenied, + ElevatedPowerShell, + InterceptedPatch, + UnsandboxedInterceptedPatch, + FullDiskInterceptedPatch, LegacyExit, ReplayGap, } @@ -103,19 +117,31 @@ async fn accept_initialized_exec_server(listener: TcpListener) -> WebSocketStrea websocket } -async fn send_environment_info(websocket: &mut WebSocketStream) { +async fn send_environment_info( + websocket: &mut WebSocketStream, + scenario: PushedExecScenario, +) { let info = read_exec_server_json(websocket).await; assert_eq!(info["method"], "environment/info"); - respond_environment_info(websocket, &info["id"]).await; + respond_environment_info(websocket, &info["id"], scenario).await; } -async fn respond_environment_info(websocket: &mut WebSocketStream, id: &Value) { +async fn respond_environment_info( + websocket: &mut WebSocketStream, + id: &Value, + scenario: PushedExecScenario, +) { + let shell = if matches!(scenario, PushedExecScenario::ElevatedPowerShell) { + json!({ "name": "powershell", "path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" }) + } else { + json!({ "name": "zsh", "path": "/bin/zsh" }) + }; send_exec_server_json( websocket, json!({ "id": id, "result": { - "shell": { "name": "zsh", "path": "/bin/zsh" }, + "shell": shell, "capabilities": { "networkProxyLaunch": true } } }), @@ -127,15 +153,20 @@ async fn serve_exec_with_pushed_events( listener: TcpListener, scenario: PushedExecScenario, ) -> PushedExecServerResult { + let unrestricted_patch = matches!( + scenario, + PushedExecScenario::UnsandboxedInterceptedPatch + | PushedExecScenario::FullDiskInterceptedPatch + ); let mut websocket = accept_initialized_exec_server(listener).await; - send_environment_info(&mut websocket).await; + send_environment_info(&mut websocket, scenario).await; let process_start = loop { let request = read_exec_server_json(&mut websocket).await; match request["method"].as_str() { Some("process/start") => break request, Some("environment/info") => { - respond_environment_info(&mut websocket, &request["id"]).await; + respond_environment_info(&mut websocket, &request["id"], scenario).await; } Some("fs/getMetadata") => { send_exec_server_json( @@ -147,6 +178,24 @@ async fn serve_exec_with_pushed_events( ) .await; } + Some("fs/readFile") if unrestricted_patch => { + send_exec_server_json( + &mut websocket, + json!({ + "id": request["id"], + "error": { "code": -32004, "message": "not found" } + }), + ) + .await; + } + Some("fs/writeFile") if unrestricted_patch => { + send_exec_server_json(&mut websocket, json!({ "id": request["id"], "result": {} })) + .await; + return PushedExecServerResult { + process_read_requests: 0, + process_start: request, + }; + } Some("fs/canonicalize") => { send_exec_server_json( &mut websocket, @@ -229,7 +278,7 @@ async fn serve_exec_with_pushed_events( .await; match scenario { - PushedExecScenario::Complete => { + PushedExecScenario::Complete | PushedExecScenario::ElevatedPowerShell => { let encoded_output = BASE64_STANDARD.encode(COMPLETE_OUTPUT); for message in [ json!({ @@ -273,6 +322,13 @@ async fn serve_exec_with_pushed_events( ) .await; } + PushedExecScenario::InterceptedPatch => { + panic!("cross-platform intercepted patches must not start a remote process") + } + PushedExecScenario::UnsandboxedInterceptedPatch + | PushedExecScenario::FullDiskInterceptedPatch => { + panic!("unsandboxed intercepted patches must write through the remote filesystem") + } PushedExecScenario::LegacyExit => { send_exec_server_json( &mut websocket, @@ -319,6 +375,16 @@ async fn serve_exec_with_pushed_events( "failure": null, "sandboxDenied": true, }), + PushedExecScenario::ElevatedPowerShell => { + panic!("elevated remote PowerShell must not read a remote process") + } + PushedExecScenario::InterceptedPatch => { + panic!("cross-platform intercepted patches must not read a remote process") + } + PushedExecScenario::UnsandboxedInterceptedPatch + | PushedExecScenario::FullDiskInterceptedPatch => { + panic!("unsandboxed intercepted patches must not read a remote process") + } PushedExecScenario::LegacyExit => json!({ "chunks": [], "nextSeq": 3, @@ -390,17 +456,23 @@ async fn serve_exec_with_pushed_events( } } -#[test_case(PushedExecScenario::Complete, false, false ; "complete_event_stream")] -#[test_case(PushedExecScenario::DirectDenied, false, false ; "direct_sandbox_denial")] -#[test_case(PushedExecScenario::LegacyExit, false, false ; "legacy_exit_metadata")] -#[test_case(PushedExecScenario::ReplayGap, false, false ; "truncated_event_replay")] -#[test_case(PushedExecScenario::Complete, true, true ; "managed_network_uses_executor_proxy_launch")] -#[test_case(PushedExecScenario::Complete, true, false ; "strict_managed_allowlist_omits_policy_callbacks")] +#[test_case(PushedExecScenario::Complete, false, false, false ; "complete_event_stream")] +#[test_case(PushedExecScenario::DirectDenied, false, false, false ; "direct_sandbox_denial")] +#[test_case(PushedExecScenario::LegacyExit, false, false, false ; "legacy_exit_metadata")] +#[test_case(PushedExecScenario::ReplayGap, false, false, false ; "truncated_event_replay")] +#[test_case(PushedExecScenario::Complete, true, true, false ; "managed_network_uses_executor_proxy_launch")] +#[test_case(PushedExecScenario::Complete, true, false, false ; "strict_managed_allowlist_omits_policy_callbacks")] +#[cfg_attr(not(windows), test_case(PushedExecScenario::Complete, false, false, true ; "foreign_windows_workspace_sandbox"))] +#[test_case(PushedExecScenario::ElevatedPowerShell, false, false, true ; "windows_elevated_powershell_disables_profile")] +#[cfg_attr(not(windows), test_case(PushedExecScenario::InterceptedPatch, false, false, true ; "foreign_windows_intercepted_patch_fails_closed"))] +#[cfg_attr(not(windows), test_case(PushedExecScenario::UnsandboxedInterceptedPatch, false, false, true ; "foreign_windows_unsandboxed_intercepted_patch_succeeds"))] +#[cfg_attr(not(windows), test_case(PushedExecScenario::FullDiskInterceptedPatch, false, false, true ; "foreign_windows_full_disk_intercepted_patch_succeeds"))] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_command_consumes_pushed_remote_process_events( scenario: PushedExecScenario, managed_network: bool, policy_callbacks: bool, + foreign_cwd: bool, ) -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let server = start_mock_server().await; @@ -413,7 +485,16 @@ async fn exec_command_consumes_pushed_remote_process_events( CALL_ID, "exec_command", &json!({ - "cmd": "pwd", + "cmd": match scenario { + PushedExecScenario::InterceptedPatch => { + "apply_patch <<'PATCH'\n*** Begin Patch\n*** Update File: secret.txt\n@@\n-old\n+new\n*** End Patch\nPATCH" + } + PushedExecScenario::UnsandboxedInterceptedPatch + | PushedExecScenario::FullDiskInterceptedPatch => { + "apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: allowed.txt\n+allowed\n*** End Patch\nPATCH" + } + _ => "pwd", + }, "yield_time_ms": 1_000, }) .to_string(), @@ -481,6 +562,9 @@ timeout = 900 let mut builder = builder.with_config(move |config| { config.project_doc_max_bytes = 0; config.use_experimental_unified_exec_tool = true; + if matches!(scenario, PushedExecScenario::ElevatedPowerShell) { + config.set_windows_elevated_sandbox_enabled(/*value*/ true); + } if managed_network { config.approvals_reviewer = ApprovalsReviewer::AutoReview; config.bypass_hook_trust = true; @@ -496,6 +580,18 @@ timeout = 900 let turn_permission_profile = if managed_network { test.session_configured.permission_profile.clone() + } else if matches!(scenario, PushedExecScenario::FullDiskInterceptedPatch) { + PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new( + FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + FileSystemAccessMode::Write, + )]), + NetworkSandboxPolicy::Enabled, + ) + } else if foreign_cwd && !matches!(scenario, PushedExecScenario::UnsandboxedInterceptedPatch) { + PermissionProfile::workspace_write() } else { PermissionProfile::Disabled }; @@ -511,6 +607,21 @@ timeout = 900 responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + environments: foreign_cwd.then(|| { + let cwd = PathUri::parse("file:///C:/workspace").expect("valid Windows cwd"); + TurnEnvironmentSelections::new( + test.config.cwd.clone(), + vec![TurnEnvironmentSelection { + environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(), + cwd: cwd.clone(), + workspace_roots: vec![ + cwd, + PathUri::parse("file:///D:/other-workspace") + .expect("valid Windows workspace root"), + ], + }], + ) + }), approval_policy: Some(if managed_network { AskForApproval::OnRequest } else { @@ -546,6 +657,64 @@ timeout = 900 } } } + if matches!(scenario, PushedExecScenario::InterceptedPatch) { + assert!(!saw_exec_command_begin); + let request = response_mock + .last_request() + .context("model should receive the intercepted patch rejection")?; + let (output, success) = request + .function_call_output_content_and_success(CALL_ID) + .context("intercepted patch rejection should be model visible")?; + assert_ne!(success, Some(true)); + assert!( + output + .context("intercepted patch rejection should contain text")? + .contains("cross-platform remote apply_patch is unavailable") + ); + assert!( + !exec_server.is_finished(), + "intercepted patch must not access the executor filesystem" + ); + exec_server.abort(); + return Ok(()); + } + if matches!( + scenario, + PushedExecScenario::UnsandboxedInterceptedPatch + | PushedExecScenario::FullDiskInterceptedPatch + ) { + let request = response_mock + .last_request() + .context("model should receive the unrestricted patch result")?; + let (output, success) = request + .function_call_output_content_and_success(CALL_ID) + .context("unrestricted patch result should be model visible")?; + assert_ne!(success, Some(false)); + let output = output.context("unrestricted patch result should contain text")?; + assert!( + output.contains("Success. Updated the following files:"), + "unrestricted intercepted patch failed: {output}" + ); + let exec_server_result = timeout(Duration::from_secs(5), exec_server) + .await + .context("fake exec-server should observe the unrestricted patch write")??; + let write_request = exec_server_result.process_start; + assert_eq!(write_request["method"], "fs/writeFile"); + assert_eq!( + write_request["params"]["path"], + "file:///C:/workspace/allowed.txt" + ); + assert_eq!(write_request["params"]["sandbox"], Value::Null); + assert_eq!( + BASE64_STANDARD.decode( + write_request["params"]["dataBase64"] + .as_str() + .expect("filesystem write should include encoded contents") + )?, + b"allowed\n" + ); + return Ok(()); + } let cleanup_timeout = if managed_network { Duration::from_secs(15) } else { @@ -554,6 +723,26 @@ timeout = 900 let exec_server_result = timeout(cleanup_timeout, exec_server) .await .context("fake exec-server should observe process cleanup")??; + if foreign_cwd { + let params = &exec_server_result.process_start["params"]; + assert_eq!(params["cwd"], "file:///C:/workspace"); + assert_eq!(params["sandbox"]["cwd"], "file:///C:/workspace"); + assert_eq!( + params["sandbox"]["workspaceRoots"], + json!(["file:///C:/workspace", "file:///D:/other-workspace"]) + ); + if matches!(scenario, PushedExecScenario::ElevatedPowerShell) { + assert_eq!(params["sandbox"]["windowsSandboxLevel"], "elevated"); + assert!( + params["argv"] + .as_array() + .is_some_and(|argv| argv.iter().any(|arg| arg == "-NoProfile")), + "elevated remote PowerShell must not load a user profile" + ); + } else { + assert_eq!(params["sandbox"]["windowsSandboxLevel"], "restricted-token"); + } + } if managed_network { let params = &exec_server_result.process_start["params"]; assert_eq!(params["enforceManagedNetwork"], true); @@ -585,7 +774,7 @@ timeout = 900 let output = output.context("exec_command output should contain text")?; let process_read_requests = exec_server_result.process_read_requests; match scenario { - PushedExecScenario::Complete => { + PushedExecScenario::Complete | PushedExecScenario::ElevatedPowerShell => { assert_ne!(success, Some(false)); assert!(saw_exec_command_begin); assert!(output.contains("Process exited with code 0")); @@ -597,6 +786,11 @@ timeout = 900 assert!(output.contains("Process exited with code 1")); assert_eq!(process_read_requests, 0, "unexpected compatibility read"); } + PushedExecScenario::InterceptedPatch => unreachable!("intercepted patch returned early"), + PushedExecScenario::UnsandboxedInterceptedPatch + | PushedExecScenario::FullDiskInterceptedPatch => { + unreachable!("unsandboxed intercepted patch returned early") + } PushedExecScenario::LegacyExit => { assert!(!saw_exec_command_begin); assert!(output.contains("Process exited with code 1"));