diff --git a/codex-rs/core/assets/tools/mxc_pwsh_cwd.ps1 b/codex-rs/core/assets/tools/mxc_pwsh_cwd.ps1 new file mode 100644 index 0000000000..2064788100 --- /dev/null +++ b/codex-rs/core/assets/tools/mxc_pwsh_cwd.ps1 @@ -0,0 +1,14 @@ +# PowerShell/PowerShell#27899: a denied ancestor can leave $PWD unset and the +# filesystem drive at its root. Use the process cwd without resolving it again. +# A successful startup or a profile that changed location has already set $PWD. +& { + if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { return } + if ($null -ne $ExecutionContext.SessionState.PSVariable.GetValue('global:PWD')) { return } + $drive = $ExecutionContext.SessionState.Drive.Current + $cwd = [Environment]::CurrentDirectory + if ($drive.Provider.Name -eq 'FileSystem' -and !$drive.CurrentLocation -and + $drive.Root -eq [IO.Path]::GetPathRoot($cwd) -and $cwd.Length -gt $drive.Root.Length) { + $drive.CurrentLocation = $cwd.Substring($drive.Root.Length) + Microsoft.PowerShell.Utility\Set-Variable -Name PWD -Scope Global -Value $ExecutionContext.SessionState.Path.CurrentLocation -Force + } +} diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 915bf0a378..ae986a721d 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -42,6 +42,7 @@ use crate::unified_exec::UnifiedExecProcess; use crate::unified_exec::UnifiedExecProcessManager; use codex_core_plugins::PluginMetricsSidecar; use codex_features::Feature; +use codex_file_system::WindowsSandboxSelection; use codex_network_proxy::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; use codex_network_proxy::ManagedNetworkSandboxContext; use codex_network_proxy::NetworkProxy; @@ -545,18 +546,28 @@ impl<'a> ToolRuntime, UnifiedExecAttempt> for UnifiedExec } // The executor selection is Disabled for non-Windows target paths, so this // preparation is a no-op for PowerShell on other platforms. - let command = prepare_powershell_command_for_windows_sandbox( + let windows_sandbox = executor_windows_sandbox_selection( + attempt.windows_sandbox_type, + attempt.windows_sandbox_level, + attempt.sandbox_cwd, + ); + let mut command = prepare_powershell_command_for_windows_sandbox( &command, Some(&requested_shell.shell_type), attempt.sandbox_requested, - executor_windows_sandbox_selection( - attempt.windows_sandbox_type, - attempt.windows_sandbox_level, - attempt.sandbox_cwd, - ), + windows_sandbox, environment_is_remote, ); let command = if matches!(requested_shell.shell_type, ShellType::PowerShell) { + if attempt.sandbox_requested + && windows_sandbox == WindowsSandboxSelection::Mxc + && let [_, .., flag, script] = command.as_mut_slice() + && flag.eq_ignore_ascii_case("-Command") + { + // Temporary workaround for https://github.com/PowerShell/PowerShell/issues/27899. + // Remove the prefix once supported PowerShell versions initialize $PWD under a denied ancestor. + script.insert_str(0, include_str!("../../../assets/tools/mxc_pwsh_cwd.ps1")); + } prefix_powershell_script_with_utf8(&command) } else { command diff --git a/codex-rs/core/tests/suite/unified_exec_mxc_powershell_tests.rs b/codex-rs/core/tests/suite/unified_exec_mxc_powershell_tests.rs index 42939e5ff3..97df49c024 100644 --- a/codex-rs/core/tests/suite/unified_exec_mxc_powershell_tests.rs +++ b/codex-rs/core/tests/suite/unified_exec_mxc_powershell_tests.rs @@ -3,6 +3,10 @@ use anyhow::Context; use anyhow::Result; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_shell_command::shell_detect::DetectedShell; use codex_shell_command::shell_detect::ShellType; use core_test_support::responses::mount_function_call_agent_response; @@ -13,8 +17,10 @@ use serde_json::json; use std::path::PathBuf; use std::time::Duration; +#[test_case::test_case(false; "store_fallback")] +#[test_case::test_case(true; "unreadable_ancestor")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn mxc_replaces_store_powershell_before_launch() -> Result<()> { +async fn mxc_exec_command_starts_powershell(restrict_parent: bool) -> Result<()> { if !codex_sandboxing::windows_mxc_available() { eprintln!("skipping test: native MXC is unavailable on this host"); return Ok(()); @@ -38,11 +44,49 @@ async fn mxc_replaces_store_powershell_before_launch() -> Result<()> { }); // Discovery is intentionally host-local; remote execution must not use the host's shell. let harness = TestCodexHarness::with_builder(builder).await?; + let (workdir, permissions, checks) = if restrict_parent { + let ancestor = harness.path_abs("ancestor"); + let workspace = ancestor.join("Downloads"); + std::fs::create_dir_all(&workspace)?; + std::fs::write(workspace.join("fixture.txt"), b"granted")?; + std::fs::write(ancestor.join("private.txt"), b"private")?; + let mut policy = FileSystemSandboxPolicy::read_only(); + policy.entries.extend([ + FileSystemSandboxEntry::new(ancestor.into(), FileSystemAccessMode::Deny), + FileSystemSandboxEntry::new(workspace.clone().into(), FileSystemAccessMode::Write), + ]); + let checks = r#" +$ErrorActionPreference = 'Stop' +if ($PWD.Path -ne [Environment]::CurrentDirectory) { throw "Unexpected PowerShell location: $PWD" } +if ((Get-Content -LiteralPath .\fixture.txt -Raw) -ne 'granted') { throw 'Relative read failed' } +$parent = [IO.Path]::GetDirectoryName([Environment]::CurrentDirectory) +try { + [void][IO.File]::GetAttributes($parent) + throw 'The workspace parent allowed an attribute read' +} catch [UnauthorizedAccessException] {} +try { + [void][IO.File]::ReadAllText([IO.Path]::Combine($parent, 'private.txt')) + throw 'The sibling was readable' +} catch [UnauthorizedAccessException] {} catch [IO.FileNotFoundException] {} +"#; + ( + workspace, + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + checks, + ) + } else { + ( + harness.path_abs(""), + PermissionProfile::workspace_write(), + "", + ) + }; let mock = mount_function_call_agent_response( harness.server(), "mxc-powershell", &json!({ - "cmd": "Set-Content -LiteralPath fallback.txt -Value mxc-powershell -NoNewline", + "cmd": format!("{checks}\nSet-Content -LiteralPath fallback.txt -Value mxc-powershell -NoNewline"), + "workdir": workdir, "login": false, "yield_time_ms": 10_000, }) @@ -53,7 +97,7 @@ async fn mxc_replaces_store_powershell_before_launch() -> Result<()> { harness .submit_with_permission_profile( "Write a file using PowerShell in the sandbox.", - PermissionProfile::workspace_write(), + permissions.clone(), ) .await?; @@ -89,7 +133,7 @@ async fn mxc_replaces_store_powershell_before_launch() -> Result<()> { harness .submit_with_permission_profile( "Wait for the PowerShell command to finish.", - PermissionProfile::workspace_write(), + permissions.clone(), ) .await?; let request = poll_mock.completion.single_request(); @@ -105,7 +149,7 @@ async fn mxc_replaces_store_powershell_before_launch() -> Result<()> { .context("timed out waiting for MXC PowerShell to exit")??; assert!(output.contains("Process exited with code 0"), "{output}"); assert_eq!( - std::fs::read_to_string(harness.path("fallback.txt"))?, + std::fs::read_to_string(workdir.join("fallback.txt"))?, "mxc-powershell" ); let metadata: serde_json::Value = serde_json::from_str(