Skip to content

Phase 3.5: --prepare-system-drive flag for AppContainer host setup#387

Closed
MGudgin wants to merge 1 commit into
mainfrom
user/gudge/downlevel_phase3_5
Closed

Phase 3.5: --prepare-system-drive flag for AppContainer host setup#387
MGudgin wants to merge 1 commit into
mainfrom
user/gudge/downlevel_phase3_5

Conversation

@MGudgin

@MGudgin MGudgin commented May 22, 2026

Copy link
Copy Markdown
Member

📖 Description

Adds wxc-exec --prepare-system-drive (and its inverse --unprepare-system-drive) ΓÇö a one-time, host-wide setup step that grants the well-known AppContainer SIDs S-1-15-2-1 (ALL APPLICATION PACKAGES) and S-1-15-2-2 (ALL RESTRICTED APPLICATION PACKAGES) a tiny metadata-only access mask on the system-drive root (C:\).

Why

AppContainer processes can't, by default, read directory metadata of the system-drive root via APIs such as GetFileAttributesW, _stat, or [IO.DirectoryInfo]::GetAccessControl. Common tools (cmd.exe, powershell.exe, pwsh.exe, node.exe) hit these during startup and fail with ERROR_ACCESS_DENIED inside an AppContainer. This blocks most non-trivial agent workloads on the T2/T3 paths landed in phases 3 and 4.

What we grant

Trustee SID Access mask Inheritance
ALL APPLICATION PACKAGES S-1-15-2-1 FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE (0x00120088) none
ALL RESTRICTED APPLICATION PACKAGES S-1-15-2-2 same none

The mask is the metadata-read set used by GetFileAttributesW, _stat, and GetAccessControl. It explicitly excludes FILE_LIST_DIRECTORY (the container still cannot enumerate C:\) and FILE_READ_DATA. Inheritance is None, so descendant files and subdirectories are unaffected ΓÇö the change is scoped to the directory object itself.

Code shape

  • New wxc_common::system_drive_prep module (Windows-only).
  • filesystem_dacl::apply_ace refactored into a thin wrapper over a new pub(crate) helper apply_explicit_ace(path, sid, mask, type, inheritable). Behavior-preserving.
  • New pub(crate) helper revoke_specific_aces_for_sid for tuple-precise revoke (only ACEs matching the apply tuple are removed; non-matching ones ΓÇö e.g. third-party icacls /grant ACEs ΓÇö are preserved).
  • wxc/src/main.rs adds three flags (two visible, one hidden) with clap conflicts_with_all against the other top-level "do this and exit" flags.

Hardenings (from the adversarial review on 330fabd5)

  • Precise tuple-matching revoke (MF-1): --unprepare-system-drive no longer issues a blunt SetEntriesInAclW(REVOKE_ACCESS) for the SID. It scans the existing DACL, partitions explicit ACEs into matches/non-matches against the apply tuple, and only revokes matches. PowerShell scripts and Rust path now genuinely mirror each other.
  • clap mutual exclusion (MF-2): the new flags carry conflicts_with_all against --setup-hyperlight, --setup-wslc, --delete, --dry-run, and each other. Invalid combos are rejected at parse time instead of silently dropping the other flag.
  • Elevated child diagnostics + WaitForSingleObject check (MF-3): the elevated child appends its console output to %TEMP%\wxc-exec-prepare-system-drive.log; the unelevated parent reads and prints the contents on non-zero child exit so failures surface a real diagnostic instead of an opaque exit code. WaitForSingleObject return value is now checked against WAIT_OBJECT_0.
  • --internal-target-path for path-resolution hardening (SF-1): the unelevated parent resolves %SystemDrive% once and passes the resolved path to the elevated child as a hidden CLI arg. The child does not re-read %SystemDrive% from a potentially attacker-controlled environment and validates the received path is a literal drive root (X:\) before touching its DACL.

Elevation model

Modifying the DACL on C:\ needs WRITE_DAC, which normal users don't hold. Windows does not allow a running process to elevate its own token, so the unelevated parent re-launches itself via ShellExecuteExW(runas) with a hidden --internal-elevated-helper flag, waits for the elevated child, and propagates the exit code. A UAC prompt is expected on the first invocation.

🔗 References

Prerequisite for #296 (Phase 4: dispatcher integration for BaseContainer fallback). Phase 4 will need to be rebased onto this once it lands.

Related to #304 ("T3: directory enumeration fails even on granted paths") ΓÇö does not resolve it (that's about ancestor FILE_TRAVERSE rights for FindFirstFile, a different problem), but is on the same axis and helps unblock the same "cmd/pwsh inside AppContainer can't start up" symptom.

