Skip to content

fix(policy): stop granting the system drive when pwsh.exe is on PATH - #751

Open
Carlos Alexandro Becker (caarlos0) wants to merge 2 commits into
microsoft:mainfrom
caarlos0:windows-pwsh
Open

Carlos Alexandro Becker (caarlos0) wants to merge 2 commits into
microsoft:mainfrom
caarlos0:windows-pwsh

Conversation

@caarlos0

@caarlos0 Carlos Alexandro Becker (caarlos0) commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

powershell_policy (src/core/mxc_engine/src/policy.rs) and its SDK mirror getPowerShellPolicy (sdk/node/src/policy.ts) returned the system-drive root (C:\) as a read-only grant the moment pwsh.exe was found in any PATH directory.

That grant is recursive. Any sandbox seeded from these discovery helpers could read the entire volume — ~/.ssh, ~/.aws/credentials, .npmrc / .netrc tokens, browser profiles, and other users' profile directories — defeating the deny-by-default read confinement the container identity otherwise provides.

This PR removes the grant.

Why removing it is safe

The grant existed for one narrow reason: pwsh.exe before 7.7 calls GetFileAttributesW("C:\\") during startup. That is a metadata-only need, and it is already served host-wide — and narrowly — by wxc-host-prep prepare-system-drive, which stamps metadata-only, non-inheriting ACEs (FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE, no FILE_LIST_DIRECTORY) on the drive root. See docs/host-prep.md. The policy grant was a recursive, massively over-broad duplicate of a problem that already had a narrow solution.

Nothing else is lost. $PSHOME needs no special case: the directory holding pwsh.exe is by definition a PATH directory, so available_tools_policy already grants it read-only through the ordinary filter. A regression test pins exactly that.

Why not processContainer.filesystem.enumeratePaths

enumeratePaths is the right concept for a metadata-only need, but it cannot be emitted from a policy-discovery helper. Four blockers:

  1. It is processContainer.filesystem-scoped — backend-specific — while available_tools_policy is a cross-backend helper feeding the shared filesystem section.
  2. It requires schema exactly 0.9.0-alpha; any other version is a hard error (configs/process_container.rs).
  3. On a host without PSEC 1.1 + PSE_SUPPORT_FS_ENUMERATE the run is rejected outright — "enumeration-only access cannot fall back to AppContainer enforcement" (base_container_runner.rs).
  4. leastPrivilege forces the non-PSEC path, so the two can never combine (least_privilege_is_not_psec_compatible).

A discovery default that silently opts every caller into one schema version, one backend, and one OS capability — and hard-fails otherwise — is worse than no default. Callers that genuinely want enumeration-only access can pass enumeratePaths explicitly, where the version and capability requirements are visible to them.

Coupled change: the missing_root_readonly diagnostic

Removing the grant makes this launch diagnostic unsound, so it goes in the same commit.

It inferred "pwsh cannot read the root" purely from the absence of a root grant in readonlyPaths — a usable signal only while the discovery helpers still produced one. Now that they never do, the predicate is true for every pwsh.exe run, so an ordinary non-zero exit (a script error) would be reported to the user as missing_filesystem_access.

Its remediation text was also actively harmful: it told users to add C:\ to readonlyPaths by hand, i.e. to re-introduce this exact vulnerability.

It is removed rather than re-gated because the correct form of the hint already exists in fallback_detector.rs, gated on the real wxc-host-prep prepare-system-drive DACL state, fired on the AppContainer + DACL tier that actually needs those ACEs, and naming pwsh.exe explicitly:

AppContainer + DACL tier selected: AppContainer processes may be unable to read metadata of the system-drive root (e.g. cmd.exe, pwsh.exe, node.exe startup stats of C:\). Run wxc-host-prep prepare-system-drive (elevated) to grant the minimal metadata ACEs.

Coupled change: the PSReadLine write grant

The read-only discovery list was already filtered through is_system_critical_path / isSystemCriticalPath, but the PowerShell write grant bypassed that check entirely — and once the drive-root read grant is gone, that write path is the helper's entire output.

