MXC IsolationSession Backend
August 9, 2026 · View on GitHub
Problem
MXC supports several sandboxing backends, but none of them runs the workload as a freshly-provisioned, per-execution Windows user account inside a dedicated OS-managed session. Use cases that need this — per the broader claw-on-MXC scenario — call for:
- per-execution OS-isolated identity so the workload's actions cannot pollute the calling user's NTFS / registry / token state,
- OS-managed session lifecycle that the OS-side service tears down cleanly when the calling process exits, and
- a path toward stateful execution where one provisioned user and session can host multiple sequential exec calls without re-paying the provisioning / session-start cost each time.
Proposed Solution
Add an IsolationSession runner to wxc-exec.exe, behind --experimental.
When the JSON config specifies "containment": "isolation_session" and the
experimental flag is set, the binary routes to a new IsolationSessionRunner
(implementing the existing ScriptRunner trait). The runner orchestrates the
full lifecycle against the OS-side Isolation Session API: provision an
agent user, start a session, run the script (capturing stdout / stderr /
exit code into ScriptResponse), then stop the session and deprovision the
agent. All of this happens through
Rust bindings auto-generated from a private WinMD; the OS-side API is gated
on an internal Windows feature flag.
This implementation is a one-shot runner — every wxc-exec
invocation pays the full lifecycle cost. A two-layer architecture
(IsolationSessionManager for lifecycle methods, IsolationSessionRunner
for the one-shot ScriptRunner glue) is what let the state-aware lifecycle
reuse the manager's methods individually.
How It Works
User: wxc-exec.exe --experimental config.json
(config.json sets containment = "isolation_session")
│
▼
wxc-exec.exe (Rust — single binary, multiple backends)
├── Parses JSON config → sees containment = "isolation_session"
├── Checks --experimental flag → instantiates IsolationSessionRunner
├── Calls IsolationSessionManager methods 1:1 with the OS-side service:
│ add_user(...) → Step 1 — creates agent user
│ start_session(...) → Step 2 — boots session
│ create_process(...) → Step 3 — launches in session
│ [Read stdout + stderr] → drives ScriptResponse
│ [WaitForExitAsync + ExitCode] → drives exit_code
│ stop_session(...) → Step 4
│ deprovision_agent_user(...) → Step 5
└── Returns ScriptResponse with stdout, stderr, exit_code
The OS-side service does the heavy lifting: it provisions a fresh per-execution
Windows agent user account (an opaque, OS-assigned name), launches a per-session
host process, and exposes the running script as an IsoSessionProcess handle
from which the runner reads pipe handles for I/O.
Architecture
This backend follows the existing single-binary, multiple-backend pattern.
Dispatch in wxc/src/main.rs:
let mut runner: Box<dyn ScriptRunner> = match request.containment {
ContainmentBackend::AppContainer => Box::new(AppContainerScriptRunner::new()),
// ... existing stable + experimental backends ...
ContainmentBackend::IsolationSession => {
if !request.experimental_enabled {
eprintln!("Error: IsolationSession is experimental. Use --experimental.");
process::exit(1);
}
Box::new(IsolationSessionRunner::new(/* ... */))
}
};
The runner is split into two layers:
IsolationSessionManager— reusable, lifecycle methods that map 1:1 to the OS-side API. Methods:new,add_user,start_session,create_process,stop_session,deprovision_agent_user.IsolationSessionRunner— thin one-shotScriptRunnerimpl that drives the manager's methods in order.
This split is what the state-aware lifecycle builds on: it hosts the manager
directly and invokes its methods explicitly across separate wxc-exec
invocations, without changing the manager's interface. See
state-aware-rust.md.
File Map
New files:
| File | Purpose |
|---|---|
external/windows-sdk/isolation-session/README.md | WinMD provenance, version-coupling notes |
external/windows-sdk/isolation-session/GENERATION_INFO.toml | Machine-readable provenance (windows-bindgen version, target windows crate version, generated date) |
src/backends/isolation_session/bindings/Cargo.toml | Bindings crate manifest |
src/backends/isolation_session/bindings/build.rs | Verifies windows crate version matches the recorded provenance |
src/backends/isolation_session/bindings/src/lib.rs | Re-exports the generated module |
src/backends/isolation_session/bindings/src/bindings.rs | Generated by windows-bindgen (committed) |
src/backends/isolation_session/common/src/ (one_shot.rs, manager.rs, etc.) | IsolationSessionManager + IsolationSessionRunner |
Modified files:
| File | Change |
|---|---|
src/Cargo.toml | Add isolation_session_bindings to workspace members |
src/core/wxc_common/Cargo.toml | Add optional dependency on isolation_session_bindings |
src/core/wxc_common/src/lib.rs | Add the IsolationSession backend module (cfg-gated). (The backend now lives in src/backends/isolation_session/common/.) |
src/core/wxc_common/src/models.rs | Add IsolationSession to ContainmentBackend |
src/core/wxc_common/src/config_parser.rs | Parse the "isolation_session" containment value |
src/core/wxc/Cargo.toml | Add isolation_session Cargo feature |
src/core/wxc/src/main.rs | Dispatch IsolationSession behind --experimental; call CoInitializeEx(COINIT_MULTITHREADED) at top of main (required for any WinRT activation, benign for other backends) |
Configuration
{
"version": "0.6.0-alpha",
"containerId": "MyIsolationSessionRun",
"containment": "isolation_session",
"process": {
"commandLine": "echo hello & whoami",
"cwd": "C:\\Windows",
"env": ["MYVAR=hello"],
"timeout": 30000
},
"network": {
"defaultPolicy": "allow",
"allowLocalNetwork": true
},
"experimental": {
"isolation_session": {}
}
}
The one-shot surface takes no backend configuration at all — there is no
experimental.isolation_session field the one-shot path reads. Anything
supplied there is just an unrecognised key in the deliberately permissive
experimental block and is ignored (the run proceeds normally). Process options
(cwd, env, timeout) read from the existing top-level process section,
matching the contract every other backend honors.
Run with: wxc-exec.exe --experimental config.json.
OS API Dependency
The runner calls into the WinRT API namespaced
Windows.AI.IsolationSession.Preview, exposed by the OS-side Isolation
Session service (running as SYSTEM via svchost.exe). The API is gated
on an internal Windows feature flag.
Activation goes through the WinRT activation factory for the
Windows.AI.IsolationSession.Preview IsoSessionOps runtime class.
Activation requires RoInitialize(RO_INIT_MULTITHREADED) (handled in
main.rs at startup, applied unconditionally because it's benign for other
backends).
The API surface includes the lifecycle methods plus
IsoSessionProcess (the running-process handle). The runner uses the
process surface for stdio relay (stdout / stderr / stdin pipe handles),
exit-wait and exit code, console resize, and the graceful-shutdown ladder
(close stdin → send-ctrl-close → terminate). It sets the interactive-console
flag when wxc-exec's stdout is a TTY.
Bindings Workflow
Why a private WinMD. The OS-side API ships its WinMD as part of an
internal Windows OS build (the exact file name and provenance are recorded
in GENERATION_INFO.toml). There is no public NuGet or release distribution
today. MXC stores generated Rust bindings in the workspace and tracks their
provenance.
Future direction. The OS API is expected to land in the public Windows
SDK eventually, at which point the windows crate (auto-generated from
the public Windows SDK metadata) will pick it up automatically. When that
happens, MXC can drop this private bindings crate and consume the API
through the standard windows crate dependency. That milestone is
currently far off — the private bindings remain the working approach for
the foreseeable future.
Generated bindings are committed.
src/backends/isolation_session/bindings/src/bindings.rs is a checked-in artifact.
The WinMD itself is not committed (binary, frequently updated). All
provenance lives in GENERATION_INFO.toml.
Regeneration. When the OS-side API changes (or the consumed OS build
moves), the bindings must be regenerated by a Microsoft engineer with
access to the private WinMD. windows-bindgen X.Y generates code that
targets the windows X.Y crate, so a regenerator must use a
windows-bindgen release whose major.minor matches the workspace
windows crate. The generated bindings and the Rust code in this repo use
the Windows.AI.IsolationSession.Preview namespace (for example,
IsoSessionOps) — that is the naming to use when diagnosing regeneration or
version-coupling issues. The build-time check below catches the most common
slip — bumping the workspace windows crate without regenerating — by
comparing the workspace version against the recorded target_windows_crate.
build.rs version check. isolation_session_bindings/build.rs reads
the expected windows crate version from GENERATION_INFO.toml
(target_windows_crate) and compares against the actual workspace
Cargo.lock. A mismatch panics the build with a message naming both
versions and stating that the bindings must be regenerated.
v0.1 Scope
Implemented:
- Single-shot
provision → start → run → stop → deprovisionlifecycle, gated by--experimental. process.commandLine(the script command, wrapped viacmd.exe /c "..."— the same pattern the LXC runner uses with/bin/sh -c).process.cwd(working directory inside the session).process.env(environment variables forwarded via the OS-sideIsoSessionProcessOptions).process.timeout(forwarded to the OS-side per-process timeout enforcement).- Stdout / stderr capture and exit code propagation into
ScriptResponse.
Not honored (refused, not silently dropped):
lifecycle.destroyOnExit: falseandlifecycle.preservePolicy: true. The in-proc API exposes no session-lifetime knob, so the backend cannot vary teardown: the one-shot path always stops the session and removes the agent user before returning.destroyOnExit: true(the default) is therefore accepted because it matches actual behavior;falseis refused. There is no filesystem or network policy to preserve (both are rejected outright), sopreservePolicy: trueis refused as meaningless here.ui(any value). The backend has no UI-restriction primitive — see the cross-cutting policy honor matrix below.
Cross-cutting policy honor matrix (one-shot)
The full one-shot column of the backend's honor matrix. The state-aware columns, the rationale for each disposition, and the error mapping live in state-aware-rust.md.
| Field | one-shot disposition |
|---|---|
process.commandLine | honored (required) |
process.cwd / process.env / process.timeout | honored |
filesystem.{readwritePaths,readonlyPaths,deniedPaths} | rejected — no host-folder-sharing primitive |
network — canonical unrestricted acknowledgment (defaultPolicy=allow + allowLocalNetwork=true, no host rules, no proxy, default enforcement) | required |
network — anything else, including absent (defaults to the unenforceable block) | rejected |
ui | rejected if supplied — no ui posture is truthful here (see below); an omitted ui is accepted and applies no restriction |
lifecycle.destroyOnExit | true accepted (matches behavior); false rejected |
lifecycle.preservePolicy | false accepted; true rejected |
fallback.allowDaclMutation | n/a — AppContainer-only; this backend never mutates DACLs, so either value is vacuously satisfied |
containerId | accepted, no effect (a label; the backend addresses sandboxes by the OS-assigned agent user name) |
experimental.isolation_session.provision | accepted, ignored — per-phase config is state-aware-only |
processContainer / lxc / seatbelt / another backend's section | rejected — only the section matching containment is accepted |
Refusals surface as a non-zero exit with the reason on stderr. One-shot has no
typed policy error code: the envelope carries error.code = "backend_error" with
the reason in the message, unlike the state-aware surface which emits
policy_validation.
Why every supplied ui is refused. The ui section states intent about the
contained code's relationship to the user's environment, and was modelled on a
process/job boundary where "the clipboard" and "the desktop" are the user's. An
isolation session is a separate OS session, so the contained code keeps its UI
capabilities but cannot reach the host's. That makes every posture untrue here —
disable either denies capabilities the session grants or promises a GUI the
user can never see; clipboard describes a relationship to a clipboard the
sandbox cannot touch. Only injection: false is honest (SendInput returns
ERROR_ACCESS_DENIED), and it cannot be supplied alone because the other fields
materialize to defaults that are false. With nothing truthful to accept, there is
no acknowledgment-style gate as there is for network. An omitted ui is
accepted because absence is not a caller statement of intent — but note it
applies no restriction, so the schema's default-deny reading does not hold here.
The full field-by-field table is in
state-aware-rust.md.
Deferred to follow-up work:
- TypeScript SDK exposure. Adding a one-shot isolation-session config
surface to
SandboxSpawnOptionsso the SDK can spawn isolation-session workloads programmatically on the one-shot path. Today the one-shot backend is reachable only via JSON config (spawnSandboxFromConfigorwxc-execdirectly), and it takes no backend configuration; the state-aware lifecycle is SDK-exposed.
Test Plan
Automated (cargo test, runs on any machine including CI):
| Category | Location | What it verifies |
|---|---|---|
| Config parsing | config_parser.rs | The "isolation_session" containment value; a stray experimental.isolation_session payload is accepted and ignored |
| Policy validation | policy.rs | Filesystem fields (readwritePaths / readonlyPaths / deniedPaths) are rejected at every phase; the network policy must be the canonical unrestricted-network acknowledgment (defaultPolicy=allow + allowLocalNetwork=true, no host rules or proxy) at provision, and any supplied network policy is rejected post-provision |
| Option building | process_options.rs | ExecutionRequest → ProcessOptions mapping (timeout, cwd, env vars, redirect flags) |
| Feature unavailable | manager.rs | Runner returns a clean error on machines without the IsolationSession feature enabled, so the test passes everywhere |
These backend-specific tests run alongside the existing workspace tests. The feature-unavailable test is what runs in CI, since CI machines do not have a Windows build with the IsolationSession feature enabled.
Integration tests (require a Windows host with the IsolationSession feature enabled):
Two end-to-end configs live under tests/configs/:
isolation_session_hello.json— happy path. PrintsUSERNAME,MYVAR,CWD, andwhoamifrom inside the session. Validates the agent identity (the freshly-provisioned agent account), env-var pass-through, working-directory pass-through, and that the running account differs from the caller.isolation_session_exit42.json— runsexit 42and validates that exit code 42 propagates toScriptResponse.exit_code.
A test runner at tests/scripts/run_isolation_session_tests.ps1 invokes
both configs via wxc-exec.exe --experimental, validates exit codes and
expected output substrings, and reports a pass/fail summary. Pattern
follows the existing per-backend integration scripts (e.g.
run_microvm_tests.ps1, run_wslc_all_tests.ps1).
The script must run interactively on the test host. The OS-side service's
calling-process identity check rejects network-logon tokens, so
PSSession-driven invocations fail with Access Denied. Intended workflow:
build a release
wxc-exec.exe (the repo's .cargo/config.toml already configures
+crt-static for Windows MSVC, so the binary has no vcruntime140.dll
dependency on the test host), copy it plus the two test configs and the
test script to the host, then run the script directly in cmd.exe or
PowerShell on that host.
CI does not run these tests today — there is no CI agent provisioned with
the OS-side Isolation Session service. The feature-unavailable behavior is what runs
in CI (via the automated unit test in cargo test).
Known Issues observed in v0.1
The following were observed during VM testing and are accepted for v0.1.
StopSessionAsyncteardown delay. Initially observed as ~30s on an earlier OS build. Appeared substantially shorter on the current OS build (qualitatively, not quantitatively measured). Documented for awareness; if it regresses materially, the runner can be reshaped to return theScriptResponseahead of teardown.- Intermittent
IdentityNotFound(status 4) immediately after VM boot. Observed once, resolved by a VM restart. Cause unconfirmed; suspected to be an Isolation Session service initialization race. Re-runs on a settled VM are reliable.
Risks
| Risk | Mitigation |
|---|---|
| Bindings tied to a specific OS API version | GENERATION_INFO.toml records the windows-bindgen version and target windows crate version; build.rs panics if the workspace windows crate drifts from the recorded target_windows_crate. Regeneration is a manual step performed by a Microsoft engineer with WinMD access |
| OS API not present on older Windows builds | the IsolationSession feature is OS-side; runner reports a clean error when the activation factory fails. Feature-unavailable test exercises this on CI |
| New Cargo feature increases coupling | The isolation_session feature is off by default in the workspace; default builds and existing CI are unaffected |
| Manual VM testing required | The OS-side service has the same constraint for any consumer (it rejects network-logon tokens). Automated suite covers what it can without the OS-side service |
| One-shot lifecycle is heavy (full provision → start per call) | Inherent to the one-shot path; the experimental flag indicates rough edges. The state-aware lifecycle is the mitigation — it provisions once and reuses the session across exec calls |
| Session lifetime is not caller-controllable | The in-proc API exposes no lifetime knob, so lifecycle.destroyOnExit: false cannot be honored. The one-shot path always stops the session and removes the agent user before returning |
Prerequisites
For end users:
- A Windows build with the IsolationSession feature enabled.
- The OS-side isolation-session host binary present in
%SystemRoot%\System32\(ships with Windows as part of the OS-side service). - WinRT initialized as MTA (handled by
wxc-exec).
For developers:
- Standard Rust toolchain.
cargo build --features isolation_sessionto build the feature intowxc-exec. Default builds skip it — no impact on existing workflows.- A private WinMD only when regenerating bindings. As long as the OS API hasn't changed, no regen is needed.
End-User Experience
# Minimal config: print the agent identity inside the session
wxc-exec.exe --experimental hello.json
hello.json:
{
"version": "0.6.0-alpha",
"containerId": "Hello",
"containment": "isolation_session",
"process": {
"commandLine": "whoami",
"timeout": 30000
},
"network": {
"defaultPolicy": "allow",
"allowLocalNetwork": true
},
"experimental": {
"isolation_session": {}
}
}
Expected stdout: the freshly-provisioned agent account name (an opaque,
OS-assigned account, distinct from whichever account ran wxc-exec).
Supported Workloads
The IsolationSession backend is language-agnostic and image-free — the
workload runs as a normal Windows process inside a system-managed isolated
user session. Any executable accessible to the agent account
(path-resolvable, includes cmd.exe, powershell.exe, etc.) can be the
entry point.
Supported
- Fire-and-forget script execution (
cmd.exe /c "...",powershell.exe -c "..."). - Compiled executables that exit on their own and produce stdout/stderr.
- File processing pipelines using
cmdredirection inside the session. - Workloads that interact with files inside
cwdor paths the agent account can access (the agent is a fresh isolated Windows account; it has access to system-wide resources but not to the calling user's per-user data).
Not supported
| Workload type | Why |
|---|---|
| GUI applications | No display server inside the session; only stdout/stderr captured |
| Long-running daemons | Process is expected to exit within process.timeout |