Restore PowerShell's working directory in the MXC sandbox (#60647)

## Why

When a workspace has an unreadable ancestor, PowerShell can start with `$PWD` unset and its filesystem drive at the root, breaking relative paths.

## What changed

Prefix PowerShell commands in the MXC sandbox with a guarded workaround that restores the location from the process working directory when `$PWD` is unset. Preserve locations already initialized by startup or a profile.

## Testing

Extend the MXC PowerShell integration test to cover an unreadable ancestor, checking that `$PWD` matches the process working directory, relative file access works, and the parent and sibling file remain inaccessible.

GitOrigin-RevId: 4fb067db52a7f3c6e819169016fe6bd104a277a1
This commit is contained in:
zm-oai
2026-11-10 19:40:10 +00:00
committed by copyberry
parent a2b7c4a655
commit a92eae0891
3 changed files with 80 additions and 11 deletions
@@ -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
}
}
@@ -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<UnifiedExecRequest<'_>, 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
@@ -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(