Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ The Rust workspace (`src/`) implements multiple sandboxing backends behind the `
| Windows Sandbox | `wxc-exec.exe` | Windows | `backends/windows_sandbox/lifecycle/src/` (live transient one-shot `WindowsSandboxRunner` + state-aware `StatefulSandboxBackend`). Experimental β€” requires `--experimental`. Supports both **one-shot** (a fresh, disposable VM per invocation with guaranteed teardown, via `ScriptRunner`) and **state-aware** (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. State-aware holds a single live VM across separate `wxc-exec` phase processes behind a persistent detached host-side daemon (`backends/windows_sandbox/daemon/`); the OS enforces a single running Windows Sandbox VM per host, so the daemon owns it and reclaims an orphaned VM on restart only via positive process-identity proof. The shared boot sequence (write per-launch nonce, launch VM, capture ownership proof, wait rendezvous, connect) lives in `backends/windows_sandbox/lifecycle/src/vm.rs::launch_managed_vm`; each mode plugs in its own `LaunchObserver` for the per-caller ownership / proof bookkeeping. Honors `readwritePaths`/`readonlyPaths`/`deniedPaths` (HOST paths) at provision via `.wsb` `<MappedFolder>` entries (mapped at the same absolute host path inside the guest; rejects `deniedPaths` equal-to or nested-within a mapped share since `.wsb` has no Deny primitive); filesystem policy is immutable post-provision. Network isolation is enforced unconditionally by the in-guest agent; `network`/`ui` and the Entra `user` bundle are not honored. ID prefix `wsb` (strict `wsb:<8-hex>` grammar). Per-launch handshake: 32-byte `Nonce` + 1-byte `ChannelRole` tag on every TCP connection (boot + reconnect); the guest pairs accepted sockets by declared role, not by accept order. The guest agent binary `wxc-windows-sandbox-guest.exe` (`backends/windows_sandbox/guest/`) is injected into the VM. |
| MicroVM (NanVix) | `wxc-exec.exe` | Windows | `backends/nanvix/runner/src/lib.rs` β€” feature-gated behind `microvm` |
| Hyperlight | `wxc-exec.exe` | Windows | `backends/hyperlight/common/src/lib.rs` β€” Hyperlight + Unikraft micro-VM backend |
| IsolationSession | `wxc-exec.exe` | Windows | `backends/isolation_session/common/src/` β€” feature-gated behind `isolation_session`, experimental, uses the in-proc `Windows.AI.IsolationSession.Preview` `IsoSessionOps` API. Supports both one-shot (single-invocation lifecycle, via `ScriptRunner`) and state-aware (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. Rejects all filesystem policy (`readwritePaths`/`readonlyPaths`/`deniedPaths`) at every phase with `policy_validation` β€” the backend has no host-folder-sharing primitive. The container's network is unrestricted (outbound open; a process inside can listen on a localhost-reachable port) and MXC has no primitive to filter or deny it, so provision (and one-shot) accept ONLY the canonical unrestricted-network acknowledgment β€” `network.defaultPolicy=allow` + `network.allowLocalNetwork=true`, no host rules, no proxy, default enforcement β€” and refuse anything else (including an absent policy, which defaults to the unenforceable deny) with `policy_validation`; post-provision phases reject any supplied network policy (fixed at provision, tracked via `ExecutionRequest.network_specified`) and inherit an absent one. State-aware additionally accepts an optional `user` bundle (`upn`, `wamToken`) at provision and start to provision Entra cloud-agent sandboxes; one-shot rejects the bundle, and hosts that don't support Entra agents surface `backend_unavailable`. Streams stdout/stderr, forwards stdin, and switches to ConPTY mode when wxc-exec's stdout is a TTY for `spawnSandbox` parity. |
| IsolationSession | `wxc-exec.exe` | Windows | `backends/isolation_session/common/src/` β€” feature-gated behind `isolation_session`, experimental, uses the in-proc `Windows.AI.IsolationSession.Preview` `IsoSessionOps` API. Supports both one-shot (single-invocation lifecycle, via `ScriptRunner`) and state-aware (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. Rejects all filesystem policy (`readwritePaths`/`readonlyPaths`/`deniedPaths`) at every phase with `policy_validation` β€” the backend has no host-folder-sharing primitive. Likewise rejects any supplied `ui` policy at every phase on both surfaces (as `policy_validation` on the state-aware surface; one-shot discards the typed variant and surfaces `backend_error` with the reason in the message): the isolation session isolates the *host's* UI from contained code but does not deny it UI capabilities (window creation, GDI and the session's own clipboard all work inside it), so no `ui` posture is truthful here β€” there is no value combination that could be accepted instead, which is why there is no acknowledgment-style gate as there is for `network`. The check is presence-based via `ContainerPolicy::ui_specified` (twin of `network_specified`) because `UiPolicy`'s defaults are full lockdown, making an explicit lockdown `ui` indistinguishable by value from an absent one. An omitted `ui` is accepted and applies no restriction β€” the schema's default-deny reading does not hold on this backend. One-shot additionally rejects `lifecycle.destroyOnExit=false` and `lifecycle.preservePolicy=true` β€” the in-proc API has no session-lifetime knob, and the default `destroyOnExit=true` matches actual behavior so it is accepted; the state-aware parser already rejects the whole `lifecycle` section. The full per-phase honor matrix for both surfaces is in `docs/isolation-session/state-aware-rust.md`. The container's network is unrestricted (outbound open; a process inside can listen on a localhost-reachable port) and MXC has no primitive to filter or deny it, so provision (and one-shot) accept ONLY the canonical unrestricted-network acknowledgment β€” `network.defaultPolicy=allow` + `network.allowLocalNetwork=true`, no host rules, no proxy, default enforcement β€” and refuse anything else (including an absent policy, which defaults to the unenforceable deny) with `policy_validation`; post-provision phases reject any supplied network policy (fixed at provision, tracked via `ExecutionRequest.network_specified`) and inherit an absent one. State-aware additionally accepts an optional `user` bundle (`upn`, `wamToken`) at provision and start to provision Entra cloud-agent sandboxes; one-shot rejects the bundle, and hosts that don't support Entra agents surface `backend_unavailable`. Streams stdout/stderr, forwards stdin, and switches to ConPTY mode when wxc-exec's stdout is a TTY for `spawnSandbox` parity. |
| LXC | `lxc-exec` | Linux | `core/lxc/src/main.rs` + `backends/lxc/common/` |
| Seatbelt | `mxc-exec-mac` | macOS | `core/mxc_darwin/src/main.rs` + `backends/seatbelt/common/` β€” uses macOS App Sandbox (Seatbelt) profiles for process containment. Requires schema `0.7.0-alpha`+. Supports `network.proxy` via the same cooperative env-var model as Bubblewrap (injects `HTTP_PROXY`/`HTTPS_PROXY` into the sandbox, reusing `wxc_common::unix_proxy_coordinator`; `builtinTestServer` spawns the shared `unix-test-proxy`). See `docs/macos-support/seatbelt-backend.md`. |
| Bubblewrap | `lxc-exec` | Linux | `backends/bubblewrap/common/src/bwrap_runner.rs` β€” unprivileged sandboxing via Linux user namespaces and `bwrap`. Experimental β€” requires `--experimental`. Uses shared filesystem/network policy fields; per-host network filtering via `NetworkIptablesManager` from `backends/lxc/common`. See `docs/bwrap-support/bubblewrap-backend.md`. |
Expand Down
101 changes: 79 additions & 22 deletions docs/isolation-session/oneshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ interface.
|---|---|
| `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 `pub mod isolation_session_runner` (cfg-gated) |
| `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`; add `IsolationSessionConfig` |
| `src/core/wxc_common/src/config_parser.rs` | Parse `"isolation_session"` containment and the `experimental.isolation_session` section |
| `src/core/wxc/Cargo.toml` | Add `isolation_session` Cargo feature |
Expand All @@ -131,6 +131,10 @@ interface.
"env": ["MYVAR=hello"],
"timeout": 30000
},
"network": {
"defaultPolicy": "allow",
"allowLocalNetwork": true
},
"experimental": {
"isolation_session": {}
}
Expand Down Expand Up @@ -159,10 +163,11 @@ Activation requires `RoInitialize(RO_INIT_MULTITHREADED)` (handled in
backends).

The API surface includes the lifecycle methods plus
`IsoSessionProcess` (the running-process handle). The runner
uses a minimal subset of the process surface: stdout pipe, stderr
pipe, exit-wait, and exit code. It does not use stdin, terminate,
control signals, or interactive ConPTY mode.
`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

Expand Down Expand Up @@ -216,11 +221,63 @@ versions and stating that the bindings must be regenerated.
`IsoSessionProcessOptions`).
- `process.timeout` (forwarded to the OS-side per-process timeout
enforcement).
- `lifecycle.destroyOnExit` (mapped to the OS-side `LifetimePolicy`: `true` β†’
`CallerProcess`, `false` β†’ `Indefinite`; matches how other backends
interpret this field).
- 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](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.user` | rejected β€” Entra is state-aware-only |
| `experimental.isolation_session.{provision,start}` | accepted, ignored β€” per-phase config is state-aware-only, and the one-shot mapping reads only the flat `user` |
| `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`](state-aware-rust.md).

**Deferred to follow-up work:**

- **Stateful API.** Hosting `IsolationSessionManager` directly so a single
Expand All @@ -229,11 +286,9 @@ versions and stating that the bindings must be regenerated.
precisely to make this migration straightforward later.
- **TypeScript SDK exposure.** Lifting `experimental.isolation_session`
into `SandboxSpawnOptions` so the SDK can spawn isolation-session
workloads programmatically. Today the backend works only via JSON.
- **Interactive ConPTY** (no plans currently). The OS-side
`InteractiveConsole` flag, console resize, and control signals
(CtrlC / CtrlBreak / CtrlClose) are not used by fire-and-forget script
execution.
workloads programmatically **on the one-shot path**. Today the one-shot
backend is reachable only via JSON config (`spawnSandboxFromConfig` or
`wxc-exec` directly); the state-aware lifecycle *is* SDK-exposed.