Stacks on top of #295 (Phase 3: filesystem_dacl module). Phase 3 is already merged to main, so this PR targets main directly.

🔍 Validation

Automated

  • cargo fmt --all -- --check Γ£à
  • cargo check --workspace --all-targets Γ£à
  • cargo clippy --workspace --all-targets -- -D warnings Γ£à
  • cargo test -p wxc_common --lib -- --test-threads=1 Γ£à (345/345 pass, including 4 new tests in system_drive_prep::tests: round-trip, override seam, MF-1 precise-revoke regression, SF-1 drive-root validator)

Manual (Windows 11 25H2)

Verified end-to-end with scripts/host-prep/Prepare-SystemDriveForAppContainer.ps1 on real C:\. Before the prep, icacls C:\ showed no explicit ACEs for the AppContainer SIDs. After wxc-exec --prepare-system-drive, icacls confirmed both ACEs landed with mask (Rc,S,REA,RA) (= 0x00120088) and no inheritance flags. Re-running --prepare-system-drive was idempotent. --unprepare-system-drive removed both. The C:\ DACL restored byte-for-byte to baseline (SDDL comparison).

UAC handshake (from a non-elevated PowerShell): observed UAC consent dialog → elevated child runs → unelevated parent prints Elevated helper completed successfully. → exit 0.

✅ Checklist

(Copilot instructions: added docs/host-prep.md to the Key documentation → Core references list so future agents discover the new doc and the scripts/host-prep/ hand-test helpers.)

📋 Issue Type

  • Bug fix
  • Feature
  • Task

Copilot AI review requested due to automatic review settings May 22, 2026 18:05
@MGudgin

MGudgin commented May 22, 2026

Copy link
Copy Markdown
Member Author

End-to-end verification on real C:\ (Windows 11 25H2, elevated PowerShell): 15/15 pass, including the MF-1 acceptance test against a pre-seeded third-party ACE.

PASS :: Step 4: prepare exits 0
PASS :: Step 4: AAP ACE landed (icacls)                          found=1
PASS :: Step 4: RAP ACE landed (icacls)                          found=1
PASS :: Step 4: AAP idempotent after re-prepare                  found=1
PASS :: Step 4: RAP idempotent after re-prepare                  found=1
PASS :: Step 5: third-party inheritable AAP ACE seeded           found=1
PASS :: Step 5: our metadata-only AAP ACE present after prep     found=1
PASS :: Step 5: seeded inheritable AAP ACE STILL present after prep  found=1
PASS :: Step 5: our metadata-only AAP ACE removed by unprep      found=0
PASS :: Step 5: seeded inheritable AAP ACE PRESERVED (MF-1)      found=1
PASS :: Step 6: validator rejects '\''C:\Windows'\''
PASS :: Step 6: validator rejects '\''\\server\share'\''
PASS :: Step 6: validator rejects '\''C:'\''
PASS :: Step 6: validator rejects '\''foobar'\''
PASS :: Cleanup: C:\ SDDL matches baseline

The MF-1 procedure was: icacls C:\ /grant *S-1-15-2-1:(OI)(CI)(R) (creates an inheritable AAP allow ACE with FILE_GENERIC_READ), then wxc-exec --prepare-system-drive (adds a no-inheritance AAP allow ACE with the metadata-only 0x00120088 mask — kept distinct by SetEntriesInAclW because inheritance flags differ), then wxc-exec --unprepare-system-drive. After unprep, icacls confirms only the seeded inheritable (OI)(CI)(R) ACE remains for AAP — our no-inheritance one is gone, the third-party one untouched. Precise tuple-matching revoke confirmed.

C:\ SDDL byte-compared back to baseline after the cleanup step.

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.

Pull request overview

Adds a Windows host-prep mode to wxc-exec to permanently grant AppContainer well-known groups minimal metadata-read access on the system drive root, unblocking common shell/tool startup inside AppContainer.

Changes:

  • Added wxc-exec --prepare-system-drive / --unprepare-system-drive flags (plus hidden internal helper args) to perform host-wide DACL prep with UAC self-elevation.
  • Introduced wxc_common::system_drive_prep implementing the elevation flow and tuple-precise apply/revoke logic.
  • Refactored filesystem_dacl to expose apply_explicit_ace and added revoke_specific_aces_for_sid for precise removal; added PowerShell equivalents + documentation.
