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-shot ScriptRunner impl 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:

FilePurpose
external/windows-sdk/isolation-session/README.mdWinMD provenance, version-coupling notes
external/windows-sdk/isolation-session/GENERATION_INFO.tomlMachine-readable provenance (windows-bindgen version, target windows crate version, generated date)
src/backends/isolation_session/bindings/Cargo.tomlBindings crate manifest
src/backends/isolation_session/bindings/build.rsVerifies windows crate version matches the recorded provenance
src/backends/isolation_session/bindings/src/lib.rsRe-exports the generated module
src/backends/isolation_session/bindings/src/bindings.rsGenerated by windows-bindgen (committed)
src/backends/isolation_session/common/src/ (one_shot.rs, manager.rs, etc.)IsolationSessionManager + IsolationSessionRunner

Modified files:

FileChange
src/Cargo.tomlAdd isolation_session_bindings to workspace members
src/core/wxc_common/Cargo.tomlAdd optional dependency on isolation_session_bindings
src/core/wxc_common/src/lib.rsAdd the IsolationSession backend module (cfg-gated). (The backend now lives in src/backends/isolation_session/common/.)
src/core/wxc_common/src/models.rsAdd IsolationSession to ContainmentBackend
src/core/wxc_common/src/config_parser.rsParse the "isolation_session" containment value
src/core/wxc/Cargo.tomlAdd isolation_session Cargo feature
src/core/wxc/src/main.rsDispatch 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 → deprovision lifecycle, gated by --experimental.
  • process.commandLine (the script command, wrapped via cmd.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-side IsoSessionProcessOptions).
  • 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: false and lifecycle.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; false is refused. There is no filesystem or network policy to preserve (both are rejected outright), so preservePolicy: true is 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.

Fieldone-shot disposition
process.commandLinehonored (required)
process.cwd / process.env / process.timeouthonored
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
uirejected if supplied — no ui posture is truthful here (see below); an omitted ui is accepted and applies no restriction
lifecycle.destroyOnExittrue accepted (matches behavior); false rejected
lifecycle.preservePolicyfalse accepted; true rejected
fallback.allowDaclMutationn/a — AppContainer-only; this backend never mutates DACLs, so either value is vacuously satisfied
containerIdaccepted, no effect (a label; the backend addresses sandboxes by the OS-assigned agent user name)
experimental.isolation_session.provisionaccepted, ignored — per-phase config is state-aware-only
processContainer / lxc / seatbelt / another backend's sectionrejected — 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 SandboxSpawnOptions so the SDK can spawn isolation-session workloads programmatically on the one-shot path. Today the one-shot backend is reachable only via JSON config (spawnSandboxFromConfig or wxc-exec directly), and it takes no backend configuration; the state-aware lifecycle is SDK-exposed.

Test Plan

Automated (cargo test, runs on any machine including CI):

CategoryLocationWhat it verifies
Config parsingconfig_parser.rsThe "isolation_session" containment value; a stray experimental.isolation_session payload is accepted and ignored
Policy validationpolicy.rsFilesystem 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 buildingprocess_options.rsExecutionRequestProcessOptions mapping (timeout, cwd, env vars, redirect flags)
Feature unavailablemanager.rsRunner 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. Prints USERNAME, MYVAR, CWD, and whoami from 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 — runs exit 42 and validates that exit code 42 propagates to ScriptResponse.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.

  • StopSessionAsync teardown 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 the ScriptResponse ahead 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

RiskMitigation
Bindings tied to a specific OS API versionGENERATION_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 buildsthe 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 couplingThe isolation_session feature is off by default in the workspace; default builds and existing CI are unaffected
Manual VM testing requiredThe 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-controllableThe 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_session to build the feature into wxc-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 cmd redirection inside the session.
  • Workloads that interact with files inside cwd or 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 typeWhy
GUI applicationsNo display server inside the session; only stdout/stderr captured
Long-running daemonsProcess is expected to exit within process.timeout