It is derived from USERPROFILE, which can legitimately sit inside %WINDIR%: the SYSTEM account's profile is C:\Windows\System32\config\systemprofile. A service-hosted run could therefore hand the sandbox write access beneath a protected system directory — strictly worse than the read grant this PR removes.

The write paths now go through the same system-critical filter on both sides. They stay deliberately out of the existence filter: PowerShell creates the PSReadLine history directory on first use, so requiring it to pre-exist would silently drop a legitimate grant.

Sample configs that taught the anti-pattern

tests/examples/08_pwsh.json and tests/configs/pwsh_setlocation.json both handed the sandbox "readonlyPaths": ["C:\\"]. Being hand-written configs, nothing filtered them, and as the canonical worked examples of running pwsh under MXC they taught the very pattern this PR removes. Neither needs it — PowerShell is reached through the explicit C:\Program Files\PowerShell\7 entry, and the startup root-metadata access comes from host prep.

The isolation_session_* configs that also mention C:\ deliberately pass over-broad paths to prove protected_paths_filter rejects them, and are untouched.

Testing

  • cargo test -p mxc_engine --lib — 121 pass. powershell_policy_grants_system_drive_root is renamed and inverted to powershell_policy_never_grants_the_drive_root; new available_tools_policy_still_grants_pshome_via_path proves $PSHOME survives via PATH while no drive root does; tool_paths_never_grant_write_access_under_windir and psreadline_write_grant_survives_when_the_directory_is_absent pin the two halves of the write-path filter. All are Windows-gated and run on the Windows CI job.
  • cargo fmt --all -- --check and cargo clippy -p mxc_engine -p appcontainer_common --all-targets --target x86_64-pc-windows-msvc -- -D warnings — clean, so the #[cfg(target_os = "windows")] code and its tests are compiled and linted from a non-Windows dev host too.
  • node scripts/versioning/validate-configs.js — 362 configs validate against the registered schemas.
  • npm test in sdk/node — 403 pass. The two SDK tests asserting the old behavior are inverted; a new one asserts $PSHOME is still granted via PATH. Two more cover the write-path filter; they are gated on a real Windows host rather than a mocked process.platform, because isSystemCriticalPath resolves %WINDIR% from process.env and normalizes with the host's path flavor — mocking process.platform does not turn the imported path module into path.win32. The 7 remaining failures are identical to those on a clean main (pre-existing, macOS-local; CI runs SDK unit tests on Linux and Windows), and this branch introduces none.
  • launch_diagnostics gains pwsh_nonzero_exit_reports_no_filesystem_diagnostic, pinning that a failing pwsh script is no longer misreported as a policy problem.

Follow-up (deliberately out of scope)

Both are pre-existing and independent of the pwsh grant, so they belong in their own changes:

  • is_system_critical_path / isSystemCriticalPath check %WINDIR% (Windows) and /bin-style paths (Unix), but never reject a filesystem root. A root arriving through PATH or a known tool/SDK variable is therefore still granted read-only.
  • temporary_files_policy / getTemporaryFilesPolicy put %TEMP% / $TMPDIR straight into readwritePaths with no system-critical check at all, so a TEMP=C:\ environment yields a read-write whole-volume grant. Separately, the TypeScript docstring there claims a unique per-sandbox subdirectory is created (and imports randomBytes for it), but the body returns the bare temp root.

Copilot AI balanced review requested due to automatic review settings August 5, 2026 18:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

PowerShell’s ordinary nonzero exits can now be misreported as missing root access, and Windows path hardening lacks effective automated coverage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Hardens tool discovery policies to avoid granting filesystem roots while retaining PowerShell functionality.

Changes:

  • Grants $PSHOME instead of the system-drive root.
  • Filters filesystem roots and Windows namespace variants.
  • Updates diagnostics and cross-platform policy tests.
