fix(policy): stop granting the system drive when pwsh.exe is on PATH - #751
Carlos Alexandro Becker (caarlos0) wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
🟡 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
$PSHOMEinstead 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.
There was a problem hiding this comment.
🟡 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_accessfor every nonzeropwsh.exeexit 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 throughstd::path::absolute. The TypeScriptpath.win32tests cannot validate RustPath::componentsbehavior. 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
rootis derived from the executable's drive, butdrive_prepared()now probes only%SystemDrive%infallback_detector. If pwsh is installed onD:, preparedC:ACEs suppress a diagnostic whose message namesD:, while an explicitC:\grant is not recognized when the system-drive probe fails. Use the same root for the policy check, DACL probe, and message—perdocs/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.
There was a problem hiding this comment.
🟡 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:
rootcomes from the executable path, whiledrive_prepared()always probes%SystemDrive%. Forpwsh.exeinstalled onD:, a preparedC:suppresses the diagnostic even thoughD:was never probed, and an explicitC:\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.
There was a problem hiding this comment.
🟡 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.exeexit 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%, whiledrive_root(exe_path)and the policy check use the volume containingpwsh.exe. For a PowerShell installation onD:, an unpreparedC:host reports thatprepare-system-drivewill add ACEs toD:(it will not), and an explicitD:\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_criticalonly 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-136reports all prerequisites met after only finding PowerShell 7, andrun_pwsh_test.ps1runs this config without checking host prep. Consequently the supported setup can still fail at PowerShell startup. Update the prerequisite setup/check to run or verifywxc-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.
There was a problem hiding this comment.
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. Forpwsh.exeinstalled onD:, an explicitC:\grant will not short-circuit, and an unprepared host tells users the command preparesD:\even thoughprepare-system-drivedefaults toC:\; conversely, a preparedC:\suppresses the warning ifD:\were actually required. Use the same system-drive root for the policy check, DACL probe, and diagnostic text (matching the documentedpwshstartup 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/Componentbehavior. 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
|
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. |
|
accidentally closed btw, but comment above still stands |
|
Caarlos Are we okay to fully remove the C:\ from readonly? |
|
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>
33e1967 to
2e8da4b
Compare
There was a problem hiding this comment.
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
Open (2)
|
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>

.github/copilot-instructions.md.Summary
powershell_policy(src/core/mxc_engine/src/policy.rs) and its SDK mirrorgetPowerShellPolicy(sdk/node/src/policy.ts) returned the system-drive root (C:\) as a read-only grant the momentpwsh.exewas found in anyPATHdirectory.That grant is recursive. Any sandbox seeded from these discovery helpers could read the entire volume —
~/.ssh,~/.aws/credentials,.npmrc/.netrctokens, 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.exebefore 7.7 callsGetFileAttributesW("C:\\")during startup. That is a metadata-only need, and it is already served host-wide — and narrowly — bywxc-host-prep prepare-system-drive, which stamps metadata-only, non-inheriting ACEs (FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE, noFILE_LIST_DIRECTORY) on the drive root. Seedocs/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.
$PSHOMEneeds no special case: the directory holdingpwsh.exeis by definition aPATHdirectory, soavailable_tools_policyalready grants it read-only through the ordinary filter. A regression test pins exactly that.Why not
processContainer.filesystem.enumeratePathsenumeratePathsis the right concept for a metadata-only need, but it cannot be emitted from a policy-discovery helper. Four blockers:processContainer.filesystem-scoped — backend-specific — whileavailable_tools_policyis a cross-backend helper feeding the sharedfilesystemsection.0.9.0-alpha; any other version is a hard error (configs/process_container.rs).PSE_SUPPORT_FS_ENUMERATEthe run is rejected outright — "enumeration-only access cannot fall back to AppContainer enforcement" (base_container_runner.rs).leastPrivilegeforces 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
enumeratePathsexplicitly, where the version and capability requirements are visible to them.Coupled change: the
missing_root_readonlydiagnosticRemoving 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 everypwsh.exerun, so an ordinary non-zero exit (a script error) would be reported to the user asmissing_filesystem_access.Its remediation text was also actively harmful: it told users to add
C:\toreadonlyPathsby 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 realwxc-host-prep prepare-system-driveDACL state, fired on the AppContainer + DACL tier that actually needs those ACEs, and namingpwsh.exeexplicitly: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 isC:\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.jsonandtests/configs/pwsh_setlocation.jsonboth 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 explicitC:\Program Files\PowerShell\7entry, and the startup root-metadata access comes from host prep.The
isolation_session_*configs that also mentionC:\deliberately pass over-broad paths to proveprotected_paths_filterrejects them, and are untouched.Testing
cargo test -p mxc_engine --lib— 121 pass.powershell_policy_grants_system_drive_rootis renamed and inverted topowershell_policy_never_grants_the_drive_root; newavailable_tools_policy_still_grants_pshome_via_pathproves$PSHOMEsurvives viaPATHwhile no drive root does;tool_paths_never_grant_write_access_under_windirandpsreadline_write_grant_survives_when_the_directory_is_absentpin the two halves of the write-path filter. All are Windows-gated and run on the Windows CI job.cargo fmt --all -- --checkandcargo 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 testinsdk/node— 403 pass. The two SDK tests asserting the old behavior are inverted; a new one asserts$PSHOMEis still granted viaPATH. Two more cover the write-path filter; they are gated on a real Windows host rather than a mockedprocess.platform, becauseisSystemCriticalPathresolves%WINDIR%fromprocess.envand normalizes with the host's path flavor — mockingprocess.platformdoes not turn the importedpathmodule intopath.win32. The 7 remaining failures are identical to those on a cleanmain(pre-existing, macOS-local; CI runs SDK unit tests on Linux and Windows), and this branch introduces none.launch_diagnosticsgainspwsh_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/isSystemCriticalPathcheck%WINDIR%(Windows) and/bin-style paths (Unix), but never reject a filesystem root. A root arriving throughPATHor a known tool/SDK variable is therefore still granted read-only.temporary_files_policy/getTemporaryFilesPolicyput%TEMP%/$TMPDIRstraight intoreadwritePathswith no system-critical check at all, so aTEMP=C:\environment yields a read-write whole-volume grant. Separately, the TypeScript docstring there claims a unique per-sandbox subdirectory is created (and importsrandomBytesfor it), but the body returns the bare temp root.