Show a summary per file
File Description
src/wxc/src/main.rs Adds CLI flags and early-exit dispatch to the host-prep entrypoints.
src/wxc_common/src/system_drive_prep.rs New Windows-only module implementing prepare/unprepare, UAC relaunch, and logging/tests.
src/wxc_common/src/lib.rs Exposes the new system_drive_prep module on Windows.
src/wxc_common/src/filesystem_dacl.rs Adds apply_explicit_ace and tuple-precise revoke_specific_aces_for_sid.
scripts/host-prep/Prepare-SystemDriveForAppContainer.ps1 Adds an elevated PowerShell script to apply the persistent ACEs.
scripts/host-prep/Unprepare-SystemDriveForAppContainer.ps1 Adds an elevated PowerShell script to precisely remove the ACEs.
docs/host-prep.md Documents the new host-prep flag behavior, security scope, and verification steps.

Copilot's findings

  • Files reviewed: 7/7 changed files
  • Comments generated: 2

Comment on lines +234 to +240
/// Path the elevated child writes its console output to, so the
/// unelevated parent can surface it on failure. The child's console
/// window closes immediately on exit; without this redirection the
/// user sees only `ChildFailed(<code>)` with no diagnostic.
fn helper_log_path() -> PathBuf {
std::env::temp_dir().join("wxc-exec-prepare-system-drive.log")
}
Comment on lines +1048 to +1064
let priors = scan_explicit_aces_for_sid(path, sid_str)?;
let mut keeps: Vec<PriorAce> = Vec::new();
let mut removed = 0usize;
for p in priors {
if p.access_mask == access_mask
&& p.ace_type == ace_type
&& p.inherit_flags == expected_inherit_flags
{
removed += 1;
} else {
keeps.push(p);
}
}

if removed == 0 {
return Ok(0);
}
@MGudgin MGudgin force-pushed the user/gudge/downlevel_phase3_5 branch from 28dba43 to 20f859d Compare May 22, 2026 18:11
@MGudgin MGudgin marked this pull request as ready for review May 22, 2026 18:11
@MGudgin MGudgin force-pushed the user/gudge/downlevel_phase3_5 branch from 20f859d to 664ebfe Compare May 22, 2026 18:17
AppContainer processes can't read metadata of the system-drive root
(GetFileAttributesW, _stat, GetAccessControl). cmd.exe, powershell.exe,
pwsh.exe, and node.exe hit these during startup and fail with
ERROR_ACCESS_DENIED, blocking most agent workloads.

Add `wxc-exec --prepare-system-drive` and `--unprepare-system-drive`
to add/remove a metadata-only allow ACE (mask 0x00120088, no
inheritance) on `C:\` for the well-known SIDs S-1-15-2-1 and
S-1-15-2-2. Self-elevates via UAC. Unprep is tuple-precise: removes
only ACEs the prepare path would have written, so same-SID ACEs
authored by other tools (e.g. via `icacls /grant`) survive.

Tested: cargo fmt/check/clippy clean; 345/345 lib tests (4 new in
`system_drive_prep::tests` covering round-trip, precise-revoke
regression, and drive-root validator); end-to-end against real `C:\`
on Windows 11 25H2.

See `docs/host-prep.md`; hand-test scripts in `scripts/host-prep/`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@MGudgin MGudgin force-pushed the user/gudge/downlevel_phase3_5 branch from 664ebfe to b518413 Compare May 22, 2026 18:20
@MGudgin

MGudgin commented May 22, 2026

Copy link
Copy Markdown
Member Author

Closing without merging.

Phase 3.5 was motivated by the claim that cmd/pwsh/node fail to start inside an AppContainer because they can't stat C:\. End-to-end testing on Windows 11 25H2 + pwsh 7.6.2 + node 24.13.0 falsified that hypothesis:

  • cmd.exe runs in an AppContainer without any host-prep.
  • pwsh.exe 7.6.2 handles missing C:\ metadata-read access via PR #27266's SafeDoesPathExist catch — runs without host-prep.
  • node.exe runs without host-prep.

The actual unblocker for pwsh/node was setting ui.disable=false (the wxc default is true, which applies the Win32k mitigation policy and breaks coreclr DLL init) — a separate concern from C:\ DACLs.

Phase 3.5 still has one confirmed empirical effect: cmd.exe's if exist C:\ construct flips from HIDDEN (exit 99) to SEEN (exit 42) with prep applied. That's narrower than the motivating problem and not worth ~600 lines of new code (CLI flag, elevation handshake, module, doc, scripts) for the present audience.

The branch user/gudge/downlevel_phase3_5 is preserved for revival if a real use case emerges (e.g. legacy tools that don't defensively handle UnauthorizedAccessException on the drive root).

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.

2 participants