File summaries
File Description
src/core/mxc_engine/src/policy.rs Hardens Rust path filtering and PowerShell discovery.
src/backends/appcontainer/common/src/launch_diagnostics.rs Revises PowerShell remediation guidance.
sdk/node/src/policy.ts Mirrors policy hardening in the TypeScript SDK.
sdk/node/tests/unit/policy.test.ts Updates PowerShell and root-filter tests.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread src/backends/appcontainer/common/src/launch_diagnostics.rs Outdated
Comment thread sdk/node/tests/unit/policy.test.ts Outdated
Copilot AI review requested due to automatic review settings August 5, 2026 18:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

PowerShell diagnostics can still misidentify failures and inconsistently compare the executable drive with the system drive.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (3)

src/backends/appcontainer/common/src/launch_diagnostics.rs:148

  • This still reports missing_filesystem_access for every nonzero pwsh.exe exit on an unprepared host, although the message itself says only versions before 7.7 need this access. For example, a pwsh 7.7+ script that intentionally exits 1 is misdiagnosed because neither the executable version nor access-denied evidence is checked. Please gate the process-exit heuristic on evidence that the affected startup path was hit; host-prep state alone is insufficient.
    if missing_root_metadata_access(exe_path, readonly_paths, || {
        crate::fallback_detector::system_drive_prepared()
    }) {

src/core/mxc_engine/src/policy.rs:196

  • The Rust tests only exercise C:\/C:/; they do not cover the newly added UNC, verbatim, or device-namespace stripping here, nor drive-relative resolution through std::path::absolute. The TypeScript path.win32 tests cannot validate Rust Path::components behavior. Please add Windows-target cases for these security-boundary spellings, including non-root/non-%WINDIR% controls.
        let n = n
            .strip_prefix(r"\\?\unc\")
            .or_else(|| n.strip_prefix(r"\\?\"))
            .or_else(|| n.strip_prefix(r"\\.\"))

src/backends/appcontainer/common/src/launch_diagnostics.rs:431

  • root is derived from the executable's drive, but drive_prepared() now probes only %SystemDrive% in fallback_detector. If pwsh is installed on D:, prepared C: ACEs suppress a diagnostic whose message names D:, while an explicit C:\ grant is not recognized when the system-drive probe fails. Use the same root for the policy check, DACL probe, and message—per docs/host-prep.md:61-68, the startup access being prepared is the system-drive root—or parameterize the probe with the root actually being tested.
    let root = drive_root(exe_path);
    let policy_grants_root = readonly_paths
        .iter()
        .any(|p| p.eq_ignore_ascii_case(&root) || p == "\\");
    !policy_grants_root && !drive_prepared()
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Copilot AI review requested due to automatic review settings August 6, 2026 12:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

PowerShell write paths still bypass critical-path filtering, and root-access diagnostics can evaluate inconsistent or equivalent root spellings incorrectly.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (2)

src/backends/appcontainer/common/src/launch_diagnostics.rs:431

  • These two checks can refer to different drives: root comes from the executable path, while drive_prepared() always probes %SystemDrive%. For pwsh.exe installed on D:, a prepared C: suppresses the diagnostic even though D: was never probed, and an explicit C:\ grant does not short-circuit it. Make the DACL probe accept/check the same root used for the policy comparison, or consistently base both checks and the message on the system-drive root that PowerShell actually stats.
    let root = drive_root(exe_path);
    let policy_grants_root = readonly_paths
        .iter()
        .any(|p| p.eq_ignore_ascii_case(&root) || p == "\\");
    !policy_grants_root && !drive_prepared()

src/backends/appcontainer/common/src/launch_diagnostics.rs:430

  • This textual comparison misses equivalent recursive root grants such as C:/, C:\., and verbatim-root spellings. Filesystem policy paths are not lexically normalized by the parser, so on an unprepared host a normal nonzero PowerShell exit with one of those grants is still misreported as missing root access. Normalize each policy path before deciding whether it denotes the relevant root; the new root-classification tests already establish these spellings as equivalent roots.
    let policy_grants_root = readonly_paths
        .iter()
        .any(|p| p.eq_ignore_ascii_case(&root) || p == "\\");
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread src/core/mxc_engine/src/policy.rs Outdated
Comment thread sdk/node/src/policy.ts Outdated
Copilot AI review requested due to automatic review settings August 6, 2026 12:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

PowerShell diagnostics still misidentify some failures, and the executable test fixture lacks the newly required host-preparation setup.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (4)

src/backends/appcontainer/common/src/launch_diagnostics.rs:431

  • The predicate still classifies every nonzero pwsh.exe exit on an unprepared host as missing root access, regardless of the executable version. That contradicts the new message's statement that 7.7+ does not require this access: a normal script error from 7.7+ will be replaced by this diagnostic whenever the host is intentionally unprepared. Gate this on the affected PowerShell versions or on failure evidence specific to the root metadata access.
    let policy_grants_root = readonly_paths
        .iter()
        .any(|p| p.eq_ignore_ascii_case(&root) || p == "\\");
    !policy_grants_root && !drive_prepared()

src/backends/appcontainer/common/src/launch_diagnostics.rs:149

  • This mixes two different roots: system_drive_prepared() probes %SystemDrive%, while drive_root(exe_path) and the policy check use the volume containing pwsh.exe. For a PowerShell installation on D:, an unprepared C: host reports that prepare-system-drive will add ACEs to D: (it will not), and an explicit D:\ grant suppresses the diagnostic even though the required system-drive metadata is still unavailable. Derive the displayed and policy-checked root from the same system-drive value used by the probe.
    if missing_root_metadata_access(exe_path, readonly_paths, || {
        crate::fallback_detector::system_drive_prepared()
    }) {
        let root = drive_root(exe_path);

src/core/mxc_engine/src/policy.rs:196

  • No Rust test exercises this newly added device-namespace branch (or the adjacent verbatim %WINDIR% handling); filesystem_roots_are_system_critical only checks plain drive/POSIX roots, while the comprehensive namespace matrix is TypeScript-only. Add Windows-targeted Rust assertions for \\?\C:\Windows, \\.\C:\Windows, and verbatim/device roots so the two policy implementations cannot silently diverge at this security boundary.
            .or_else(|| n.strip_prefix(r"\\.\"))

tests/configs/pwsh_setlocation.json:14

  • Removing the root grant makes this executable fixture depend on the host-prep ACEs for PowerShell versions before 7.7, but the documented test setup does not establish that prerequisite: scripts/setup-test-prereqs.ps1:122-136 reports all prerequisites met after only finding PowerShell 7, and run_pwsh_test.ps1 runs this config without checking host prep. Consequently the supported setup can still fail at PowerShell startup. Update the prerequisite setup/check to run or verify wxc-host-prep prepare-system-drive (or gate this fixture on a version that no longer needs it).
      "C:\\Users"
    ]
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Copilot AI review requested due to automatic review settings August 13, 2026 15:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (2)

src/backends/appcontainer/common/src/launch_diagnostics.rs:149

  • system_drive_prepared() probes %SystemDrive%, but this heuristic and its message derive the required root from the executable path. For pwsh.exe installed on D:, an explicit C:\ grant will not short-circuit, and an unprepared host tells users the command prepares D:\ even though prepare-system-drive defaults to C:\; conversely, a prepared C:\ suppresses the warning if D:\ were actually required. Use the same system-drive root for the policy check, DACL probe, and diagnostic text (matching the documented pwsh startup access).
    if missing_root_metadata_access(exe_path, readonly_paths, || {
        crate::fallback_detector::system_drive_prepared()
    }) {
        let root = drive_root(exe_path);

src/core/mxc_engine/src/policy.rs:196

  • The Rust security boundary added here is only tested for ordinary drive roots; there is no Rust coverage for UNC roots, drive-relative paths, or the verbatim/device prefixes handled by these lines. The TypeScript matrix does not validate std::path::absolute/Component behavior. Add Windows-gated Rust cases for the same namespace and %WINDIR% spellings so regressions in this implementation are caught.
        let n = n
            .strip_prefix(r"\\?\unc\")
            .or_else(|| n.strip_prefix(r"\\?\"))
            .or_else(|| n.strip_prefix(r"\\.\"))
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@bbonaby

Copy link
Copy Markdown
Collaborator

Caarlos does this one need to be reworked? or is it all good to be reviewed? I think I heard there was a c drive issue, not sure if this is related to that.

@bbonaby

Copy link
Copy Markdown
Collaborator

accidentally closed btw, but comment above still stands

@jsidewhite

Copy link
Copy Markdown
Member

Caarlos Are we okay to fully remove the C:\ from readonly?

@caarlos0

Copy link
Copy Markdown
Collaborator Author

Jeff Whiteside (@jsidewhite) - yes - will redo this

The policy-discovery helpers returned the system-drive root as a read-only
grant whenever pwsh.exe was found in any PATH directory. That grant is
recursive, so any sandbox seeded from these helpers could read the whole
volume: ~/.ssh, ~/.aws/credentials, .npmrc and .netrc tokens, browser
profiles, and other users' profile directories.

The grant existed to satisfy a metadata-only need: pwsh.exe before 7.7 stats
the drive root during startup. That need is already served host-wide, and
narrowly, by `wxc-host-prep prepare-system-drive`, which stamps
metadata-only, non-inheriting ACEs on the root.

Removing the grant costs nothing else. The directory that holds pwsh.exe is
by definition a PATH directory, so the ordinary discovery filter already
grants $PSHOME read-only.

Also removes the `missing_root_readonly` launch diagnostic. Its trigger was
"the drive root is absent from readonlyPaths", which is now true for every
pwsh.exe run, so an ordinary script error would have been reported as
`missing_filesystem_access`. Its remediation text also told users to add the
root to readonlyPaths by hand, recreating the vulnerability. The correctly
gated form of that hint already exists in `fallback_detector`, keyed on the
real host-prep DACL state and naming pwsh.exe explicitly.

The two shipped pwsh configs granted the drive root as well. As the canonical
worked examples of running pwsh under MXC they taught the very pattern this
change removes, and neither needs it.

Deliberately not moved to processContainer.filesystem.enumeratePaths: that
field is backend-specific, requires schema 0.9.0-alpha exactly, is rejected
outright on hosts without PSEC 1.1 PSE_SUPPORT_FS_ENUMERATE, and cannot
combine with leastPrivilege. A cross-backend discovery default must never
make a run fail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65848df5-ce23-4b2a-8697-2f7f6fafe106
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 18, 2026 13:27
@caarlos0 Carlos Alexandro Becker (caarlos0) changed the title fix(policy): stop granting the entire system drive when pwsh.exe is on PATH fix(policy): stop granting the system drive when pwsh.exe is on PATH Sep 18, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The Rust and Node helpers still allow PSReadLine write grants beneath system-critical directories.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity

Open (2)

Comment thread sdk/node/src/policy.ts Outdated
Comment thread src/core/mxc_engine/src/policy.rs Outdated
@caarlos0

Copy link
Copy Markdown
Collaborator Author

done

The read-only discovery list was filtered through is_system_critical_path /
isSystemCriticalPath, but the PowerShell write grant bypassed that check
entirely. USERPROFILE can legitimately sit under %WINDIR% -- the SYSTEM
account's profile is C:\Windows\System32\config\systemprofile -- so a
service-hosted run could hand the sandbox WRITE access beneath a protected
system directory. That is strictly worse than the read grant this branch
removes, and after that removal the write path is the helper's entire output.

The write paths now go through the same system-critical filter in both the
engine and the SDK. They are deliberately left out of the existence filter:
PowerShell creates the PSReadLine history directory on first use, so
requiring it to pre-exist would silently drop a legitimate grant. A
regression test pins each half.

Addresses the two unresolved review threads on sdk/node/src/policy.ts and
src/core/mxc_engine/src/policy.rs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 65848df5-ce23-4b2a-8697-2f7f6fafe106
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The security-sensitive grant is removed consistently with appropriate diagnostics and regression coverage.

Review effort: Balanced
Findings: None

Resolved since last review (2)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants