Skip to content

windows: opt-in MXC BaseContainer backend - #766

Open
ig-ant wants to merge 31 commits into
mainfrom
ig/windows-mxc-backend
Open

ig-ant wants to merge 31 commits into
mainfrom
ig/windows-mxc-backend

Conversation

@ig-ant

@ig-ant ig-ant commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Adds Microsoft's MXC BaseContainer as an opt-in second Windows sandbox backend, where the OS enforces filesystem and network policy with no install step. Callers choose it with windows.backend: 'mxc'; unset means srt-win, so existing callers see no change.

Status

Commands under 'mxc' now start through the MXC SDK in process, with SandboxManager.spawnSandboxed(). The wxc-exec runner is no longer used, and the temporary CI probe step is gone. This lands so it is clear what works on MXC today and what doesn't.

What works: the network fence (only the proxy is reachable; raw TCP, other loopback ports and direct DNS are blocked), reads except the deny-list, writes only inside allowWrite and the host's temp folder (by its full path), per-command allowRead/allowWrite, env var masking, kill of the whole process tree, and cmd, PowerShell, git, node and whoami.

What doesn't:

  • MSYS2 programs: anything built on the MSYS2 runtime (msys-2.0.dll) crashes at startup with 0xC0000005 in a BaseContainer, even true.exe, and Windows writes no crash record. That covers Git Bash, the Unix tools in Git for Windows' usr\bin, and git hooks or aliases that run through sh. Native git.exe, x64 programs under emulation, cmd and PowerShell all start. Callers should use cmd or PowerShell as the shell with this backend. The Git Bash test row is marked failing, so it turns red once it works.
  • Visible windows: UI is on for every command, so a sandboxed program can open a window on the desktop. With UI off, any program that loads user32/gdi32 fails with 0xC0000142. Clipboard and input injection stay blocked.
  • Refused settings (backend_unsupported), because MXC cannot express them with the meaning they have elsewhere: tlsTerminate (so request-header credential masking is unavailable), session-level filesystem.disabled, and allowLocalBinding: true (nothing outside the container can connect in).
  • Loopback with a proxy is closed by measurement, not by promise: MXC 1.0 refuses a host proxy unless ingress.default is allow (and one without an app identity needs host loopback allowed too). On Windows 11 25H2 (build 26200.9711) that request admitted no inbound connection from loopback or the LAN, and blocked outbound to loopback ports other than the proxy's, but MXC documents no guarantee.

