diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 181d09650..eb6d184b4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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` `` 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`. | diff --git a/docs/isolation-session/oneshot.md b/docs/isolation-session/oneshot.md index 88726856b..97eebb876 100644 --- a/docs/isolation-session/oneshot.md +++ b/docs/isolation-session/oneshot.md @@ -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 | @@ -131,6 +131,10 @@ interface. "env": ["MYVAR=hello"], "timeout": 30000 }, + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true + }, "experimental": { "isolation_session": {} } @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -349,6 +402,10 @@ wxc-exec.exe --experimental hello.json "commandLine": "whoami", "timeout": 30000 }, + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true + }, "experimental": { "isolation_session": {} } diff --git a/docs/isolation-session/state-aware-rust.md b/docs/isolation-session/state-aware-rust.md index efdfc1f2e..0892fe211 100644 --- a/docs/isolation-session/state-aware-rust.md +++ b/docs/isolation-session/state-aware-rust.md @@ -123,32 +123,148 @@ unrestricted-network acknowledgment — `defaultPolicy=allow` + enforcement — and refuse anything else, including an absent policy (which defaults to the unenforceable `block`). On the **post-provision** phases the network posture is fixed at provision, so any supplied network policy is -rejected and an absent one is inherited. The only other caller-supplied knob -it accepts is the optional Entra `user` bundle, at provision and start. - -| Field | provision | start | exec | stop | deprovision | -|---|---|---|---|---|---| -| `policy.filesystem.{readwritePaths,readonlyPaths}` | rejected | rejected | rejected | rejected | rejected | -| `policy.filesystem.deniedPaths` | rejected | rejected | rejected | rejected | rejected | -| `policy.network` — canonical `allow` acknowledgment (`defaultPolicy=allow` + `allowLocalNetwork=true`, no host rules, no proxy, default enforcement) | **required** | rejected | rejected | rejected | rejected | -| `policy.network` — any other value (incl. absent → `block`, host rules, proxy) | rejected | rejected | rejected | rejected | rejected | -| `policy.ui` — see the known gap below | ignored | ignored | ignored | ignored | ignored | -| `experimental.isolation_session.{provision,start}.user` | **honored** | **honored** | n/a | n/a | n/a | - -Rejection of `policy.*` fields surfaces as `error.code = "policy_validation"`. -A malformed `user` shape (UPN missing `@`, empty `wamToken`) likewise surfaces -as `policy_validation`. Start does not cross-check the `user` bundle against -the `sandboxId` tail — the tail is opaque — so there is no identity-mismatch -`malformed_request` path; the OS validates the WAM token against the agent -user it assigned at provision. +rejected and an absent one is inherited. + +UI policy is rejected at every phase, on both surfaces, and **no `ui` posture is +truthful for this backend** — there is no value combination that could be +accepted instead. + +The section states *intent about the contained code's relationship to the user's +environment*, and it 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*: it isolates the host's UI from the contained code, but does not deny +the contained code UI capabilities within its own session. Measured inside a live +session, window creation, GDI, and the session's own clipboard all work; only +input injection is blocked. Field by field: + +| Field | What it asserts | In an isolation session | +|---|---|---| +| `disable: true` | no window creation, no GDI, no `NtUser*`/`NtGdi*` | false — all of it works | +| `disable: false` | may drive a GUI the user can see | false — windows are unreachable and invisible | +| `clipboard: none` / `read` / `write` | a specific relationship to the user's clipboard | false — reaches only the session's own, in both directions | +| `clipboard: all` | may read and write the user's clipboard | false — same reason | +| `injection: false` | no synthetic input | **true** — `SendInput` returns `ERROR_ACCESS_DENIED` | +| `injection: true` | may inject synthetic input | false — injection is blocked regardless | + +Only `injection: false` is honest, and it cannot be expressed on its own: the +other two fields materialize to defaults that are both false here. So there is +nothing to accept, and no acknowledgment-style gate is possible — unlike +`network`, where the canonical unrestricted acknowledgment *is* a true statement +about the container. + +Accepting a `ui` block would also assert the Win32k attack-surface reduction that +`disable: true` implies. That one is not a boundary property at all: a Win32k +kernel exploit escapes a session exactly as it escapes a job, and session +isolation does nothing for it. + +**An omitted `ui` is accepted, and applies no restriction.** The schema's +default-deny reading ("an omitted `ui` is equivalent to full lockdown") does +**not** hold on this backend. The asymmetry with the network gate — which +*requires* a positive acknowledgment and refuses an absent policy — is +deliberate, and rests on how the two defaults fail. An absent `network` defaults +to `block` while the container's network is genuinely open to the outside world, +so the caller is exposed and must acknowledge it. An absent `ui` defaults to +lockdown while the contained code's UI reach never leaves its own session, so +nothing is exposed to acknowledge. Absence is also not a caller statement of +intent; refusing it would fail every request that omits the section, which is +ceremony rather than a control. + +The only caller-supplied knob the backend accepts beyond the network +acknowledgment is the optional Entra `user` bundle, at provision and start. + +The matrix covers the full surface a caller can express, on both the one-shot +and state-aware paths. Dispositions come from the closed set in §10.3 of the +[state-aware design](../state-aware-lifecycle/mxc-state-aware-sandbox-api.md), +plus `required` for the network acknowledgment and `n/a` where a field has no +meaning for this backend. + +| Field | one-shot | provision | start | exec | stop | deprovision | +|---|---|---|---|---|---|---| +| `policy.filesystem.{readwritePaths,readonlyPaths}` | rejected | rejected | rejected | rejected | rejected | rejected | +| `policy.filesystem.deniedPaths` | rejected | rejected | rejected | rejected | rejected | rejected | +| `policy.network` — canonical `allow` acknowledgment (`defaultPolicy=allow` + `allowLocalNetwork=true`, no host rules, no proxy, default enforcement) | **required** | **required** | rejected | rejected | rejected | rejected | +| `policy.network` — any other **supplied** value (host rules, proxy, `defaultPolicy=block`) | rejected | rejected | rejected | rejected | rejected | rejected | +| `policy.network` — **absent** | rejected (defaults to the unenforceable `block`) | rejected (same) | inherited from provision | inherited | inherited | inherited | +| `policy.ui` | rejected | rejected | rejected | rejected | rejected | rejected | +| `lifecycle.destroyOnExit` | `true` accepted; `false` rejected | rejected (whole section) | rejected | rejected | rejected | rejected | +| `lifecycle.preservePolicy` | `false` accepted; `true` rejected | rejected (whole section) | rejected | rejected | rejected | rejected | +| `fallback.allowDaclMutation` | n/a | n/a | n/a | n/a | n/a | n/a | +| `containerId` | accepted, no effect | accepted, no effect | accepted, no effect | accepted, no effect | accepted, no effect | accepted, no effect | +| `process.commandLine` | **honored** | accepted, ignored | accepted, ignored | **honored** | accepted, ignored | accepted, ignored | +| `process.{cwd,env,timeout}` | **honored** | accepted, ignored | accepted, ignored | **honored** | accepted, ignored | accepted, ignored | +| `experimental.isolation_session.user` (flat) | rejected | accepted, ignored | accepted, ignored | accepted, ignored | accepted, ignored | accepted, ignored | +| `experimental.isolation_session..user` | accepted, ignored | **honored** | **honored** | n/a | n/a | n/a | +| `experimental.isolation_session..*` | accepted, ignored | accepted, ignored | accepted, ignored | accepted, ignored | accepted, ignored | accepted, ignored | +| `processContainer` / `lxc` / `seatbelt` (stable sections) | rejected | rejected | rejected | rejected | rejected | rejected | +| another backend's `experimental.` section | rejected | rejected | accepted, ignored if it is the only one | accepted, ignored if the only one | accepted, ignored if the only one | accepted, ignored if the only one | + +Notes on the rows that are not a simple accept/reject: + +- **`lifecycle`** is refused by *value* on one-shot and by *section* on + state-aware. The in-proc API exposes no session-lifetime knob: one-shot always + stops the session and removes the agent user before returning, which is + exactly what `destroyOnExit: true` (the default) asks for — so the default is + honest and accepted. `destroyOnExit: false` asks the session to outlive the + call and cannot be delivered; `preservePolicy: true` is meaningless because + filesystem and network policy are rejected outright, leaving nothing to + preserve. On the state-aware path the parser rejects the whole `lifecycle` + section for every backend, so no per-value handling applies. +- **`fallback`** is `n/a` rather than `rejected`. `allowDaclMutation` gates an + AppContainer-only DACL fallback this backend never performs, so either value + is vacuously satisfied and neither asserts anything untrue. Bringing it under + the single-backend-section check uniformly across backends is tracked + separately. +- **A lone foreign `experimental.` section on a non-provision phase** is + accepted and ignored, not rejected. Those requests carry no `containment`, so + `validate_experimental_backend_keys` has no resolved backend to compare + against; it rejects two or more foreign keys as unambiguously wrong but + tolerates exactly one. The *stable* sections (`processContainer`, `lxc`, + `seatbelt`) are rejected on every phase by the separate stray-section check. + Closing the lone-foreign-key case requires resolving the backend from the + `sandboxId` prefix, which is cross-backend work tracked separately. +- **`containerId`** is a caller-supplied label, not a restriction. This backend + addresses sandboxes by the OS-assigned agent user name, so the field has no + effect and ignoring it asserts nothing. +- **`process` on non-exec state-aware phases** is accepted and ignored. The + dispatcher reads `process` only on `exec`, so a `commandLine`, `cwd`, `env` or + `timeout` supplied at provision / start / stop / deprovision has no effect and + no error. Nothing runs at those phases, so nothing is lost — but the request is + not what the caller believes it is. Supply `process` only on `exec`. +- **Mis-slotted `experimental.isolation_session` payloads are accepted and + ignored, not rejected.** `deserialize_config` navigates exactly + `experimental..`; anything else in that block + is read by nothing. Three shapes reach that state: + - the flat `experimental.isolation_session.user` on a *state-aware* request + (it is the one-shot spelling, and one-shot does reject it, since one-shot has + no Entra mode); + - a nested `provision` / `start` block on a *one-shot* request; + - a block under a phase that is not this request's phase, e.g. + `{"phase": "start", …, "isolation_session": {"provision": {…}}}`. + + Each is a caller supplying a documented field in an undocumented position, and + the resulting sandbox is *local* rather than Entra-backed. That is a capability + downgrade rather than an escalation — the local agent user is more restricted — + and it surfaces downstream as an authentication failure. Detecting mis-slotted + payloads generically is a cross-backend concern and is deliberately not solved + here. Nest the bundle under the request's own phase; the SDK already does. + +Rejection of `policy.*` fields surfaces on the **state-aware** surface as +`error.code = "policy_validation"`. On the **one-shot** surface the typed variant +is discarded (`ScriptResponse::error`) and the envelope carries +`error.code = "backend_error"` with the reason in the message; one-shot has no +typed policy code today. A malformed `user` shape (UPN missing `@`, empty +`wamToken`) likewise surfaces as `policy_validation`. Start does not cross-check +the `user` bundle against the `sandboxId` tail — the tail is opaque — so there is +no identity-mismatch `malformed_request` path; the OS validates the WAM token +against the agent user it assigned at provision. ## Mode-specific fields ### Fields valid in both modes - `process.commandLine` — required for one-shot and for state-aware exec; - ignored at non-exec state-aware phases (the parser allows `process` to be - absent for non-exec phases). + accepted and ignored at non-exec state-aware phases (the dispatcher reads + `process` only on `exec`, and nothing runs at the other phases). - `process.cwd`, `process.env`, `process.timeout` — optional in both modes, honoured per-process (each exec receives its own block). @@ -156,19 +272,16 @@ user it assigned at provision. Both modes share the same policy matrix above. Every `policy.filesystem` field (`readwritePaths`, `readonlyPaths`, `deniedPaths`) is rejected at every -phase (no host-folder-sharing primitive). The network policy is honesty-gated +phase (no host-folder-sharing primitive). `policy.ui` is likewise rejected at +every phase (no UI-restriction primitive). The network policy is honesty-gated per the matrix — provision requires the canonical unrestricted-network acknowledgment and post-provision rejects any supplied network policy -(inheriting an absent one). One-shot enforces this via `validate_runner`; +(inheriting an absent one). One-shot enforces all of this via `validate_runner`; state-aware enforces it via the `validate_` hooks. -**Known gap — `policy.ui` is silently ignored.** The backend has no -UI-restriction primitive, and it does not validate `policy.ui`, so a supplied -UI policy is accepted and then dropped rather than refused. That is the same -false-guarantee shape the network honesty gate closes: a caller asking for a -UI restriction gets no error and no enforcement. Rejecting `policy.ui` is the -intended end state, not a deliberate exemption — the matrix records `ignored` -because that is the current behavior, not the desired one. +The one asymmetry is `lifecycle`: one-shot refuses it by value (the defaults +match what the backend actually does), while the state-aware parser refuses the +whole section for every backend. See the matrix notes above. ### Fields valid in state-aware only diff --git a/docs/schema.md b/docs/schema.md index a43b29e75..e65ab914e 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -60,6 +60,12 @@ production configs and the dev schema when working on experimental features: // localhost/builtinTestServer are unreachable, rejected) }, + "ui": { + "disable": true, // Disable all UI access (default true) + "clipboard": "none", // "none", "read", "write", or "all" + "injection": false // Allow synthetic input injection + }, + "processContainer": { // Process-based container-specific "leastPrivilege": false, "capabilities": ["internetClient"], @@ -142,6 +148,35 @@ containment tier selected at runtime: runs sharing the same `containerId` can revoke each other's ACEs — use distinct `containerId` values for parallel runs. +### UI Policy + +The `ui` section is the cross-platform UI-restriction policy. Every field is +default-deny, so on a backend that enforces the section an omitted `ui` is +equivalent to full lockdown. **That equivalence is per-backend**: a backend that +does not enforce UI policy applies no restriction whether the section is omitted +or supplied, so an omitted `ui` there is not lockdown. Check the backend's own +documentation before relying on the default. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `disable` | boolean | `true` | Disable all UI access. On Windows ProcessContainer this maps to the Win32k system-call disable mitigation, so the process cannot create windows, use GDI, or make `NtUser*` / `NtGdi*` calls. | +| `clipboard` | enum | `"none"` | Clipboard access level: `"none"`, `"read"`, `"write"`, or `"all"`. | +| `injection` | boolean | `false` | Whether the process may inject synthetic keyboard/mouse input (`SendInput` and friends). | + +**Per-backend support.** `ui` is enforced by the Windows ProcessContainer +backend (via job-object UI restrictions plus the Win32k mitigation — see +[`process-container/UIPolicy_Schema.md`](process-container/UIPolicy_Schema.md)) +and by the macOS Seatbelt backend (via the generated sandbox profile). Other +backends do not implement UI restrictions; each backend's documentation states +whether it applies, rejects, or ignores the section. **IsolationSession refuses +any supplied `ui` at every phase on both surfaces** — no `ui` posture is truthful +for a session-isolated sandbox (see +[`isolation-session/state-aware-rust.md`](isolation-session/state-aware-rust.md)) — +and accepts an omitted one without applying any UI restriction. The Windows +`processContainer.ui` sub-block carries additional ProcessContainer-only fields +(`isolation`, `desktopSystemControl`, `systemSettings`, `ime`) and is valid only +when `containment` is `processcontainer`. + ### Fallback Policy The `fallback` section gates the runner's host-impacting fallbacks. Each flag is an explicit operator consent for a specific mechanism the runner may otherwise pick when the preferred primitive is unavailable. Defaults preserve the pre-fallback-section behavior (all permitted). @@ -174,11 +209,14 @@ force a particular backend. | `"wslc"` | Linux containers via the WSL Container SDK | | `"lxc"` | Native LXC container isolation | | `"microvm"` | MicroVM isolation via Windows HyperV Platform (NanVix microkernel) | +| `"hyperlight"` | MicroVM isolation via Hyperlight + Unikraft with an embedded CPython snapshot (experimental) | +| `"isolation_session"` | Windows isolation session — runs the workload as a freshly-provisioned, per-execution isolated user account in its own OS-managed session (experimental). Dual-mode: one-shot and state-aware. | | `"seatbelt"` | macOS sandbox isolation (Seatbelt) | | `"bubblewrap"` | Unprivileged Linux sandboxing via Bubblewrap/user namespaces (experimental) | -Only the backend section matching the selected `containment` value is used; -other backend sections are ignored. +Only the backend section matching the selected `containment` value is accepted; +a config that also carries an unrelated backend's section is **rejected** with a +"Multiple containment backends configured" error rather than silently ignored. ### State-aware lifecycle envelope diff --git a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md index abbb1b5cd..466410c2a 100644 --- a/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md +++ b/docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md @@ -239,8 +239,10 @@ wire format and have different roles: | `containerId` | One-shot wire envelope (per `docs/schema.md`) | Caller-supplied (or auto-generated random hex) | Human-readable label, used as e.g. AppContainer profile name | State-aware non-provision calls carry `sandboxId` on the request; provision returns it -on the response. Neither shape carries `containerId`. One-shot calls carry `containerId` -(when present); they do not carry `sandboxId`. +on the response. A state-aware request **may** also carry `containerId` — the parser +preserves it into the request the backend receives — but it is inert for backends that +do not use it as a label, and it is never a routing key on the state-aware path. +One-shot calls carry `containerId` (when present); they do not carry `sandboxId`. ## 6. TypeScript SDK @@ -266,6 +268,14 @@ type StateAwareContainmentBackend = Extract Promise; + const startUntyped = startSandbox as unknown as UntypedStart; + + it('backend refuses a provision that supplies a ui policy', async () => { + await assert.rejects( + () => provisionUntyped( + 'isolation_session', + { + network: { defaultPolicy: 'allow', allowLocalNetwork: true }, + ui: { disable: true }, + }, + { experimental: true }, + ), + (err: unknown) => err instanceof MxcError && err.code === 'policy_validation', + ); + }); + + it('backend refuses a lockdown-equivalent ui policy too (presence, not value)', async () => { + // `UiPolicy::default()` is full lockdown, so an explicit lockdown `ui` is + // indistinguishable by value from an absent one — presence drives the + // refusal, exactly as it does for the network acknowledgment. + await assert.rejects( + () => provisionUntyped( + 'isolation_session', + { + network: { defaultPolicy: 'allow', allowLocalNetwork: true }, + ui: {}, + }, + { experimental: true }, + ), + (err: unknown) => err instanceof MxcError && err.code === 'policy_validation', + ); + }); + + it('backend refuses a ui policy on a post-provision phase', async () => { + // No provision needed: `validate_start` refuses before the sandbox id is + // ever resolved against the OS service. + await assert.rejects( + () => startUntyped( + 'iso:not-a-real-sandbox', + { ui: { disable: false, clipboard: 'all', injection: true } }, + { experimental: true }, + ), + (err: unknown) => err instanceof MxcError && err.code === 'policy_validation', + ); + }); }); diff --git a/src/backends/isolation_session/common/src/manager.rs b/src/backends/isolation_session/common/src/manager.rs index 7e0b44b29..ccd02b4fe 100644 --- a/src/backends/isolation_session/common/src/manager.rs +++ b/src/backends/isolation_session/common/src/manager.rs @@ -97,8 +97,11 @@ impl IsolationSessionManager { /// until this returns, the caller constructs the manager via `new` /// afterward — hence an associated function rather than a method. /// - /// Note: `lifecycle.destroyOnExit` is silently ignored on this backend. - /// The in-proc API hardcodes `Indefinite` lifetime. + /// Note: the in-proc API exposes no session-lifetime knob, so `lifecycle` + /// cannot be honored here. Unsupported values are refused by the calling + /// surface rather than ignored — one-shot rejects `destroyOnExit: false` + /// and `preservePolicy: true` in `validate_runner`, and the state-aware + /// parser rejects the whole `lifecycle` section. pub(super) fn add_user( opt_entra_account_name: &str, opt_wam_token: &str, diff --git a/src/backends/isolation_session/common/src/one_shot.rs b/src/backends/isolation_session/common/src/one_shot.rs index a3e77fb54..11285e089 100644 --- a/src/backends/isolation_session/common/src/one_shot.rs +++ b/src/backends/isolation_session/common/src/one_shot.rs @@ -17,6 +17,37 @@ use super::policy::validate_provision_policy; use super::process_options::build_process_options; use super::IsolationSessionRunner; +/// Refuses the `lifecycle` settings the backend cannot honor. +/// +/// Value-based rather than presence-based (unlike `ui`), because the defaults +/// genuinely match the behavior: the in-proc API exposes no session-lifetime +/// knob, so one-shot always stops the session and removes the agent user before +/// returning — exactly what `destroyOnExit: true` asks for. Only the values the +/// backend cannot deliver are refused: +/// +/// * `destroyOnExit: false` asks the session to outlive the call. It cannot. +/// * `preservePolicy: true` asks for filesystem/network policy to be retained +/// past the run. This backend installs no persistent filesystem or network +/// enforcement — filesystem policy is refused outright, and the accepted +/// network policy is an acknowledgment of an unrestricted posture rather than +/// anything applied — so there is nothing to retain. +fn reject_unsupported_lifecycle(request: &ExecutionRequest) -> Result<(), ScriptResponse> { + if !request.lifecycle.destroy_on_exit { + return Err(ScriptResponse::error( + "lifecycle.destroyOnExit=false is not supported by the isolation session backend; \ + the session is always stopped and the agent user removed before the call returns", + )); + } + if request.lifecycle.preserve_policy { + return Err(ScriptResponse::error( + "lifecycle.preservePolicy=true is not supported by the isolation session backend; \ + it installs no persistent filesystem or network enforcement, so there is none \ + to preserve", + )); + } + Ok(()) +} + impl ScriptRunner for IsolationSessionRunner { fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { // One-shot runs the full provision → start → exec → stop → @@ -29,6 +60,7 @@ impl ScriptRunner for IsolationSessionRunner { )); } } + reject_unsupported_lifecycle(request)?; validate_provision_policy(request).map_err(ScriptResponse::from) } @@ -114,7 +146,7 @@ mod tests { use super::*; use wxc_common::models::{ ContainerPolicy, ExperimentalConfig, IsolationSessionConfig, IsolationSessionUser, - NetworkPolicy, + LifecycleConfig, NetworkPolicy, }; fn well_formed_user() -> IsolationSessionUser { @@ -178,4 +210,76 @@ mod tests { resp.error_message ); } + + // ====== lifecycle (value-based: defaults match actual behavior) ====== + + fn canonical_request() -> ExecutionRequest { + ExecutionRequest { + policy: ContainerPolicy { + default_network_policy: NetworkPolicy::Allow, + allow_local_network: true, + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn validate_runner_one_shot_rejects_destroy_on_exit_false() { + // The in-proc API has no session-lifetime knob; one-shot always tears + // the session down, so this asks for something the backend cannot do. + let runner = IsolationSessionRunner::new(); + let req = ExecutionRequest { + lifecycle: LifecycleConfig { + destroy_on_exit: false, + preserve_policy: false, + }, + ..canonical_request() + }; + let resp = runner.validate_runner(&req).unwrap_err(); + assert!( + resp.error_message.contains("lifecycle.destroyOnExit=false"), + "got {}", + resp.error_message + ); + } + + #[test] + fn validate_runner_one_shot_rejects_preserve_policy_true() { + let runner = IsolationSessionRunner::new(); + let req = ExecutionRequest { + lifecycle: LifecycleConfig { + destroy_on_exit: true, + preserve_policy: true, + }, + ..canonical_request() + }; + let resp = runner.validate_runner(&req).unwrap_err(); + assert!( + resp.error_message.contains("lifecycle.preservePolicy=true"), + "got {}", + resp.error_message + ); + } + + #[test] + fn validate_runner_one_shot_accepts_default_lifecycle() { + // `destroyOnExit: true` (the default) is exactly what the backend does, + // so it must not be refused — the gate is value-based for this reason. + let runner = IsolationSessionRunner::new(); + runner.validate_runner(&canonical_request()).unwrap(); + } + + #[test] + fn validate_runner_one_shot_rejects_supplied_ui() { + let runner = IsolationSessionRunner::new(); + let mut req = canonical_request(); + req.policy.ui_specified = true; + let resp = runner.validate_runner(&req).unwrap_err(); + assert!( + resp.error_message.contains("UI policy is not supported"), + "got {}", + resp.error_message + ); + } } diff --git a/src/backends/isolation_session/common/src/policy.rs b/src/backends/isolation_session/common/src/policy.rs index 073f4b89d..4f24b46a0 100644 --- a/src/backends/isolation_session/common/src/policy.rs +++ b/src/backends/isolation_session/common/src/policy.rs @@ -6,6 +6,13 @@ //! Filesystem policy (`rw`, `ro`, `denied`) is rejected at every phase — the //! backend has no host-folder-sharing primitive. //! +//! UI policy is rejected at every phase — the backend has no UI-restriction +//! primitive. The isolation session is a *separate OS session*, which isolates +//! the host's UI from the contained code but does not deny the contained code +//! the capability: window creation, GDI, and the session's own clipboard all +//! work inside it. A `ui` policy therefore cannot be honored, and accepting it +//! would assert a guarantee the backend does not provide. +//! //! Network policy is honesty-gated. The container runs on an unrestricted //! network that MXC cannot filter or deny, so at provision (and one-shot, which //! runs the full lifecycle in one call) the ONLY accepted network policy is the @@ -24,6 +31,12 @@ use super::error::IsolationSessionError; const ERR_FILESYSTEM_POLICY: &str = "filesystem policy is not supported by the isolation session backend"; +const ERR_UI_POLICY: &str = "UI policy is not supported by the isolation session backend; the \ + session isolates the host's UI from the 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. Omitting the ui section is accepted but \ + applies no restriction — it is not the lockdown the schema's default implies. Use a \ + backend that enforces UI policy if you need one"; const ERR_NETWORK_POLICY: &str = "the network is unrestricted and cannot be filtered or denied; \ set network.defaultPolicy=allow and network.allowLocalNetwork=true with no allowed/blocked \ hosts, no proxy, and default enforcement to acknowledge the container is fully \ @@ -43,17 +56,20 @@ pub(super) fn validate_provision_policy( request: &ExecutionRequest, ) -> Result<(), IsolationSessionError> { reject_filesystem_policy(request)?; + reject_ui_policy(request)?; validate_provision_network_policy(request) } /// Validates the request for any non-provision phase (start / exec / stop / /// deprovision). Filesystem policy is rejected (bound to provision and -/// immutable). The network posture is likewise fixed at provision, so a -/// network policy supplied here is refused; an absent one is inherited. +/// immutable). UI policy is rejected (never supported). The network posture is +/// likewise fixed at provision, so a network policy supplied here is refused; +/// an absent one is inherited. pub(super) fn validate_post_provision_policy( request: &ExecutionRequest, ) -> Result<(), IsolationSessionError> { reject_filesystem_policy(request)?; + reject_ui_policy(request)?; if request.policy.network_specified { return Err(IsolationSessionError::Policy( ERR_NETWORK_IMMUTABLE.to_string(), @@ -98,6 +114,18 @@ fn reject_filesystem_policy(request: &ExecutionRequest) -> Result<(), IsolationS Ok(()) } +/// Rejects any supplied UI policy. Presence-based, not value-based: the domain +/// `UiPolicy::default()` is full lockdown, so an explicitly-supplied lockdown +/// `ui` is indistinguishable from an absent one by value — the same blind spot +/// `network_specified` closes for the network policy. Runs after the filesystem +/// check so a filesystem rejection keeps precedence. +fn reject_ui_policy(request: &ExecutionRequest) -> Result<(), IsolationSessionError> { + if request.policy.ui_specified { + return Err(IsolationSessionError::Policy(ERR_UI_POLICY.to_string())); + } + Ok(()) +} + /// Accepts only the canonical unrestricted-network acknowledgment and refuses /// everything else. The container's network is open on both axes — outbound is /// unrestricted and a process inside can listen on a localhost-reachable port — @@ -129,7 +157,7 @@ fn validate_provision_network_policy( #[cfg(test)] mod tests { use super::*; - use wxc_common::models::{ContainerPolicy, ProxyAddress, ProxyConfig}; + use wxc_common::models::{ContainerPolicy, ProxyAddress, ProxyConfig, UiPolicy}; use wxc_common::mxc_error::MxcErrorCode; fn assert_policy_err_contains(err: IsolationSessionError, expected: &str) { @@ -385,6 +413,121 @@ mod tests { ); } + // ====== UI policy (rejected at every phase) ====== + + #[test] + fn provision_policy_rejects_supplied_ui() { + let request = ExecutionRequest { + policy: ContainerPolicy { + ui_specified: true, + ..canonical_allow_policy() + }, + ..Default::default() + }; + assert_policy_err_contains( + validate_provision_policy(&request).unwrap_err(), + "UI policy is not supported", + ); + } + + #[test] + fn provision_policy_accepts_absent_ui() { + // Guard against over-rejection: the canonical request carries no `ui`. + let request = ExecutionRequest { + policy: canonical_allow_policy(), + ..Default::default() + }; + validate_provision_policy(&request).unwrap(); + } + + #[test] + fn provision_policy_rejects_lockdown_equivalent_ui() { + // Presence, not value, drives the refusal. `UiPolicy::default()` is + // full lockdown, so a caller sending an explicit lockdown `ui` is + // indistinguishable by value from one sending none — but the backend + // still cannot deliver the Win32k/clipboard denial the policy asserts. + let request = ExecutionRequest { + policy: ContainerPolicy { + ui_specified: true, + ui: UiPolicy::default(), + ..canonical_allow_policy() + }, + ..Default::default() + }; + assert_policy_err_contains( + validate_provision_policy(&request).unwrap_err(), + "UI policy is not supported", + ); + } + + #[test] + fn post_provision_policy_rejects_supplied_ui() { + let request = ExecutionRequest { + policy: ContainerPolicy { + ui_specified: true, + ..Default::default() + }, + ..Default::default() + }; + assert_policy_err_contains( + validate_post_provision_policy(&request).unwrap_err(), + "UI policy is not supported", + ); + } + + #[test] + fn post_provision_policy_accepts_absent_ui() { + let request = ExecutionRequest::default(); + assert!(validate_post_provision_policy(&request).is_ok()); + } + + #[test] + fn ui_error_takes_precedence_over_network_but_not_filesystem() { + // Ordering is filesystem -> ui -> network, so each existing + // precedence test stays valid and the new check slots in between. + let fs_and_ui = ExecutionRequest { + policy: ContainerPolicy { + readwrite_paths: vec!["C:\\src".to_string()], + ui_specified: true, + ..Default::default() + }, + ..Default::default() + }; + assert_policy_err_contains( + validate_provision_policy(&fs_and_ui).unwrap_err(), + ERR_FILESYSTEM_POLICY, + ); + + // `ui` supplied with a non-canonical (absent -> Block) network: the ui + // rejection fires first. + let ui_and_network = ExecutionRequest { + policy: ContainerPolicy { + ui_specified: true, + ..Default::default() + }, + ..Default::default() + }; + assert_policy_err_contains( + validate_provision_policy(&ui_and_network).unwrap_err(), + "UI policy is not supported", + ); + } + + #[test] + fn ui_rejection_maps_to_policy_validation() { + let request = ExecutionRequest { + policy: ContainerPolicy { + ui_specified: true, + ..Default::default() + }, + ..Default::default() + }; + let err = super::super::error::map_lifecycle_error( + validate_provision_policy(&request).unwrap_err(), + ); + assert_eq!(err.code, MxcErrorCode::PolicyValidation); + } + #[test] fn post_provision_policy_rejects_readwrite_paths() { let request = ExecutionRequest { diff --git a/src/backends/isolation_session/common/src/state_aware.rs b/src/backends/isolation_session/common/src/state_aware.rs index 75c31b513..bf515b1a8 100644 --- a/src/backends/isolation_session/common/src/state_aware.rs +++ b/src/backends/isolation_session/common/src/state_aware.rs @@ -553,6 +553,58 @@ mod tests { assert_eq!(d.code, MxcErrorCode::PolicyValidation); } + // ====== UI policy is refused on every phase ====== + + #[test] + fn every_validate_hook_rejects_supplied_ui() { + // The backend has no UI-restriction primitive at any phase, so all five + // hooks refuse a supplied `ui` rather than accepting and dropping it. + let runner = IsolationSessionRunner::new(); + let req = ExecutionRequest { + policy: ContainerPolicy { + ui_specified: true, + ..request_with_canonical_network().policy + }, + ..Default::default() + }; + + let p = runner.validate_provision(&req, None).unwrap_err(); + assert_eq!(p.code, MxcErrorCode::PolicyValidation); + assert!(p.message.contains("UI policy"), "got {}", p.message); + + for (label, err) in [ + ("start", runner.validate_start("iso:abc", &req, None)), + ("exec", runner.validate_exec("iso:abc", &req, None)), + ("stop", runner.validate_stop("iso:abc", &req, None)), + ( + "deprovision", + runner.validate_deprovision("iso:abc", &req, None), + ), + ] { + let err = err.unwrap_err(); + assert_eq!(err.code, MxcErrorCode::PolicyValidation, "phase {label}"); + assert!( + err.message.contains("UI policy"), + "phase {label}: got {}", + err.message + ); + } + } + + #[test] + fn validate_hooks_accept_absent_ui() { + // Guard against over-rejection. + let runner = IsolationSessionRunner::new(); + runner + .validate_provision(&request_with_canonical_network(), None) + .unwrap(); + let req = ExecutionRequest::default(); + runner.validate_start("iso:abc", &req, None).unwrap(); + runner.validate_exec("iso:abc", &req, None).unwrap(); + runner.validate_stop("iso:abc", &req, None).unwrap(); + runner.validate_deprovision("iso:abc", &req, None).unwrap(); + } + // ====== Entra user bundle validation ====== #[test] diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs index 88bc5495c..08b941df7 100644 --- a/src/core/wxc_common/src/config_parser.rs +++ b/src/core/wxc_common/src/config_parser.rs @@ -1252,7 +1252,12 @@ fn convert_wire_config( // rejected above. let seatbelt = cfg.seatbelt.map(make_seatbelt_config); - // UI section + // UI section. Capture presence before the typed mapping consumes `ui`: + // `UiPolicy::default()` is full lockdown, so an explicit lockdown `ui` is + // otherwise indistinguishable from an absent one, and a backend that cannot + // honor UI restrictions has no way to tell "caller asked for lockdown" from + // "caller said nothing". Twin of `network_specified`. + policy.ui_specified = cfg.ui.is_some(); if let Some(raw_ui) = cfg.ui { let clipboard = raw_ui.clipboard.map(Into::into).unwrap_or_default(); policy.ui = UiPolicy { @@ -2907,6 +2912,43 @@ mod tests { assert!(!req.policy.network_specified); } + #[test] + fn ui_specified_true_when_ui_present() { + // An empty `ui: {}` still counts as "supplied" — the twin of + // `network_specified`. Backends with no UI primitive refuse on + // presence, because `UiPolicy::default()` is full lockdown and so an + // explicit lockdown `ui` is indistinguishable from an absent one. + let json = r#"{"process": {"commandLine": "echo x"}, "ui": {}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(req.policy.ui_specified); + } + + #[test] + fn ui_specified_false_when_ui_absent() { + let json = r#"{"process": {"commandLine": "echo x"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(!req.policy.ui_specified); + } + + #[test] + fn ui_specified_true_on_state_aware_requests() { + let json = r#"{ + "phase": "provision", + "containment": "isolation_session", + "ui": {"disable": true} + }"#; + match load_mxc(json).unwrap() { + MxcRequest::StateAware(p) => assert!(p.request.policy.ui_specified), + other => panic!("expected state-aware request, got {other:?}"), + } + } + #[test] fn filesystem_paths() { let json = r#"{ diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs index 1c4d2fa26..045e856df 100644 --- a/src/core/wxc_common/src/models.rs +++ b/src/core/wxc_common/src/models.rs @@ -530,6 +530,21 @@ pub struct ContainerPolicy { pub network_specified: bool, /// Cross-platform UI policy. pub ui: UiPolicy, + /// Whether the caller supplied a `ui` block on the wire (any field + /// present), captured at parse time. The twin of `network_specified`, and + /// necessary for the same reason: `UiPolicy::default()` is full lockdown, + /// so an absent `ui` and an explicitly-supplied lockdown `ui` are + /// indistinguishable from the other fields here. Parse-derived, never on + /// the wire. + /// + /// Consumed only by IsolationSession today, which has no UI-restriction + /// primitive and refuses a supplied UI policy rather than accepting and + /// dropping it. The other backends that do not enforce `policy.ui` — LXC + /// and Bubblewrap on Linux, Seatbelt on macOS, Windows Sandbox — still + /// accept and ignore it, so this flag being set does not mean a UI policy + /// was honored anywhere; it means only that the caller supplied one. + #[serde(skip)] + pub ui_specified: bool, /// BaseProcessContainer-specific UI config (Windows only, from processContainer.ui). pub base_process_ui: BaseProcessUiConfig, /// Windows denial capture (from `processContainer.captureDenials`). When diff --git a/src/testing/wxc_e2e_tests/tests/e2e_isolation_session_policy.rs b/src/testing/wxc_e2e_tests/tests/e2e_isolation_session_policy.rs new file mode 100644 index 000000000..0a04ef037 --- /dev/null +++ b/src/testing/wxc_e2e_tests/tests/e2e_isolation_session_policy.rs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! IsolationSession policy-refusal tests. +//! +//! The backend refuses policy it cannot honor rather than accepting and +//! dropping it. Every refusal asserted here happens in a `validate_*` hook, +//! which runs *before* any OS-side service call (`ScriptRunner::run` calls +//! `validate_runner` before `execute`; the state-aware dispatcher calls +//! `validate_` before the phase method). So these tests need only a +//! `wxc-exec.exe` built with `--features isolation_session` — no host with the +//! OS-side isolation service, and nothing is ever provisioned. +//! +//! When `wxc-exec.exe` was built without the feature they skip, so the suite is +//! clean on any Windows host. + +use std::sync::OnceLock; + +use serde_json::{json, Value}; +use wxc_e2e_tests::{has_wxc_exe, run_wxc_config, run_wxc_state_aware, CommandResult}; + +static HAS_WXC_EXE: OnceLock = OnceLock::new(); + +fn cached_has_wxc_exe() -> bool { + *HAS_WXC_EXE.get_or_init(has_wxc_exe) +} + +/// Marker in the error text emitted when the binary was built without +/// `--features isolation_session`. +const NOT_COMPILED: &str = "IsolationSession backend not compiled"; + +/// Parse the single JSON envelope on stdout and return `error.code`. +fn error_code_on_stdout(result: &CommandResult) -> String { + let stdout = result.stdout.trim(); + let parsed: Value = serde_json::from_str(stdout).unwrap_or_else(|e| { + panic!( + "{} stdout did not parse as JSON: {}\n--- stdout ---\n{}\n--- stderr ---\n{}", + result.label, e, result.stdout, result.stderr, + ) + }); + parsed + .get("error") + .and_then(|e| e.get("code")) + .and_then(|c| c.as_str()) + .unwrap_or_else(|| { + panic!( + "{} envelope missing error.code\n--- stdout ---\n{}", + result.label, result.stdout, + ) + }) + .to_string() +} + +/// `true` when the run failed only because the feature is not compiled in. +fn skipped_not_compiled(result: &CommandResult) -> bool { + let combined = result.combined_output_with_decoded_base64(); + if combined.contains(NOT_COMPILED) { + println!( + "SKIPPED: {} — wxc-exec.exe was built without --features isolation_session", + result.label + ); + return true; + } + false +} + +// --------------------------------------------------------------------------- +// One-shot: refusals surface through `validate_runner`. +// --------------------------------------------------------------------------- + +#[test] +fn one_shot_refuses_ui_policy() { + if !cached_has_wxc_exe() { + return; + } + + // The isolation session is a separate OS session, which isolates the host's + // UI from the contained code but does not deny it UI capabilities — window + // creation, GDI, and the session's own clipboard all work inside it. A `ui` + // policy therefore cannot be honored and must not be silently accepted. + let result = run_wxc_config( + "isolation_session_one_shot_ui_rejected.json", + &["--experimental"], + ); + if skipped_not_compiled(&result) { + return; + } + let combined = result.combined_output_with_decoded_base64(); + assert!( + combined.contains("UI policy is not supported"), + "expected a UI-policy refusal, got exit {:?}\n--- stdout ---\n{}\n--- stderr ---\n{}", + result.code, + result.stdout, + result.stderr, + ); + assert_ne!(result.code, Some(0), "non-zero exit expected on refusal"); +} + +#[test] +fn one_shot_refuses_destroy_on_exit_false() { + if !cached_has_wxc_exe() { + return; + } + + // The in-proc API exposes no session-lifetime knob: one-shot always stops + // the session and removes the agent user before returning. `false` asks for + // something the backend cannot deliver. + let result = run_wxc_config( + "isolation_session_one_shot_lifecycle_rejected.json", + &["--experimental"], + ); + if skipped_not_compiled(&result) { + return; + } + let combined = result.combined_output_with_decoded_base64(); + assert!( + combined.contains("lifecycle.destroyOnExit=false"), + "expected a lifecycle refusal, got exit {:?}\n--- stdout ---\n{}\n--- stderr ---\n{}", + result.code, + result.stdout, + result.stderr, + ); + assert_ne!(result.code, Some(0), "non-zero exit expected on refusal"); +} + +// --------------------------------------------------------------------------- +// State-aware: refusals surface as a typed envelope on stdout. +// --------------------------------------------------------------------------- + +#[test] +fn state_aware_provision_refuses_ui_policy_with_policy_validation() { + if !cached_has_wxc_exe() { + return; + } + + let request = json!({ + "phase": "provision", + "containment": "isolation_session", + "network": { "defaultPolicy": "allow", "allowLocalNetwork": true }, + "ui": { "disable": true } + }); + let result = run_wxc_state_aware("iso provision + ui", &request, &["--experimental"]); + let code = error_code_on_stdout(&result); + if code == "unsupported_phase" || code == "unsupported_containment" { + println!("SKIPPED: wxc-exec.exe was built without --features isolation_session"); + return; + } + assert_eq!( + code, "policy_validation", + "expected policy_validation for a supplied `ui`, got {:?}; stdout={:?}", + code, result.stdout + ); +} + +#[test] +fn state_aware_provision_accepts_canonical_request_shape() { + if !cached_has_wxc_exe() { + return; + } + + // Guard against over-rejection: the canonical provision shape must still + // get past validation. `--dry-run` stops before the backend provisions + // anything, so this is safe on a host with the OS-side service and on one + // without it alike. + let request = json!({ + "phase": "provision", + "containment": "isolation_session", + "network": { "defaultPolicy": "allow", "allowLocalNetwork": true } + }); + let result = run_wxc_state_aware( + "iso provision canonical (dry-run)", + &request, + &["--experimental", "--dry-run"], + ); + let stdout = result.stdout.trim(); + let parsed: Value = match serde_json::from_str(stdout) { + Ok(v) => v, + Err(_) => panic!("stdout did not parse as JSON: {stdout}"), + }; + if let Some(code) = parsed + .get("error") + .and_then(|e| e.get("code")) + .and_then(|c| c.as_str()) + { + if code == "unsupported_phase" || code == "unsupported_containment" { + println!("SKIPPED: wxc-exec.exe was built without --features isolation_session"); + return; + } + panic!("canonical provision was refused with {code}: {stdout}"); + } + assert!( + parsed.get("result").is_some(), + "expected a result envelope, got {stdout}" + ); +} diff --git a/tests/configs/isolation_session_one_shot_lifecycle_rejected.json b/tests/configs/isolation_session_one_shot_lifecycle_rejected.json new file mode 100644 index 000000000..24b206e2f --- /dev/null +++ b/tests/configs/isolation_session_one_shot_lifecycle_rejected.json @@ -0,0 +1,16 @@ +{ + "version": "0.6.0-alpha", + "containerId": "isolation-session-one-shot-lifecycle-rejected", + "containment": "isolation_session", + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true + }, + "lifecycle": { + "destroyOnExit": false + }, + "process": { + "commandLine": "echo unreachable", + "timeout": 30000 + } +} diff --git a/tests/configs/isolation_session_one_shot_ui_rejected.json b/tests/configs/isolation_session_one_shot_ui_rejected.json new file mode 100644 index 000000000..9798f1098 --- /dev/null +++ b/tests/configs/isolation_session_one_shot_ui_rejected.json @@ -0,0 +1,16 @@ +{ + "version": "0.6.0-alpha", + "containerId": "isolation-session-one-shot-ui-rejected", + "containment": "isolation_session", + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true + }, + "ui": { + "disable": true + }, + "process": { + "commandLine": "echo unreachable", + "timeout": 30000 + } +} diff --git a/tests/configs/isolation_session_state_aware_provision_rejected_ui.json b/tests/configs/isolation_session_state_aware_provision_rejected_ui.json new file mode 100644 index 000000000..0e6cd453a --- /dev/null +++ b/tests/configs/isolation_session_state_aware_provision_rejected_ui.json @@ -0,0 +1,11 @@ +{ + "phase": "provision", + "containment": "isolation_session", + "network": { + "defaultPolicy": "allow", + "allowLocalNetwork": true + }, + "ui": { + "disable": true + } +} diff --git a/tests/scripts/run_isolation_session_state_aware_tests.ps1 b/tests/scripts/run_isolation_session_state_aware_tests.ps1 index 0b8b28f7e..047fa2b7e 100644 --- a/tests/scripts/run_isolation_session_state_aware_tests.ps1 +++ b/tests/scripts/run_isolation_session_state_aware_tests.ps1 @@ -331,6 +331,20 @@ try { Assert-True ($code -eq 'policy_validation') "error.code is 'policy_validation' (got '$code')" } | Out-Null + # Test 1d: provision rejects a `ui` policy. The isolation session isolates + # the host's UI from the contained code but does not deny it UI + # capabilities (window creation, GDI and the session's own clipboard all + # work inside it), so a UI restriction cannot be honored and is refused + # rather than accepted and dropped. Refused up-front, so no cleanup needed. + Run-StateAwareTest "provision (ui policy rejected)" { + $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_provision_rejected_ui.json' -Experimental + Assert-True ($r.ExitCode -ne 0) "exit code is non-zero (policy rejected)" + $envObj = Parse-Envelope -Stdout $r.Stdout + Assert-True ($null -ne $envObj) "stdout is a parseable envelope" + $code = if ($envObj) { $envObj.error.code } else { '' } + Assert-True ($code -eq 'policy_validation') "error.code is 'policy_validation' (got '$code')" + } | Out-Null + # Test 2: start succeeds against the provisioned sandbox. Exercises the # multi-invocation pattern -- provision was a separate wxc-exec process; # this is a fresh wxc-exec process consuming the same sandbox_id. diff --git a/tests/scripts/run_isolation_session_tests.ps1 b/tests/scripts/run_isolation_session_tests.ps1 index 96b7bfbc9..cd0157f8f 100644 --- a/tests/scripts/run_isolation_session_tests.ps1 +++ b/tests/scripts/run_isolation_session_tests.ps1 @@ -311,6 +311,24 @@ $null = $results.Add((Run-IsolationSessionTest "isolation_session_one_shot_netwo -ExpectedExit -1 ` -OutputContains @("network is unrestricted"))) +# One-shot UI rejection: the isolation session is a separate OS session, which +# isolates the host's UI from the contained code but does not deny it UI +# capabilities -- window creation, GDI and the session's own clipboard all work +# inside it. A `ui` policy therefore cannot be honored and is refused rather +# than accepted and dropped. Presence drives the refusal (UiPolicy's default is +# full lockdown, so an explicit lockdown `ui` is indistinguishable by value). +$null = $results.Add((Run-IsolationSessionTest "isolation_session_one_shot_ui_rejected.json" ` + -ExpectedExit -1 ` + -OutputContains @("UI policy is not supported"))) + +# One-shot lifecycle rejection: the in-proc API exposes no session-lifetime +# knob, so one-shot always stops the session and removes the agent user before +# returning. `destroyOnExit: true` (the default) matches that and is accepted; +# `false` asks for something the backend cannot deliver. +$null = $results.Add((Run-IsolationSessionTest "isolation_session_one_shot_lifecycle_rejected.json" ` + -ExpectedExit -1 ` + -OutputContains @("lifecycle.destroyOnExit=false"))) + # ---------------- Concurrent one-shot test ---------------- # # Three wxc-exec processes (A, B, C) run a per-agent PowerShell script