fix(policy): stop granting the entire system drive when pwsh.exe is on PATH - #751
fix(policy): stop granting the entire system drive when pwsh.exe is on PATH#751Carlos Alexandro Becker (caarlos0) wants to merge 4 commits into
Conversation
…n PATH
`powershell_policy` / `getPowerShellPolicy` returned the system-drive root
(`C:\`) as a read-only grant whenever `pwsh.exe` was found in any PATH
directory, and `available_tools_policy` appended that grant *after* its
`directory_exists && !is_system_critical_path` filter — so the one path most
deserving the system-critical check was the only one that never got it. Every
sandbox seeded from the discovery helpers could read all of `C:\`: SSH keys,
.aws/credentials, .npmrc/.netrc tokens, browser profiles and other users'
profile directories, defeating the deny-by-default read confinement the
AppContainer identity otherwise provides.
- Grant `$PSHOME` (the PATH directory that actually holds `pwsh.exe`) instead
of the volume root. Module trees already reach the policy via `PSModulePath`.
The legitimate need — `pwsh.exe` calling `GetFileAttributesW("C:\")` at
startup — is served host-wide by `wxc-host-prep prepare-system-drive`, which
stamps metadata-only, non-inheriting ACEs on the root.
- Merge the PowerShell grant into the collected set *before* the filter, so it
is held to the same bar as every other discovered directory.
- Reject filesystem roots (`C:\`, `\\server\share`, `/`) in
`is_system_critical_path` / `isSystemCriticalPath`, closing the same hole for
a root that arrives via `PATH` or any known tool/SDK variable.
- Resolve Windows drive-relative paths through `std::path::absolute`
(`GetFullPathNameW`) so `C:` and `C:Windows` can neither be misclassified as
roots nor bypass the `%WINDIR%` prefix check.
- Strip verbatim / device-namespace prefixes (`\\?\`, `\\?\UNC\`, `\\.\`) in
the SDK mirror, and add `\\.\` to the Rust strip list, so
`\\?\C:\Windows\System32` and `\\?\UNC\server\share` no longer slip past.
- Stop the `missing_filesystem_access` launch diagnostic from advising users to
add the drive root to `readonlyPaths`; point at `wxc-host-prep
prepare-system-drive` or pwsh 7.7+ instead.
- `policy.ts` now branches on `process.platform` rather than `os.platform()`,
which is what the unit tests mock. The PowerShell discovery tests were
silently skipped on Linux and failing on macOS because of that mismatch; they
now run everywhere.
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 31c4cbfa-bc3b-46d8-855b-bb91e89925b9
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.
Two review findings on the drive-root hardening.
`missing_root_readonly` inferred "pwsh cannot read the drive root" purely from
the absence of a recursive root grant in `readonlyPaths`. That was a usable
signal only while the discovery helpers still produced such a grant; now that
they never do, the predicate is true for every `pwsh.exe` invocation, so an
ordinary nonzero exit (a script error) is reported to the user as
`missing_filesystem_access` even on a fully prepared host.
Gate it on the actual machine state instead, reusing the `system_drive_prepared`
probe that `fallback_detector` already runs for its host-prep warnings — it
reads the root DACL and looks for the metadata-only, non-inheriting ACEs
`prepare-system-drive` stamps. The probe is passed as a closure so it is skipped
for non-PowerShell executables and for policies that already grant the root, and
so the unit test can pin a deterministic host state rather than depending on the
machine running CI.
The SDK root regression test only exercised the host's path flavor: on
Linux/macOS it covered `/` and nothing else, because mocking `process.platform`
does not turn the imported `path` module into `path.win32`. Make the path flavor
and platform parameters of the predicate, and add a Windows-semantics matrix
that runs everywhere, covering drive and UNC share roots, the verbatim and
device namespaces (`\\?\C:`, `\\.\C:`, `\\?\Volume{GUID}\`, `\\?\UNC\srv\shr`),
`%WINDIR%` in each spelling, and non-critical controls including the
`C:\WindowsApps` prefix trap.
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 31c4cbfa-bc3b-46d8-855b-bb91e89925b9
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.
…rive Both PowerShell sample configs handed the sandbox `"readonlyPaths": ["C:\\"]` — the exact recursive whole-volume grant this branch removes from policy discovery. They are hand-written configs, so nothing filtered them, and as the canonical worked examples of running pwsh under MXC they taught the pattern to anyone copying from them. Neither needs it: the sandbox reaches PowerShell through the explicit `C:\Program Files\PowerShell\7` entry, and the root-metadata access pwsh wants at startup comes from `wxc-host-prep prepare-system-drive`. `tests/examples/08_pwsh.json` is documentation only (`tests/examples` is schema-validated but never executed), so it gets the fuller cleanup it should have had as an example: `$PSHOME` demoted from read-write to read-only, and the `C:\Users` grant plus a hardcoded `C:\Users\stscha\...\PSReadLine` path dropped — the command line already passes `-HistorySaveStyle SaveNothing`, so no history is written, and the hardcoded profile made the example unusable for anyone else regardless. `tests/configs/pwsh_setlocation.json` is executed by `run_test_configs.ps1` and the (velocity-key-gated) `test_pwsh_setlocation` E2E, so it gets the minimal change: drop the drive-root grant, leave every other path the test relies on untouched. The `isolation_session_*` configs that also mention `C:\` are deliberately passing over-broad paths to prove `protected_paths_filter` rejects them, and are left alone. Verified with `node scripts/versioning/validate-configs.js`: 192 configs validate against the dev schema. Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 31c4cbfa-bc3b-46d8-855b-bb91e89925b9
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.
The PowerShell read-only grant is merged into the guarded collection, but the read-write PSReadLine path bypassed the filter entirely. That path is derived from USERPROFILE, which can legitimately sit inside %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, contradicting the invariant this branch adds. Apply is_system_critical_path / isSystemCriticalPath to the write paths in both the Rust engine and the TypeScript SDK. They stay deliberately free of the existence filter: PowerShell creates the PSReadLine history directory on first use, so requiring it to pre-exist would drop a legitimate grant. Regression coverage: a Windows-gated Rust test and a Windows-only SDK test drive the SYSTEM profile through discovery, and the host-agnostic injectable path-semantics suite gains the same PSReadLine shape so the rule is proven on every platform, not only where the branch can execute. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 31c4cbfa-bc3b-46d8-855b-bb91e89925b9 Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
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.
.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. Worse,available_tools_policyappended that grant after itsdirectory_exists(dir) && !is_system_critical_path(dir)filter — so the one path most deserving the system-critical check was the only path that never got it.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 AppContainer identity otherwise provides.The fix
Grant
$PSHOME, not the volume root. The policy now grants thePATHdirectory that actually containspwsh.exe. Module trees already reach the policy throughPSModulePathdiscovery.The legitimate need behind the original grant —
pwsh.execallingGetFileAttributesW("C:\\")during startup — is already served host-wide, and safely, 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.Merge before filtering. The PowerShell grant is now folded into the collected set before the existence + system-critical filter, so it is held to the same bar as every other discovered directory.
Reject filesystem roots outright.
is_system_critical_path/isSystemCriticalPathpreviously only looked at%WINDIR%(Windows) and/bin-style paths (Unix) — a root was never rejected. NowC:\,\\server\share, and/are always critical, closing the same hole for a root that arrives viaPATHor any known tool/SDK environment variable. In Rust this is expressed as "an absolute path with noComponent::Normal"; in TypeScript aspath.parse(resolved).root === resolved.Hardening found while fixing the above
Drive-relative paths.
resolve_pathhand-rolledis_absolute()+current_dir().join(), andPathBuf::pushwith a prefixed path replaces the buffer — soC:was misclassified as a root andC:Windowsbypassed the%WINDIR%check. It now usesstd::path::absolute(GetFullPathNameWon Windows), which handles per-drive current directories correctly. It fails closed: the only error case is the empty path, which then classifies as critical.Verbatim / device namespaces.
\\?\C:\Windows\System32and\\.\C:\Windowsslipped past the%WINDIR%comparison. Both implementations now strip\\?\,\\?\UNC\, and\\.\prefixes for that comparison — after the root test, so stripping can never turn an absolute root into a cwd-relative path that escapes the guard.Misleading diagnostic. The
missing_filesystem_accesslaunch diagnostic told users to add the drive root toreadonlyPaths, i.e. to re-introduce this exact vulnerability by hand. It now points atwxc-host-prep prepare-system-driveor upgrading to pwsh 7.7+, and explicitly warns against granting the root.That diagnostic also needed a better trigger. 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 would be true for everypwsh.exerun, so an ordinary nonzero exit (a script error) would be reported asmissing_filesystem_accesseven on a fully prepared host. It is now gated on the real machine state via thesystem_drive_preparedroot-DACL probe thatfallback_detectoralready runs for its host-prep warnings. The probe is passed as a closure, so it is skipped for non-PowerShell executables and for policies that already grant the root, and the unit test can pin a deterministic host state instead of depending on whichever machine runs CI.Tests that never ran.
sdk/node/src/policy.tscalledos.platform(), but the unit tests mockprocess.platform. The Windows-branch tests were therefore skipped on Linux (with a staleTODO) and failing on macOS. Switching the module toprocess.platform— identical semantics, and what the rest of the SDK already uses — makes them exercise the real code path on every platform. Theskip: isLinuxguards are gone.Mocking
process.platformstill does not turn the importedpathmodule intopath.win32, so the Windows namespace handling above could only be tested on a Windows runner. The path flavor and platform are now parameters of the predicate (isSystemCriticalPathWith,@internal, not re-exported fromindex.ts), and a Windows-semantics matrix runs on every host.The write grant escaped the same check. Only the PowerShell read-only path was merged into the guarded collection; the read-write PSReadLine path bypassed the filter entirely. It is derived from
USERPROFILE, which can legitimately sit inside%WINDIR%— the SYSTEM account's profile isC:\Windows\System32\config\systemprofile— so a service-hosted run could 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. 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 fromwxc-host-prep prepare-system-drive. The example (documentation only;tests/examplesis schema-validated but never executed) also gets$PSHOMEdemoted to read-only and loses a broadC:\Usersgrant plus a hardcodedC:\Users\stscha\...\PSReadLinepath that the command line's-HistorySaveStyle SaveNothingalready makes unnecessary.pwsh_setlocation.jsonis executed byrun_test_configs.ps1, so it gets the minimal change. Theisolation_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— 19 pass (17 before;powershell_policy_grants_system_drive_rootis renamed and inverted to..._grants_pshome_not_the_drive_root, plus newfilesystem_roots_are_system_criticalandtool_paths_never_grant_a_filesystem_root; a 20th,tool_paths_never_grant_write_access_under_windir, is Windows-gated and runs on the Windows CI job).cargo fmt --all -- --checkandcargo clippy -p appcontainer_common -p mxc_engine --all-targets --target x86_64-pc-windows-msvc -- -D warnings— clean, so the#[cfg(target_os = "windows")]code and its tests are checked from a non-Windows dev host too.node scripts/versioning/validate-configs.js— 192 configs validate against the dev schema.npm testinsdk/node— 212 tests, 185 pass, 0 fail (3 were failing before this change), 27 skipped.isSystemCriticalPath - Windows path semanticssuite covers drive and UNC share roots; the verbatim and device namespaces (\\?\C:\,\\?\C:,\\.\C:,\\?\Volume{GUID}\,\\?\UNC\server\share);%WINDIR%in each spelling; and non-critical controls includingC:\WindowsApps\vendor, a string prefix of%WINDIR%that must not be treated as under it.missing_root_metadata_accessis unit-tested for both host-prep states, and asserts the DACL probe is never invoked on either short-circuit path.Follow-up (deliberately out of scope)
temporary_files_policy/getTemporaryFilesPolicyput%TEMP%/$TMPDIRstraight intoreadwritePathswith no system-critical check at all — so aTEMP=C:\environment yields a read-write whole-volume grant, strictly worse than the bug fixed here. Separately, the TypeScript docstring there claims a unique per-sandbox subdirectory is created (andrandomBytesis imported for it), but the body returns the bare temp root. Both are pre-existing and belong in their own change.