## Test Plan

Expand All @@ -243,8 +298,8 @@ versions and stating that the bindings must be regenerated.
|---|---|---|
| Config parsing | `config_parser.rs` | `"isolation_session"` containment value and `experimental.isolation_session` section parsing |
| 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 | `isolation_session_runner.rs` | `ExecutionRequest` β†’ `ProcessOptions` mapping (timeout, cwd, env vars, redirect flags) |
| Feature unavailable | `isolation_session_runner.rs` | Runner returns a clean error on machines without the IsolationSession feature enabled, so the test passes everywhere |
| 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
Expand Down Expand Up @@ -291,11 +346,10 @@ The following were observed during VM testing and are accepted for v0.1.
(qualitatively, not quantitatively measured). Documented for awareness;
if it regresses materially, the runner can be reshaped to return the
`ScriptResponse` ahead of teardown.
- **`DeprovisionAgentUserAsync` returning status 1.** Initially observed as
a stderr warning on an earlier OS build. No longer surfacing on the
current OS build. Cleanup proceeds via the OS-side process-exit callback
when `LifetimePolicy: CallerProcess` is used, so the warning was
non-functional even when present.
- **Agent-user deprovision returning a non-success status.** Initially observed
as a stderr warning on an earlier OS build. No longer surfacing on the
current OS build. The one-shot path stops the session and removes the agent
user before returning, so the warning was non-functional even when present.
- **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
Expand All @@ -310,8 +364,7 @@ The following were observed during VM testing and are accepted for v0.1.
| 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) | Accepted for v0.1; experimental flag indicates rough edges. Stateful API is the planned mitigation |
| `ProvisionAgentUserAsync` re-provision hang under `Indefinite` lifetime | Manager calls `GetAgentUser` first and skips a redundant provision when the user already exists |
| `DeprovisionAgentUserAsync` failure under `Indefinite` lifetime | Manager re-provisions with `CallerProcess` lifetime as part of teardown so the OS-side process-exit callback handles cleanup naturally |
| 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

Expand Down Expand Up @@ -349,6 +402,10 @@ wxc-exec.exe --experimental hello.json
"commandLine": "whoami",
"timeout": 30000
},
"network": {
"defaultPolicy": "allow",
"allowLocalNetwork": true
},
"experimental": {
"isolation_session": {}
}
Expand Down
Loading
Loading