Skip to content

fix(sandbox/windows): stop the DACL cost storm, fix DLL-init/cwd bugs, add Relaxed Sandbox Mode for trusted_local - #254

Open
frankforges wants to merge 2 commits into
FerroxLabs:mainfrom
frankforges:fix/windows-sandbox-execution-and-relaxed-mode
Open

fix(sandbox/windows): stop the DACL cost storm, fix DLL-init/cwd bugs, add Relaxed Sandbox Mode for trusted_local#254
frankforges wants to merge 2 commits into
FerroxLabs:mainfrom
frankforges:fix/windows-sandbox-execution-and-relaxed-mode

Conversation

@frankforges

Copy link
Copy Markdown

What and why

Fixes the Windows AppContainer sandbox bugs described in FerroxLabs/wayland#922 (three separate reports over three months: #520, #618, #743, plus my own #921 yesterday — shell execution "not working" on Windows). Three of the four fixes are straightforward bug fixes. The fourth introduces a new opt-in posture, Relaxed Sandbox Mode, and needs a maintainer call — see below.

Full investigation, measurements, and the "why did this survive three prior reports" history are in the linked issue FerroxLabs/wayland#922. This PR description covers just the code.

The five changes

  1. crates/wcore-tools/src/workspace_policy.rstrusted_local no longer puts $HOME in the AppContainer read allowlist on Windows, and scratch_dirs() grants a small dedicated subdir of %TEMP% instead of the whole thing. Both were inheritable-ACE grants rewritten across their entire subtree on every spawn (SetNamedSecurityInfoW) — measured 35s timeout → ~20ms for the full allowlist. Non-Windows behavior unchanged (#[cfg(not(windows))] keeps $HOME/%TEMP% — bind mounts have no per-file ACL cost there).

  2. crates/wcore-sandbox/src/backends/appcontainer.rsCreateRestrictedToken now disables only BUILTIN\Administrators, not Users/Authenticated Users. Some System32 DLLs grant read/execute via Users but not ALL APPLICATION PACKAGES; disabling it made every external executable fail image init with STATUS_DLL_NOT_FOUND. Verified by A/B toggle — isolation boundary is unaffected (AppContainer SID + Low IL + deny ACEs are untouched).

  3. crates/wcore-sandbox/src/backends/appcontainer.rs — strip the \\?\ verbatim-disk prefix from the workspace cwd before CreateProcessAsUserW. cmd.exe treats a \\-prefixed cwd as UNC and silently falls back to C:\Windows, breaking every child spawn from a canonicalized workspace root.

  4. New: Relaxed Sandbox Mode (crates/wcore-sandbox/src/lib.rs, crates/wcore-config/src/config.rs, crates/wcore-agent/src/bootstrap.rs, wiring in appcontainer.rs) — [tools] windows_relaxed_sandbox / [tools] windows_allow_admin, opt-in, trusted_local only.

    This is not a fix to the existing sandbox — it's a different posture, because the existing one is structurally incompatible with running the toolchain. With 1–3 applied, git init, npm, msys ls/pwd, and PowerShell still failed. We tried amortizing a full $HOME read grant once per session to dodge the cost from [mutants-nightly] wcore-cron — surviving mutants (2026-06-08) #1 — measured that it still doesn't fix git init/npm even after paying 66 seconds for the grant. A/B'ing the AppContainer mechanisms directly showed why: every Windows primitive that scopes writes by identity (the AppContainer SID, Low integrity, a restricted token) also blocks the reads these tools need at startup. There is no allowlist, amortized or not, that gets both — we could not make the stock AppContainer-contained model run the full toolchain, however we configured it.

    Relaxed Mode is medium integrity, no AppContainer capability, restricted token (disables Administrators; disables nothing if windows_allow_admin is also set — which only grants actual admin if the host process is itself elevated, never silently). Containment becomes Job Object resource limits + no-admin/no-privilege + the existing network policy, not filesystem confinement. contained (untrusted/remote) sessions are completely untouched — full AppContainer stays the default and only default there.

    Config-driven rather than env-var-only because the desktop's ENGINE_ENV_ALLOWLIST (in the wayland repo) strips arbitrary env vars before the spawned engine process sees them — confirmed this cost us a full extra debugging pass on our own machine. The env var still works for direct CLI use (checked first, config is the fallback), but config.toml is the only channel that reliably reaches a desktop-spawned engine. Flagging for the desktop team too: if there's ever a Settings-UI toggle for this, it should write config.toml (same pattern as the existing [tools] windows_shell), not a new env var.

    Verified end-to-end via crates/wcore-sandbox/tests/live_relaxed_windows.rs (new, included): ls/pwd/git init/npm --version/powershell -Command all exit 0 with correct output through the real AppContainerBackend::execute path.

  5. crates/wcore-tools/src/bash.rs — the Windows BashTool::description() (the LLM-facing tool description, not just docs) now warns the agent about a cmd /C quoting artifact and gives it the fix. Live-reproduced after enabling Relaxed Mode, with auto-approve on (so approval-gate latency is ruled out — every command in that session returned in under 300ms, confirming 1–4 hold under real load): cmd /C powershell -Command "..." either fails with a PowerShell parse error or returns exit 0 with empty stdout, because cmd's /C re-tokenizing mangles the nested double quotes before PowerShell ever sees the script. A same-session whoami && uname -a && pwd (no nested quotes) worked and returned correct output — isolating the quoting as the sole variable. Not a sandbox bug (verified the capture path is raw CreatePipe/ReadFile, no pty involved), but worth fixing at the description level since it makes the agent retry a way that will never work. Fix: recommend wrapping -Command in single quotes (cmd.exe doesn't treat ' as special, so nothing gets mangled) or -EncodedCommand/-File for longer scripts. New unit test: description_warns_about_powershell_double_quote_mangling.

Scope

  • In scope per CONTRIBUTING.md — this is entirely within wcore-sandbox/wcore-tools/wcore-config/wcore-agent (no touched dependency is in the exact-pinned security-boundary list)
  • Happy to split [mutants-nightly] wcore-providers — surviving mutants (2026-06-08) #4 into its own PR if the maintainers want the three straightforward fixes (1–3) reviewed/merged independently of the Relaxed Mode design decision — just say the word.

Checks

  • Unit tests pass (cargo test -p wcore-sandbox -p wcore-tools -p wcore-config --lib) — no regressions; the two PowerShell/bash-rejection tests correctly flip behavior when relaxed mode is active (proves the fix, not a break)
  • New live integration test added (live_relaxed_windows.rs, gated WAYLAND_SANDBOX_LIVE_WINDOWS), passes against a real Windows box
  • No secrets, keys, or credentials committed
  • No AI-generated signatures in commits or the PR body — this was built with AI assistance at my direction (disclosed in the linked issue), but there's no bot-signature/trailer in the commit or here; I'm the one submitting and standing behind it

Testing notes for reviewers without a native Windows AppContainer box

Every claim above has a WAYLAND_SANDBOX_LIVE_WINDOWS=1-gated live test backing it (existing pattern in this crate — see live_fs_acl.rs). Happy to paste the full raw measurement logs from our session if useful for review, or re-run any specific scenario you want checked.

…, add Relaxed Sandbox Mode for trusted_local

Four Windows AppContainer bugs, found while chasing #520/#618/#743/#921
(shell execution "still not working" on Windows across three months of
reports). All measured live against real spawns.

1. workspace_policy.rs: trusted_local put $HOME and the whole host %TEMP%
   into the AppContainer read/write allowlist. Every grant becomes an
   inheritable ACE rewritten across the ENTIRE subtree
   (SetNamedSecurityInfoW), paid twice (grant+revoke) per spawn. On a real
   profile that is tens of seconds per command -> every command times out.
   Measured: full trusted_local allowlist 35s -> timeout; same allowlist
   with $HOME dropped and %TEMP% replaced by a small scratch subdir: ~20ms.
   A crash-leaked ACE also used to land on the profile root and could brick
   unrelated Electron/Chromium apps (electron/electron#51761); with $HOME
   out of the allowlist a leak lands on a workspace subdir instead.

2. appcontainer.rs: CreateRestrictedToken marked BUILTIN\Users deny-only.
   Some System32 DLLs grant read/execute via Users but not via
   ALL APPLICATION PACKAGES, so every external executable (not just
   PowerShell/git-bash) died at image init with STATUS_DLL_NOT_FOUND.
   Disabling only Administrators fixes it without weakening the actual
   isolation boundary (AppContainer SID + Low IL + deny ACEs).

3. appcontainer.rs: the workspace cwd is canonicalize()'d to the \?\C:\...
   verbatim-disk form. cmd.exe treats a \-prefixed cwd as UNC and silently
   falls back to C:\Windows, breaking every child spawn. Strip the \?\
   prefix before CreateProcessAsUserW (same object, a spelling cmd accepts).

4. New: Relaxed Sandbox Mode ([tools] windows_relaxed_sandbox /
   windows_allow_admin, config-driven -- the desktop's ENGINE_ENV_ALLOWLIST
   strips arbitrary env vars before the spawned engine sees them, so an env
   var alone does not reach a desktop-launched process). With 1-3 fixed,
   msys/git-bash, PowerShell, `git init`, and `npm` STILL fail even with an
   amortized $HOME grant (measured: git init/npm fail even after 66s of
   granting all of $HOME) -- every Windows mechanism that scopes writes by
   identity (AppContainer, Low IL, a restricted token) also blocks the reads
   these tools need at startup. There is no allowlist that gets both.
   Relaxed mode is an explicit opt-in for trusted_local only: medium
   integrity, no AppContainer capability, restricted token (admin disabled
   unless windows_allow_admin is also set AND the host process is itself
   elevated -- never a silent escalation). Containment becomes Job Object
   resource limits + no-admin/no-privilege + the network policy, not
   filesystem confinement -- the same trust model most local agent CLIs use.
   `contained` (untrusted/remote) sessions are untouched and keep the full
   AppContainer. Verified end-to-end: ls/pwd/git init/npm/powershell all
   exit 0 with correct output through the real AppContainerBackend::execute
   path, driven purely by config (env var explicitly unset in the test).
…ng artifact

Live-reproduced with Relaxed Mode active, auto-approve on (approval-gate
latency ruled out -- every command in the session returned in under 300ms,
confirming the sandbox fixes hold under real agent-driven load):

  cmd /C powershell -Command "..."     -> parse error OR exit 0 + EMPTY stdout
  whoami && uname -a && pwd (no nested quotes) -> exit 0, correct output

cmd.exe's own /C re-tokenizing mangles nested double quotes before
PowerShell ever sees the -Command script. Not a sandbox bug (the capture
path is raw CreatePipe/ReadFile, no pty anywhere) -- but the agent has no
way to know this and will keep retrying a shape that can never work.

BashTool::description() (Windows-only via #[cfg(windows)]; non-Windows
unchanged) now tells the agent to wrap -Command in single quotes instead
(cmd.exe does not treat ' as special) or use -EncodedCommand/-File for
longer scripts.
@frankforges
frankforges requested a review from FerroxLabs as a code owner July 23, 2026 07:38
This was referenced Jul 23, 2026
FerroxLabs pushed a commit that referenced this pull request Jul 28, 2026
Recommendation: do not merge #254 as-is. Take two small fixes re-authored
(the %TEMP% scratch_dirs narrowing and the \\?\ cwd strip, both still live at
HEAD), drop the SidsToDisable change as superseded by a better upstream fix,
and reject Relaxed Sandbox Mode.

Relaxed Mode is a hole rather than a tradeoff: its 'trusted_local only'
restriction exists only in doc comments with zero code paths, and its two new
config keys use plain project.or(global) while allow_no_sandbox, auto_approve
and allow_list are clamped in the same function -- so a cloned repo's
.wayland-core.toml disables the Windows sandbox. Recorded CRITICAL, siding
with the panel minority on Phase 28's own rubric.

The contributor found two real bugs nobody upstream fixed and proposed the
split themselves; the rejection is of one implementation, not of the work.
FerroxLabs pushed a commit that referenced this pull request Jul 28, 2026
std::fs::canonicalize returns the verbatim `\\?\C:\...` spelling for every
local path on Windows, so a cwd canonicalized anywhere upstream arrives at
the AppContainer backend in that form. We passed it to CreateProcessAsUserW
unmodified. The command processor reads the leading `\\` as UNC, refuses it
as a current directory, and silently defaults to `C:\Windows` -- the child
then runs somewhere other than where the caller asked, and nothing reports
an error.

Rewrite VerbatimDisk to its ordinary drive-letter spelling for that one
call. This does not widen the sandbox: `\\?\C:\a` and `C:\a` name the same
filesystem object and the AppContainer allow/deny ACEs are applied to the
object, not to the spelling. Verbatim-UNC, device and plain UNC paths are
deliberately left byte-identical -- those name genuinely remote objects, so
stripping their prefix would change which object is named.

The strip runs on the wide encoding rather than a to_str() round-trip so a
non-UTF-8 filename is handled exactly instead of silently passing through
unstripped. is_verbatim_disk_path is un-gated from #[cfg(test)]; it was
already the correct Prefix::VerbatimDisk classifier, just unreachable from
production.

Guards, both asserting observable behaviour rather than that a string
helper ran:
- resolve_cwd() is extracted so the actual UTF-16 buffer handed to
  lpCurrentDirectory can be decoded and asserted, including the untouched
  negatives and the absolute/NULL contract.
- live_cwd_verbatim.rs spawns a real child with a canonicalized cwd and
  asserts where the child says it landed -- and that it is not the Windows
  directory. Gated like live_fs_acl.rs: require_live_acceptance() asserts
  on WAYLAND_SANDBOX_LIVE_WINDOWS rather than returning early, so an unset
  variable fails these instead of silently passing them.

Found and reported by frankforges in wayland-core #254, along with the
proposal to split that PR. Re-authored here rather than merged: #254's base
predates the appcontainer/ module split, so its hunks target a file layout
that no longer exists.
FerroxLabs pushed a commit that referenced this pull request Jul 28, 2026
scratch_dirs() returned vec![canon(temp_dir())] -- the entire host temp
tree, granted writable to every sandboxed session. On Windows each writable
root is materialized as an inheritable ACE via SetNamedSecurityInfoW per
spawn and revoked after, so granting %TEMP% is O(subtree) in cost and
enormous in blast radius: a crash between grant and revoke strands an ACE
on a directory shared with every other application on the machine. It is
also far more authority than a sandboxed child needs -- it could read and
rewrite any other process's temp state.

Narrow it to a bounded scratch directory inside the temp tree, and key that
directory BY TRUST. One shared name would have handed an untrusted
Contained session a writable host directory that a Trusted local session
also writes to and reads back -- a trust-crossing channel created by the
narrowing itself. trusted_local and contained now get sibling directories,
never nested, never equal.

Two details the narrowing forces:

- The directory has to exist for a write grant to be materializable, so it
  is created. If it cannot be established the grant is EMPTY, never a
  fallback to %TEMP%: failing closed costs a session its scratch space,
  failing open would silently restore the defect.
- On unix temp_dir() is the shared, world-writable /tmp, so the uid goes in
  the top component rather than a subdirectory -- a shared parent would let
  whichever user created it first own the permissions for everyone else.
  Since /tmp is world-writable another user can also pre-create the name as
  a symlink, and create_dir_all follows symlinks, so we verify we got a real
  directory that we own before granting a write ACE to it.

Guards assert the property at the public surface (writable_roots()), not on
the private helper alone: the whole temp root must not be granted, the
bounded dir must actually be granted (so a version granting nothing cannot
pass), and no writable root of a Contained session may appear among a
Trusted session's.

The narrowing was found and proposed by frankforges in wayland-core #254.
The per-trust keying is added here; #254 used a single fixed name for both.
FerroxLabs pushed a commit that referenced this pull request Jul 28, 2026
Found by frankforges on PR #254 and re-authored here; the PR itself is Sean's
call and no GitHub action was taken on it.

%TEMP% narrowing: scratch_dirs() now takes a WorkspaceTrust and returns a
bounded dir rather than the whole host temp tree. Three things beyond a literal
re-author -- keyed BY TRUST, because one shared name would have created a
trust-crossing writable dir via the narrowing itself; fails closed to an empty
grant, never back to %TEMP%; and on unix the uid goes in the top component with
a symlink/ownership check, since temp_dir() there is world-writable /tmp and
create_dir_all follows symlinks.

\?\ cwd strip: resolve_cwd() extracted so the actual lpCurrentDirectory buffer
is assertable. Strips on the wide encoding rather than a to_str() round trip, so
non-UTF-8 filenames stay exact. Live proof on Windows: the un-fixed child
reported C:\WINDOWS, the fixed one the requested directory.

Not taken: the $HOME change (upstream's curated allowlist is better),
SidsToDisable (superseded by HEAD's 0/null), and no part of Relaxed Sandbox
Mode.

# Conflicts:
#	.planning/BACKLOG.md
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.

1 participant