Grants beneath a denied path are sent as they are: on the same build MXC resolves overlapping paths by specificity, whatever the list order, so a nested grant is accessible and the rest of the deny stays denied, matching srt's ranking. Because the most specific entry wins, a read-only entry must never sit at or beneath a write grant: until 84147e4 the working directory was always sent read-only, so a command run inside an allowWrite folder could not write to it (found on the VM). Read-only entries governed by a write grant are now left out, and the working directory is no longer granted for reading beneath a deny. On the VM at 84147e4, writes inside an allowWrite folder reach the host with the working directory inside or outside it, and task 66's deny-parent / write-child / nested-denyWrite config works fully.

  • %TEMP%, %TMP% and %LOCALAPPDATA% are per-command scratch: MXC ignores the values srt sends and points them at its own folder for each command (…\AppData\Local\Packages\sandbox.{GUID}\AC), deleted when the command ends. A write there succeeds but is not seen by the next command or on the host, and caches under LOCALAPPDATA (npm, pip and similar) do not persist. The raw SDK behaves the same.
  • Host I/O stall: host file operations can stall for several seconds while several sandboxed commands run.
  • Bun: spawn() needs fs streams that accept windowsHandle (fs: add windowsHandle option to file streams oven-sh/bun#47087, merged, not yet in a release). A temporary CI step ran the boundary suite on mxc under Bun canary 1.4.4-canary.1+da867c60b, built before that merge: initialize() and the probe passed on the arm64 runner, and every row that starts a command failed with EISDIR, as expected without the fix. The step is removed, so CI runs the boundary suite on srt-win on both Windows legs until the pinned Bun has the fix; then set SRT_BOUNDARY_BACKEND to mxc on arm64.

Moving downstream callers to spawnSandboxed(), with PowerShell as the shell under 'mxc', is a separate follow-up.

Summary

  • spawnSandboxed(command, { binShell, customConfig, cwd, timeoutMs, commandId, commandText }) returns the running process: stdout, stderr, stdin, wait() (exit code and timedOut), kill() and dispose(). wrapWithSandboxArgv() throws under 'mxc', and the srt CLI uses spawnSandboxed() there.
  • Fail closed: every spawn first runs the SDK's probe() on its own request. It refuses unless the tier is base-container and the host can enforce every part of the request: denied paths, the host proxy, and clipboard and input-injection blocking. Nothing retries with a weaker request or falls back to srt-win; refusals throw WindowsSandboxError with code backend_unsupported.
  • The child's environment is an explicit list (Windows base variables, proxy settings, git config, masked sentinels). The user's profile environment is never layered in, since it can hold secrets.
  • windows.mxc.path and MXC_BIN_DIR are removed. @microsoft/mxc-sdk stays an optional dependency (Node 24+).
  • spawnSandboxed() puts the command's attribution key in the proxy username and registers its text, as srt-win does, so per-command network lists and violation attribution apply.
  • checkDependencies()/checkDependenciesAsync() take an optional { windowsBackend }, so a caller checking before initialize() gets the mxc check, not srt-win's.
  • The srt CLI waits for the command's output to be written before it exits.
  • mxc.test.ts runs on srt-win on both Windows legs until the pinned Bun has the windowsHandle fix; only the arm64 image has the BaseContainer API.
  • Per-command mode: 'mask' credential files are denied in the request, as on srt-win (also under filesystem.disabled), and per-command denies keep missing literal paths.
  • Also: the HTTP proxy now logs only fixed fields of every upstream error, since a message or stack can carry a proxy URL with credentials. The tests workflow runs with a read-only token.
  • windows.backend should become required in the next breaking release; a TODO marks it.

Test plan

Typecheck and lint pass. The request builder and the probe gate have unit tests on every platform, including a refusal test for each unsupported capability and for a probe that throws; the request types were checked against the SDK's own ContainerRequest and ProbeOutput. Platform-spy tests check that a masked credential file lands in the request's denied paths (and fail without the fix), that the refused settings are refused before the SDK loads, that the command text is registered under the command id, and the dependency check's backend option; unit tests check the command id in the proxy username, that a grant beneath a deny is sent in both lists and not refused, and that a deny beneath a grant lands in the denied paths. On 7da4d4c, CI passed on every job except Windows arm64, which failed only on the canary mxc rows described above (the srt-win path, including the reworked B1/B2 on x64, passed). A platform-spy test sends the write-grant case through initialize() and spawnSandboxed() and checks the grant is in neither the read-only nor the denied list (it fails before 84147e4); the boundary suite gains an mxc-only row that writes from inside an allowWrite folder and reads it back on the host. On 80e3c06 and bafe1a7 every CI job passed; on 84147e4 every job passed too. The current head (b0f9008) changes only docs and comments.

Supersedes #765.

dylan-conway and others added 8 commits October 21, 2026 16:03
Squash of the MXC backend branch (#427) replayed onto current main, so the
change can run under the current CI workflow.

Two things differ from the original branch:
- The MXC path computes masked credential-file denies the same way the
  srt-win path now does, since the access-set helper requires them.
- CI runs the boundary suite (mxc.test.ts) in its own process, then the
  Windows suites main lists.

Not ported: per-command proxy attribution (commandId) is not threaded
through the MXC wrap.
Two tests failed only because main moved under the branch:
- The checked-in list of public exports gains the five MXC names the
  backend adds to the package entry point.
- The boundary suite's TLS block treats "sandbox user is not provisioned"
  from the dependency check as the expected srt-win miss. Main reports
  that case from the dependency check now, as a plain error.
@microsoft/mxc-sdk 0.9 declares Node >=24. On Node 22 the Windows legs
logged that the SDK was not installed, so backend selection never reached
the MXC probe. The other legs stay on 22, the oldest line `engines` allows.
Emit the exact MXC 1.0.0 one-shot request directly: the 1.x SDK has no compile-only buildSandboxPayload. Policy shape is unchanged from #640; hostLoopback stays explicit in both branches as 1.0 requires.
1.0.0 still refused `network.ingress.hostLoopback: 'allow'` unless the
host advertised PSEC 1.1 native ingress, which no shipping Windows 11
24H2/25H2 build does, so the mxc backend was never selected on a host
with a proxy port and every session fell back to srt-win.

mxc 1.0.1 (microsoft/mxc#1490) honors host-loopback allow on PSEC 1.0
hosts as policy-permitted outbound loopback, which is all srt needs to
reach its own proxy. The 1.0.0 request schema is unchanged, so only the
package pin and lock entry move.
…unners

The selection probe's error does not say whether the OS build, a feature
override or the API itself is the cause.
The MXC backend now runs only when requested, and a configuration it cannot
enforce is an error rather than a silent switch to srt-win. Unset means srt-win.
A temporary step that shows whether the SDK's API loads and runs under Bun; it
cannot fail the job.
The temporary step now shows whether the SDK's streaming API works on a Bun
that reports a newer Node version.
Temporary step: checks process tree kill, stdin, large output and environment handling under Node.
The quoted script paths made cmd.exe exit before the scripts ran.
@ig-ant
ig-ant marked this pull request as ready for review October 27, 2026 15:35
ig-ant added 16 commits October 27, 2026 08:44
The probe's helper scripts exit at once, so its process tree results are not valid yet.
Scripts in the writable folder could not run in the container. Also checks which folder grants allow listing and running files.
Matches the real policy, so folder listing and process launches behave as they would in use.
Under windows.backend 'mxc', commands now start with
SandboxManager.spawnSandboxed(), which calls the SDK's spawn() in
process instead of running the wxc-exec runner. wrapWithSandboxArgv()
throws under this backend, and windows.mxc.path and MXC_BIN_DIR are
gone.

Each spawn runs the SDK's probe on its own request first and refuses
to start unless the host offers BaseContainer and can enforce every
part of it: denied paths, the host proxy, and clipboard and input
injection blocking. UI is on for every command, because with it off
most programs fail at startup with 0xC0000142. The child environment
stays an explicit list, now with the Windows variables ordinary
programs expect.
An error's message, cause and stack can carry what the failing call
held, such as a proxy URL with credentials in it. Log the name, code,
errno, syscall, address and port only.
Adds rows for cmd, whoami, git and node. Git Bash is a known failure
under mxc (bash.exe exits with 0xC0000005), so its row is marked
failing and turns red once it works. B1/B2 under srt-win find a curl
with its own TLS stack instead of assuming its path.
The boundary suite now covers what it measured. Under mxc the suite
runs on a Bun canary, whose fs streams accept windowsHandle, until a
release does. The diagnostics step reports the SDK's own probe.
A per-command credential file with mode 'mask' never reached the
request's denied paths, so the child could read it. Deny it as the
srt-win path does, including under filesystem.disabled, and expand
per-command denies in deny mode so a missing literal path is kept.
Also marks the Bun canary step as temporary until a release has the
windowsHandle fix.
The canary did not yet have the windowsHandle fix, so every command
failed with EISDIR. Until a Bun release has it, the boundary suite
runs on srt-win on both Windows legs.
…ands

spawnSandboxed() now takes commandId and commandText, registers the
command text, and puts the command's attribution key in the proxy
username, as srt-win does, so per-command network lists and violation
attribution apply.

Under mxc, initialize() refuses filesystem.disabled (MXC is allow-list
both ways, so the nearest request would restrict) and
allowLocalBinding: true (MXC cannot promise it), and every spawn refuses
a grant beneath a denied path, which srt ranks above the deny and MXC
has no way to express.

checkDependencies() takes the Windows backend, so a caller checking
before initialize() is not told srt-win is missing. Selection state
now lives in one variable.
process.exit() ran as soon as the command exited, dropping output still
buffered on its way to an asynchronous Windows pipe.
Every MSYS2 program crashes in a BaseContainer, not only Git Bash; native
git.exe works.
MXC resolves overlapping paths by specificity, whatever the order: a
grant beneath a denied path is accessible and the rest of the deny
stays denied, which matches srt's ranking. Drop the refusal.

Also state the measured loopback behaviour with a proxy: no inbound
from loopback or the LAN, no outbound to other loopback ports, on
Windows 11 25H2, though MXC documents no guarantee and requires
ingress 'allow' whenever a proxy is set.
The working directory was always sent as a read-only path. MXC lets
the most specific entry win, so when a command ran inside an
allowWrite folder, or beneath one, that folder became read-only and
every write was denied. A read-only entry governed by a write grant is
now left out, and the working directory is no longer granted for
reading beneath a deny, where it overrode the deny.
MXC points TEMP, TMP and LOCALAPPDATA at its own folder for each
command and deletes it afterwards. The host's temp folder stays
writable by its full path.

This branch has not been deployed

No deployments
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