diff --git a/README.md b/README.md index 207db6054..77b2cab3b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Microsoft eXecution Container (MXC) -MXC is a **sandboxed code execution system** for running untrusted code +MXC is a **containment-based code execution system** for running untrusted code (model output, plugins, and tools) on Windows, Linux, and macOS. It provides multiple containment backends, from OS-native process sandboxes to full VMs, behind a unified containment model and typed SDKs. @@ -13,7 +13,7 @@ behind a unified containment model and typed SDKs. security policies - **Multiple containment backends**: ProcessContainer, Windows Sandbox, LXC, Bubblewrap, Seatbelt, MicroVM (Nanvix), Hyperlight, IsolationSession, and WSLC -- **Policy-driven sandboxing**: +- **Policy-driven containment**: - **Filesystem policy**: Read-only, read-write, and denied path lists - **Network policy**: Proxy support, outbound controls, and backend-dependent host filtering @@ -189,6 +189,7 @@ build.bat --all # Release build for current architecture |---|---|---| | SDK samples | [`samples/`](samples/README.md) | Runnable Rust, .NET, and Node scenarios | | SDK API reference | [`docs/api-reference/`](docs/api-reference/README.md) | Supported V1 operations and types | +| Containment policy | [`docs/containment-configuration/`](docs/containment-configuration/README.md) | Supported policy contracts by schema version | | Container lifecycle | [`docs/container-lifecycle.md`](docs/container-lifecycle.md) | Persistent container lifecycle overview | | Logging access denied | [`docs/logging-access-denied.md`](docs/logging-access-denied.md) | Diagnose blocked accesses and author policy | | Telemetry | [`docs/telemetry.md`](docs/telemetry.md) | Consent and administrative controls | diff --git a/docs/backends/bwrap/bubblewrap-backend.md b/docs/backends/bwrap/bubblewrap-backend.md index 22a4beadb..acdfeffd5 100644 --- a/docs/backends/bwrap/bubblewrap-backend.md +++ b/docs/backends/bwrap/bubblewrap-backend.md @@ -4,7 +4,7 @@ The Bubblewrap backend provides **unprivileged Linux sandboxing** using [Bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`). It uses -Linux user namespaces to create isolated sandbox environments without +Linux user namespaces to create isolated container environments without requiring root privileges or a container runtime. > **Status:** Stable — the default Linux backend. @@ -36,7 +36,7 @@ requiring root privileges or a container runtime. apk add bubblewrap ``` The deny-by-default baseline (see [How It Works](#how-it-works)) emits its - read-only mounts via `--ro-bind-try` (bwrap 0.3.1+) and the sandbox + read-only mounts via `--ro-bind-try` (bwrap 0.3.1+) and the container environment is built with `--clearenv` (bwrap 0.5.0+), so **bwrap 0.5.0 or newer** is required. Platform detection probes `bwrap --version` and reports the backend as unavailable — with the detected version — when the host is @@ -63,11 +63,11 @@ requiring root privileges or a container runtime. needs none of these additional network tools. > `ip6tables` is required to *deny* IPv6, not to carry it. slirp4netns is - > launched without `--enable-ipv6`, so the sandbox namespace has no IPv6 + > launched without `--enable-ipv6`, so the container namespace has no IPv6 > connectivity at all and the v6 rules exist to keep the unmatched family > closed. An IPv6 destination is unreachable even when a rule allows it > (see #955). On a kernel without IPv6 (built without `CONFIG_IPV6`, or - > booted with `ipv6.disable=1`) the sandbox cannot open an IPv6 socket, so + > booted with `ipv6.disable=1`) the container cannot open an IPv6 socket, so > the runner returns a warning that it is skipping the v6 rules and installs > only the IPv4 chains. The `ip6tables` tools are still probed there, > because they ship in the same package as `iptables`. @@ -98,7 +98,7 @@ requiring root privileges or a container runtime. back to sharing the host network namespace or to running without egress rules. The host must also provide the util-linux `unshare` command with `--map-current-user` and `--keep-caps`. No root is needed: `iptables` runs - against the sandbox's own network namespace, where the supervisor holds + against the container's own network namespace, where the supervisor holds `CAP_NET_ADMIN`. - User namespaces must be enabled: ```bash @@ -134,26 +134,26 @@ Bubblewrap creates a namespace-isolated process by: 1. Unsharing user, PID, IPC, and UTS namespaces (`--unshare-*`) 2. Bind-mounting a **minimal deny-by-default baseline** read-only into the - sandbox (`/bin`, `/sbin`, `/lib*`, `/usr/bin`, `/usr/sbin`, `/usr/lib*`, + container (`/bin`, `/sbin`, `/lib*`, `/usr/bin`, `/usr/sbin`, `/usr/lib*`, `/usr/libexec`, `/usr/share`, `/etc`, plus DNS stub-resolver dirs under `/run`). Everything else on the host — including the caller's `$HOME`, `/root`, `/opt`, `/var`, `/sys`, and `/run/user/` — is - invisible inside the sandbox. + invisible inside the container. 3. Layering filesystem policy overrides (read-write, read-only, denied paths) 4. Setting up minimal `/dev`, `/proc`, and `/tmp` 5. Clearing the environment and applying only requested variables 6. Executing the command via `sh -c` -The sandboxed process runs as a child of `bwrap` and dies automatically when +The contained process runs as a child of `bwrap` and dies automatically when execution completes — no container lifecycle management required. ### Deny-by-default filesystem The baseline mirrors the macOS Seatbelt backend's `(deny default)` posture: -the sandbox can read the dynamic linker, libc, system tools, and system +the container can read the dynamic linker, libc, system tools, and system configuration — and **nothing else** — until the caller opts in via `readonlyPaths` / `readwritePaths`. To make a host directory visible inside -the sandbox, list it explicitly: +the container, list it explicitly: ```json { @@ -167,7 +167,7 @@ the sandbox, list it explicitly: Common consequences of this default: - `$HOME` (e.g. `~/.aws/credentials`, `~/.ssh/id_*`, browser cookies) is - not readable from the sandbox. + not readable from the container. - `/opt` and `/usr/local` tooling is not on PATH; list either path under `readonlyPaths` if the script depends on it. - `working_directory` must live under the baseline or a policy path — a @@ -211,7 +211,7 @@ backend-specific config block is needed. ### Process environment -The host environment is never inherited — the sandbox is built with +The host environment is never inherited — the container is built with `--clearenv`, so host secrets can't leak into untrusted code. **From schema 0.9** the child gets a default block of `PATH` @@ -261,9 +261,9 @@ cannot be stat'd (missing/unreadable) fall back to `--tmpfs`. **Denied paths are resolved through symlinks before masking.** bwrap creates a mask by mounting over the destination path, and it cannot create a mount point when **any** component of that path — the leaf itself *or* an ancestor directory -— is a pre-existing host symlink whose parent is bound into the sandbox (the +— is a pre-existing host symlink whose parent is bound into the container (the mount then resolves through the host symlink and fails with `ENOENT`, aborting -the sandbox). So both `/a/link` (symlinked leaf) and `/a/link/secret` (symlinked +the container). So both `/a/link` (symlinked leaf) and `/a/link/secret` (symlinked ancestor) would abort. A `deniedPaths` entry is therefore rewritten to its real filesystem path before mounting — canonicalizing the deepest existing ancestor (following symlinks at every level) and re-appending any not-yet-created trailing @@ -294,9 +294,9 @@ All supported exact requests use directional `network.egress` and The JSON objects below are network fragments to place in a v0.9+ request. **Full block** (ruleless `egress.default: "deny"`, no runtime proxy) uses -`--unshare-net` for complete network namespace isolation. The sandbox gets a +`--unshare-net` for complete network namespace isolation. The container gets a private network stack with only its own loopback (bwrap brings `lo` up), so -nothing outside the sandbox is reachable and nothing outside can reach in. +nothing outside the container is reachable and nothing outside can reach in. This needs no root or `slirp4netns`. ```json @@ -308,15 +308,15 @@ This needs no root or `slirp4netns`. } ``` -**Address filtering** (`egress.allow` / `egress.deny`) puts the sandbox in a +**Address filtering** (`egress.allow` / `egress.deny`) puts the container in a private, slirp-backed network namespace and programs its rules there from a supervisor holding `CAP_NET_ADMIN` inside an unprivileged user namespace. -**No root required**; the sandbox drops `CAP_NET_ADMIN` before the workload +**No root required**; the container drops `CAP_NET_ADMIN` before the workload starts, so it cannot undo the rules. Rule addresses must be **IP literals or CIDR blocks**; a DNS name is rejected at validation time rather than resolved on the caller's behalf. The backend -does not resolve, because the sandbox resolves names itself and a lookup that +does not resolve, because the container resolves names itself and a lookup that disagreed with the one behind the rules would hand the workload an address the chain never authorized. @@ -324,7 +324,7 @@ chain never authorized. separate concerns and only the second is missing. Filtering works: an IPv6 rule programs `ip6tables`, and the terminal verdict of the unmatched family follows `egress.default`, so an IPv4-only allow rule under `deny` does not leave IPv6 open. -What the sandbox lacks is IPv6 *connectivity* — slirp4netns is launched without +What the container lacks is IPv6 *connectivity* — slirp4netns is launched without `--enable-ipv6`, so the namespace has no IPv6 route at all (see #955). The consequence is one-sided: an IPv6 **block** is already satisfied, while an IPv6 **allow** grants nothing in practice, because the destination stays unreachable @@ -375,10 +375,10 @@ The retired `allowLocalNetwork` field has no v0.9 spelling. Use `network.ingress.default` to express unsolicited inbound policy and `network.ingress.hostLoopback` for the bidirectional host-loopback path. Bubblewrap currently honors only `deny` for either control. An `allow` value -is rejected before sandbox creation: slirp has no host-to-sandbox port +is rejected before container creation: slirp has no host-to-container port forwarding, so no inbound-accepting posture could be delivered. -The sandbox's own loopback remains usable by its processes for `bind()` and +The container's own loopback remains usable by its processes for `bind()` and `listen()`; that does not open an inbound path from the host. Under slirp, host-loopback denial also blocks container-to-host traffic at `10.0.2.2`, ahead of any outbound allow rule. The only exception is the configured @@ -397,7 +397,7 @@ hooked into `INPUT`, for both families: ``` Be honest about what this buys. It is **not** new protection: nothing outside -the sandbox can reach in already, because the runner configures no port +the container can reach in already, because the runner configures no port forwarding into the namespace, so there is no path for an inbound packet to arrive on. The chain is defense in depth against a future change that adds one, and a defense-in-depth implementation of `ingress.default`. The terminal @@ -406,14 +406,14 @@ one, and a defense-in-depth implementation of `ingress.default`. The terminal must not open inbound as a side effect. The `ESTABLISHED,RELATED` accept is not optional. A terminal `INPUT` drop -applies to reply packets too, so without it the sandbox would lose all +applies to reply packets too, so without it the container would lose all networking rather than gain an inbound restriction. That connection-state match requires `nf_conntrack` on the host. Unprivileged Bubblewrap cannot `modprobe`, so if the module is not already loaded the `iptables-restore` transaction fails, iptables rolls the whole table back, and the supervisor aborts before releasing the workload. The failure is loud and -fail-closed by construction, not a silently unenforced sandbox. No separate +fail-closed by construction, not a silently unenforced container. No separate probe is performed: the transaction is a stricter check than probing the userspace extension would be, because it exercises the match in the actual namespace. @@ -448,7 +448,7 @@ section is read. ``` Egress lowers into the namespace-local iptables chains described above. The -supervisor holds `CAP_NET_ADMIN`, the sandbox drops it, and no root is required. +supervisor holds `CAP_NET_ADMIN`, the container drops it, and no root is required. Addresses are IP literals or CIDRs only. An `except` list on a rule is lowered by CIDR subtraction into the remaining covering blocks, so `allow 0.0.0.0/0 except 1.1.1.0/24` becomes a set of accepts that provably @@ -456,7 +456,7 @@ omit the carve-out rather than an accept followed by a hoped-for later deny. **Protocol support.** `ports[].protocol` accepts `tcp`, `udp`, `icmp`, and `any`. What Bubblewrap actually enforces is bounded by its *transport*, not by -its rule engine: every mode that installs egress rules puts the sandbox behind +its rule engine: every mode that installs egress rules puts the container behind `slirp4netns`, a userspace network stack that carries **TCP, UDP, and ICMP echo only**. No other IP protocol — SCTP, DCCP, GRE — has a path out of the namespace, whether or not a rule names it. @@ -517,7 +517,7 @@ container-to-host traffic under slirp at gateway `10.0.2.2`. That drop is lowered *ahead* of every caller rule: a broad allow, including `0.0.0.0/0`, would otherwise win. An omitted `ingress` section enforces the same deny, since deny is the schema's default rather than an absence of policy. This -gateway drop is IPv4 only — slirp gives the sandbox no IPv6 route to the host. +gateway drop is IPv4 only — slirp gives the container no IPv6 route to the host. Proxy mode is the defined exception. The proxy is reached at the gateway `10.0.2.2:`, which *is* host loopback, so its chain opens that single TCP @@ -617,7 +617,7 @@ shared-host-network behavior. 2. The runner creates a same-UID user-namespace supervisor, starts Bubblewrap with `--unshare-net`, and keeps the workload behind a startup barrier. 3. The supervisor attaches `slirp4netns` to Bubblewrap's private network - namespace. Host-loopback proxy endpoints are presented to the sandbox + namespace. Host-loopback proxy endpoints are presented to the container through slirp's `10.0.2.2` host gateway. Once slirp is up, the supervisor programs a default-DROP `MXC_EGRESS` chain into that namespace via `nsenter`, permitting only loopback and the proxy endpoint (IPv6 gets a @@ -635,16 +635,16 @@ shared-host-network behavior. and a partial apply leaves the policy unhooked rather than half-enforced. The workload is released only after every transaction is applied, so it can never run with egress open. A failure to program any - rule aborts the supervisor rather than starting an unenforced sandbox. + rule aborts the supervisor rather than starting a container without enforcement. Bubblewrap joins the supervisor's user namespace (`--userns`) rather than - creating its own, so the sandbox lives in the namespace that owns the + creating its own, so the container lives in the namespace that owns the rule-bearing network namespace. This relies on Bubblewrap dropping - capabilities in the sandboxed process — the runner passes no `--cap-add` — + capabilities in the contained process — the runner passes no `--cap-add` — which is what prevents the workload from holding the `CAP_NET_ADMIN` needed to flush the chain. 4. The command builder sets `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, - `FTP_PROXY`, and their lowercase variants inside the sandbox via + `FTP_PROXY`, and their lowercase variants inside the container via `bwrap --setenv` (caller-supplied values for these keys, including `NO_PROXY` / `no_proxy`, are stripped before injection). The runner deliberately does **not** set `NO_PROXY`, because exempt destinations would @@ -657,8 +657,8 @@ shared-host-network behavior. ### Losing the network provider mid-run -`slirp4netns` carries the sandbox's only route, so a slirp that dies under a -running workload leaves the sandbox running against a dead network: every +`slirp4netns` carries the container's only route, so a slirp that dies under a +running workload leaves the container running against a dead network: every connection fails with a generic transport error, and the run is attributed to whatever the workload reported. Two checks close that, and both apply to firewall-enforcement mode as well, since it stands up the same supervisor and @@ -668,14 +668,14 @@ the same slirp. slirp *came up*, not that it is still up — so it can already be stale by the time the startup gate opens. The supervisor is re-checked immediately before the gate is released. Any exit fails the run, including a successful one: -slirp's exit code says nothing about whether the sandbox still has a route. +slirp's exit code says nothing about whether the container still has a route. **For the lifetime of the workload.** The supervisor inherits the write end of a pipe nothing ever writes to, and slirp inherits it in turn; the runner keeps only the read end. That descriptor reaches EOF when *both* have exited, which is what separates a dead network from an orphaned slirp still carrying traffic after its supervisor was killed. A monitor thread in the executor watches the -descriptor, terminates the sandbox when it closes, and fails the run naming the +descriptor, terminates the container when it closes, and fails the run naming the supervisor's exit status and a bounded tail of its stderr: ```text @@ -769,28 +769,28 @@ so a Node caller keeps the first answer it received. ### Caveats -- **Host loopback is not sandbox loopback**: inside the private namespace - `127.0.0.1` means *the sandbox itself*, not the host. The configured proxy +- **Host loopback is not container loopback**: inside the private namespace + `127.0.0.1` means *the container itself*, not the host. The configured proxy remains reachable at slirp's gateway address `10.0.2.2`; the runner - rewrites its loopback endpoint so the sandbox can find it. Other + rewrites its loopback endpoint so the container can find it. Other host-local services do **not** come along: slirp itself runs without `--disable-host-loopback`, so the gateway can in principle carry traffic to any host-loopback port, but the egress chain admits only the single `10.0.2.2:` destination and drops the rest. The host-loopback surface is therefore the proxy endpoint alone. -- **The supervisor's user namespace is visible to the sandbox**: in proxy +- **The supervisor's user namespace is visible to the container**: in proxy mode `bwrap` joins the supervisor's user namespace via `--userns` rather than creating its own, and the namespace descriptor stays open in the workload — `bwrap` keeps it across its own `fork`/`exec` and offers no flag to close it. Re-entering the namespace with `setns` requires - `CAP_SYS_ADMIN`, which the sandbox cannot hold: `bwrap` empties the + `CAP_SYS_ADMIN`, which the container cannot hold: `bwrap` empties the capability bounding set before `exec`, so the workload runs with `CapBnd`/`CapEff`/`CapPrm` all zero. The end-to-end test suite asserts those are zero, because that assumption is what makes the exposed descriptor inert. - **Cooperative routing, enforced egress (v0.9+)**: the runner injects `HTTP_PROXY` / `HTTPS_PROXY` so cooperating clients route through the proxy, - and additionally programs a default-DROP egress chain inside the sandbox's + and additionally programs a default-DROP egress chain inside the container's private network namespace. Clients that ignore the env vars (raw sockets, custom HTTP clients) can no longer reach the network directly: only loopback and the proxy endpoint are permitted. DNS is deliberately **not** opened — @@ -835,7 +835,7 @@ The exact v0.9 contract provides no MXC-managed host-list policy for that proxy. | Lifecycle | Create/destroy containers | Process dies on exit; proxy mode's supervisor is reaped with it | **When to use Bubblewrap:** -- Quick sandboxing without root access +- Quick containment without root access - Environments where LXC is not available - Fast iteration (no container create/destroy overhead) @@ -859,14 +859,14 @@ Test configs are in `tests/configs/bubblewrap_*.json`. ## Limitations - **Linux only** — Bubblewrap requires Linux kernel namespaces -- **Deny-by-default filesystem** — the sandbox sees a minimal allowlist +- **Deny-by-default filesystem** — the container sees a minimal allowlist of host paths (system binaries, libs, `/etc`, DNS stub-resolver dirs) and nothing else. `$HOME`, `/opt`, `/var`, `/sys`, `/run/user/`, and `/usr/local` are invisible unless explicitly listed in `readonlyPaths` / `readwritePaths`. There is no separate rootfs — the visible paths are bind-mounted from the host. - **Network filtering** — `network.egress` allows or denies numeric addresses - and CIDRs without root (rules live in the sandbox's own namespace). Allowed + and CIDRs without root (rules live in the container's own namespace). Allowed IPv6 destinations remain unreachable until slirp supports IPv6 here. `runtimeConfig.networkProxy` restricts direct egress to the configured loopback proxy endpoint; hostname policy belongs to that external proxy. diff --git a/docs/backends/lxc/lxc-backend.md b/docs/backends/lxc/lxc-backend.md index d3ff17f04..f43a273cc 100644 --- a/docs/backends/lxc/lxc-backend.md +++ b/docs/backends/lxc/lxc-backend.md @@ -245,7 +245,7 @@ kill. `StdioMode::Inherit` remains unsupported. **`kill()` stops the container,** not just the workload: the workload runs under container init, where nothing aimed at the host `lxc-attach` process reaches it. -**One live sandbox per container name, per process.** A second sandbox naming a +**One live container per container name, per process.** A second container naming a `containerId` this process already holds is refused rather than queued, because LXC reads a run's network section only when the container starts. Omit `containerId` for a generated name. The claim is released when the handle drops. diff --git a/docs/backends/process-container/UIPolicy_Schema.md b/docs/backends/process-container/UIPolicy_Schema.md index 90b5ac182..a6cb5c0dc 100644 --- a/docs/backends/process-container/UIPolicy_Schema.md +++ b/docs/backends/process-container/UIPolicy_Schema.md @@ -15,7 +15,7 @@ The `"ui"` section of the MXC container configuration controls how a contained p - **Job Object UI Restrictions** - **Process Mitigation: Win32k System Call Disable** (`PROCESS_MITIGATION_SYSTEM_CALL_DISABLE_POLICY`) -Developers declare *what the process is allowed to do* — the OS-side sandbox layer translates that into the correct kernel flags and mitigations. +Developers declare *what the process is allowed to do* — the OS-side containment layer translates that into the correct kernel flags and mitigations. ### Design Principles @@ -223,7 +223,7 @@ All fields default to the most restrictive value. **`"ui": {}` = total lockdown. ## Examples -### Example 1: Sandboxed App — GUI enabled, everything else locked down +### Example 1: Contained App — GUI enabled, everything else locked down The process can create and manage its own windows but is fully isolated from other applications and the system. diff --git a/docs/backends/process-container/host-prep.md b/docs/backends/process-container/host-prep.md index 0517422b6..12d3808f7 100644 --- a/docs/backends/process-container/host-prep.md +++ b/docs/backends/process-container/host-prep.md @@ -4,14 +4,14 @@ `wxc-host-prep.exe` is a Windows-only, privileged-by-manifest binary that owns the one-time host setup steps MXC requires before -AppContainer- and other sandboxed workloads can run reliably. It is +AppContainer- and other contained workloads can run reliably. It is shipped alongside `wxc-exec.exe` inside the SDK bin payload. The binary has `requireAdministrator` baked into its embedded application manifest in release builds. The Windows loader prompts for UAC at process start (or, when launched under SYSTEM — e.g. from a scheduled task — satisfies the requirement trivially). The -sandbox launcher `wxc-exec.exe` never elevates itself; all +container launcher `wxc-exec.exe` never elevates itself; all privilege-requiring setup work lives in `wxc-host-prep.exe` instead. > **Migrated from `wxc-exec --prepare-system-drive`.** Earlier diff --git a/docs/backends/process-container/networking.md b/docs/backends/process-container/networking.md index 4551443c2..ee255fc77 100644 --- a/docs/backends/process-container/networking.md +++ b/docs/backends/process-container/networking.md @@ -242,7 +242,7 @@ for unpackaged profile creation. - **Capabilities:** none; no host or peer loopback exemptions. - **Enforcement:** no proxy; external outbound and inbound are dropped. - Intra-sandbox loopback is not part of the shared external-network policy. + Intra-container loopback is not part of the shared external-network policy. When no runtime proxy or backend proxy peer is configured, deny-all is the default and model 3 is also the result of providing no network policy at all: the explicit form, an omitted network block, and an empty `"network": {}` are diff --git a/docs/backends/seatbelt/seatbelt-backend.md b/docs/backends/seatbelt/seatbelt-backend.md index cf2552a15..27646e11b 100644 --- a/docs/backends/seatbelt/seatbelt-backend.md +++ b/docs/backends/seatbelt/seatbelt-backend.md @@ -147,14 +147,14 @@ ingress on just to run a build. These rules are path-scoped, so they never widen IP networking. > ⚠️ **`connect()` is a capability `file-write*` alone didn't grant.** A broad -> `readwritePaths` root lets the sandbox talk to any pre-existing listener +> `readwritePaths` root lets the contained process talk to any pre-existing listener > underneath it — and a Docker, `ssh-agent`, or `gpg-agent` socket is a control > plane. Keep the read-write root narrow, and put sensitive sockets in > `deniedPaths`. ### Always-on baseline -Every sandbox gets these regardless of policy, so the dynamic linker, shells, +Every workload gets these regardless of policy, so the dynamic linker, shells, and standard tools work: | Access | Paths | @@ -163,8 +163,9 @@ and standard tools work: | Read **+ write** | `/dev/null`, `/dev/zero`, `/dev/random`, `/dev/urandom` | | Read-data only | `/` itself — the loader can't resolve path lookups without it | -Every sandbox also gets an unfiltered `(allow file-read-metadata)`, because the -kernel reads metadata on each ancestor directory while resolving a path. +Each generated Seatbelt profile also includes an unfiltered +`(allow file-read-metadata)`, because the kernel reads metadata on each +ancestor directory while resolving a path. `deniedPaths` names that operation explicitly so it still outranks the grant. The `/dev/*` entries are writable because shell redirections (`>/dev/null`, @@ -192,9 +193,8 @@ Anything it hasn't declared is rejected up front. ### Fields (supported schema 0.9+) -This is the cross-backend directional shape accepted by the registered exact -contracts. Schema 0.8 is no longer accepted; see the -[supported network fields](../../schema.md#directional-networking-supported-contracts). +This is the cross-backend directional shape described by the +[supported containment policy](../../containment-configuration/1.0.0/policy.md). > **Omitting `network` entirely denies all IP networking.** Every field below > defaults to `deny`, so a config with no `network` block behaves exactly like @@ -206,14 +206,14 @@ contracts. Schema 0.8 is no longer accepted; see the | `egress.default` | `"deny"` → no *general* outbound rule; baseline `(deny default)` blocks IP sockets, except for the host-loopback path (`ingress.hostLoopback`) and a `runtimeConfig.networkProxy` endpoint, which are carved out of it. `"allow"` → `(allow network-outbound)`, `(allow network-bind (local ip))`, `(allow system-socket)`. Only the first of those three is egress. | | `egress.allow` / `egress.deny` | **Rejected** if non-empty — no CIDR/port/protocol primitive exists | | `ingress.default` | `"allow"` → `(allow network-inbound (local ip))`. This single rule is what permits **both `bind()` and `listen()`**; `network-bind` alone grants `bind()` but not `listen()`. | -| `ingress.hostLoopback` | Controls sandbox → host loopback. May be `"deny"` under `ingress.default: "allow"`; `"allow"` under `ingress.default: "deny"` is rejected. **Defaults to `"deny"`.** | +| `ingress.hostLoopback` | Controls workload → host loopback. May be `"deny"` under `ingress.default: "allow"`; `"allow"` under `ingress.default: "deny"` is rejected. **Defaults to `"deny"`.** | | `runtimeConfig.networkProxy` | Loopback `http`/`https` URL with an explicit port | ### The `hostLoopback` trap > ⚠️ **`ingress.hostLoopback` defaults to `"deny"`.** -This config looks like "let the sandbox use the network": +This config looks like "let the workload use the network": ```json { "network": { "egress": { "default": "allow" } } } @@ -227,7 +227,7 @@ generated profile is: (deny network-outbound (remote ip "localhost:*")) ;; last match wins ``` -**Your sandbox can reach the whole internet but not your own machine** — no +**Your workload can reach the whole internet but not your own machine** — no `localhost:3000` dev server, no local model endpoint. It passes validation silently, because `ingress.default` defaulted to `deny` too and the two agree. @@ -255,7 +255,7 @@ Two more things to know about `hostLoopback`: `hostLoopback` is bidirectional, but Seatbelt can only enforce the outbound half. There's no way to scope an inbound grant by peer: `(local ip)` filters on -the sandbox's *own* bind address, and a `remote ip` inbound filter is a no-op +the workload's *own* bind address, and a `remote ip` inbound filter is a no-op because the peer isn't known at bind time. **`hostLoopback: "allow"` under `ingress.default: "deny"` is rejected**, because @@ -266,7 +266,7 @@ the only rule that could carry the promised inbound grant is the blanket its container→host half *is* expressible — by the `(deny default)` baseline under a denied egress default, and by the explicit `localhost:*` deny under an allowed one. Its host→container half is not, so a host process can still reach -the sandbox's listeners. That residual grant is strictly narrower than the +the workload's listeners. That residual grant is strictly narrower than the alternative it replaces: reaching a listener through `hostLoopback: "allow"` gives up the container→host direction as well. @@ -275,7 +275,7 @@ gives up the container→host direction as well. > profile syntax error, `host must be * or localhost`, exactly as for `remote`. > So a workload that binds `0.0.0.0` rather than `127.0.0.1` is reachable from > **the LAN**, not only from this host. `--bind 127.0.0.1` is a convention the -> workload follows, not one the sandbox can enforce. Prefer a backend with a +> workload follows, not one the containment mechanism can enforce. Prefer a backend with a > private network namespace when that is not acceptable. If you only need *outbound* loopback, `egress.default: "deny"` plus a loopback @@ -287,7 +287,7 @@ This distinction matters, and it's easy to get backwards. | Question | Enforced? | |---|---| -| Can the sandbox reach anything *other than* the proxy? | **No — kernel-enforced**, provided `ingress.hostLoopback` stays `"deny"` (see the caveat below). | +| Can the workload reach anything *other than* the proxy? | **No — kernel-enforced**, provided `ingress.hostLoopback` stays `"deny"` (see the caveat below). | | Will a client actually *speak to* the proxy? | Not enforced — cooperative. | | Is traffic transparently redirected into the proxy? | No. | | Can the proxy contain *inbound* traffic? | No — a proxy confines egress only. Inbound is governed solely by `ingress.default`, and Seatbelt cannot scope that grant by peer or by address. | @@ -301,7 +301,7 @@ recommended `hostLoopback: "deny"` the profile ends up as: (allow network-outbound (remote ip "localhost:")) ``` -That single port is the sandbox's entire outbound universe. The kernel enforces +That single port is the workload's entire outbound universe. The kernel enforces it. A client that opens raw sockets and ignores `HTTP_PROXY` **cannot** reach the internet or any other host-local service — it simply fails to connect. @@ -309,12 +309,12 @@ the internet or any other host-local service — it simply fails to connect. > host*, not just the proxy port, and the confinement claim above no longer > holds. Keep `hostLoopback: "deny"` whenever the proxy is meant to be the only > way out. This won't prevent the proxy's TCP responses from reaching the -> sandbox. +> workload. > > `ingress.default` is a separate decision: it grants inbound only and never > widens outbound, so `{"default": "allow", "hostLoopback": "deny"}` keeps > proxy-only egress while permitting a listener. The tradeoff is inbound — that -> grant cannot be scoped by peer or address, so the sandbox's listeners are +> grant cannot be scoped by peer or address, so the workload's listeners are > reachable from this host and, if the workload binds `0.0.0.0`, from the LAN. **Proxy usage is cooperative.** MXC injects `HTTP_PROXY` / `HTTPS_PROXY` / @@ -326,7 +326,7 @@ way Windows can. **What the profile does not control is where the proxy then connects.** A caller-managed proxy applies its own destination policy; MXC does not supply -one. Configure hostname allow/block lists on that proxy, not in the sandbox +one. Configure hostname allow/block lists on that proxy, not in the containment request. `egress.allow` / `egress.deny` describe direct traffic and cannot be combined with `runtimeConfig.networkProxy`. @@ -349,7 +349,7 @@ Set under a top-level `"seatbelt"` key. | Option | Type | Default | What it does | |---|---|---|---| -| `nestedPty` | bool | `true` | Lets the inner process allocate its own ptys. Needed by anything that spawns a shell — test runners, `git`, `gh`, REPLs, agent tools. Set `false` for a tighter sandbox. | +| `nestedPty` | bool | `true` | Lets the inner process allocate its own ptys. Needed by anything that spawns a shell — test runners, `git`, `gh`, REPLs, agent tools. Set `false` for tighter containment. | | `guiAccess` | bool | `false` | Adds Mach/IOKit rules so GUI apps can create windows, and widens the filesystem — see below. **Requires UI to be enabled**, which is spelled `ui.disable: false` (there is no `ui.enable`). | | `keychainAccess` | bool | `false` | Opens the sandbox enough for `keytar` / Security.framework to reach the Keychain. Opt in only if genuinely needed. | | `profileOverride` | string | unset | Replaces the generated profile with raw TinyScheme. **All `filesystem`/`network`/`ui` policy is ignored for profile generation.** Last resort. | @@ -414,7 +414,7 @@ The child gets a default block of `PATH` (`/usr/bin:/bin:/usr/sbin:/sbin`), `PWD` sits outside the table: it is always exported, set to the resolved working directory. It is applied *after* everything above. It exists so the child's `getcwd()` takes its fast `$PWD` path -instead of walking parent directories the sandbox may not let it read, which +instead of walking parent directories the containment policy may not let it read, which would otherwise leak a "getcwd: … Operation not permitted" line onto stderr. The table is the environment MXC hands the child. macOS `/bin/sh` assigns its @@ -443,9 +443,9 @@ Tools installed outside the default `PATH` need both an env entry **and** a `PATH` defaults to `/usr/bin:/bin:/usr/sbin:/sbin` and each `process.env` entry adds to or overrides that baseline. `inheritDefaultEnv` is rejected. -> ⚠️ **`$HOME` and `TERM` are unset inside the sandbox unless you set them.** +> ⚠️ **`$HOME` and `TERM` are unset in the workload unless you set them.** > Policy paths still accept `~` (expanded against the *host's* `$HOME` when the -> config is parsed), but a script running inside the sandbox cannot use `~` — +> config is parsed), but a script running under containment cannot use `~` — > the shell expands it against an unset `HOME`. `getpwuid()` doesn't help > either, since directory services aren't reachable. Pass `"HOME=…"` in > `process.env` if your command needs it. @@ -549,7 +549,7 @@ Follow the `PATH` instructions it prints (`/opt/homebrew/bin` on Apple silicon). | Node.js | `brew install node` | building/testing the TypeScript SDK | > On Apple silicon Homebrew lives at `/opt/homebrew`, so example configs that -> run Python include `"readonlyPaths": ["/opt/homebrew"]` to let the sandbox +> run Python include `"readonlyPaths": ["/opt/homebrew"]` to let the workload > reach the interpreter and its libraries. **4. Verify** diff --git a/docs/backends/wslc/wsl-container-getting-started.md b/docs/backends/wslc/wsl-container-getting-started.md index 084c40fcb..341789f2e 100644 --- a/docs/backends/wslc/wsl-container-getting-started.md +++ b/docs/backends/wslc/wsl-container-getting-started.md @@ -143,7 +143,7 @@ cost once per image, not once per run. > **Bring-up reaches the network.** A cache miss makes the host fetch > from the image's registry before the container starts. That fetch is -> outside the sandbox's own network policy, so a config declaring +> outside the container's own network policy, so a config declaring > `network.egress.default: "deny"` is **refused** rather than pulled — > warm the cache first, or set `wslc.imageTarPath`. A config that > allows egress pulls on a miss. @@ -592,7 +592,7 @@ container to persist — its daemon holds the session open across phase processe policy-persistence primitive, so there is nothing for the flag to select. Note the state-aware surface differs: it rejects the whole `lifecycle` section -at parse time, because a multi-invocation sandbox's lifetime is driven by the +at parse time, because a multi-invocation container's lifetime is driven by the explicit `provision` / `deprovision` phases rather than by per-run flags. ## Supported workloads diff --git a/docs/backends/wslc/wslc-state-aware.md b/docs/backends/wslc/wslc-state-aware.md index ed975fb3b..bbb05a828 100644 --- a/docs/backends/wslc/wslc-state-aware.md +++ b/docs/backends/wslc/wslc-state-aware.md @@ -43,7 +43,7 @@ Sandbox daemon pattern. The daemon owns the live SDK handles on a **single apartment-affine worker thread**, which services every lifecycle command. Any thread that has joined the MTA may use those handles, so an image pull and an `exec` each run on an MTA thread of their own and post their outcome back to the -worker, leaving it free to serve other sandboxes for the duration of a run. A second `exec` on a +worker, leaving it free to serve other containers for the duration of a run. A second `exec` on a container with a run in flight is refused as `busy`; a lifecycle command naming that container waits for the run, because deleting the container would free a handle the run is using. See [Known limitations](#known-limitations). @@ -69,7 +69,7 @@ is the runtime-owned model, not a wire deserialization DTO. ### Port mappings -`wslc.provision.portMappings` forwards host ports into the sandbox's container, +`wslc.provision.portMappings` forwards host ports into the container, using the same entry shape as the one-shot `wslc.portMappings` list: ```json @@ -112,7 +112,7 @@ from the host itself and not from other machines. WSLC installs it when the container starts rather than at provision, so a `windowsPort` that another process already holds on loopback fails the `start` phase, not `provision`. -## Sandbox IDs +## Container IDs `provision` mints an id of the form `wslc:<32 lowercase hex>` (`wslc:` + a UUID simple form). Raw SDK/FFI requests carry this id in `sandboxId` for every post-provision phase @@ -154,7 +154,7 @@ exec's slot is held until the run has reported back and its client has been written to, so a client that disconnects mid-run keeps counting against that bound while its process is still going, and one that drains slowly keeps counting while its output is still queued. A run whose termination could not be -confirmed leaves its sandbox quarantined and keeps the slot until that sandbox +confirmed leaves its container quarantined and keeps the slot until that container is deprovisioned, because the process may still be alive. Three conditions surface as `busy`, and all reach an SDK caller as @@ -310,7 +310,7 @@ proxy, validation rejections, exec concurrency, and idle teardown. Fixtures live `tests/configs/wslc_state_aware_*.json`. The concurrency section launches a phase without waiting for it (`Start-StateAware` / -`Wait-StateAware`), which is what lets it observe two sandboxes running at once, a refused +`Wait-StateAware`), which is what lets it observe two containers running at once, a refused same-container second exec, and a lifecycle command issued while a run is in flight. Every other section drives one phase process at a time. @@ -319,7 +319,7 @@ section drives one phase process at a time. The `wslc_state_aware_*.json` fixtures are **stateful** — unlike the one-shot configs, they cannot be run individually or in an arbitrary order: -- **Order is mandatory.** A sandbox must go through `provision → start → exec… → stop → deprovision`. +- **Order is mandatory.** A container must go through `provision → start → exec… → stop → deprovision`. `provision` is what boots the session and mints the id; every other phase fails without it (`start`/`exec` before provision → `not_provisioned` / `not_started`, and any phase after `deprovision` → `not_provisioned`). @@ -337,8 +337,8 @@ fixtures **through the harness**, not by pointing `wxc-exec --config` at them di ## Known limitations - **Ordering is per-container, not global.** A lifecycle command naming a container with a run in - flight waits for that run; commands for other sandboxes proceed independently. A caller cannot - infer that work on one sandbox completed because work on another did. + flight waits for that run; commands for other containers proceed independently. A caller cannot + infer that work on one container completed because work on another did. - **`busy` collapses to `backend_error` (deferred).** A refused exec reaches an SDK caller as a generic `backend_error` with no indication that retrying would succeed. A retryable wire code diff --git a/docs/containment-configuration/0.9.0/policy.md b/docs/containment-configuration/0.9.0/policy.md new file mode 100644 index 000000000..61fb45d40 --- /dev/null +++ b/docs/containment-configuration/0.9.0/policy.md @@ -0,0 +1,94 @@ +# MXC Containment Policy Spec v0.9.0-alpha + +> **Audience:** MXC consumers + +`0.9.0-alpha` is the oldest **supported** configuration contract. Its +published [schema](../../../schemas/stable/mxc-config.schema.0.9.0-alpha.json) +is immutable. High-level [V1 SDK authoring](../README.md#authoring-a-policy) +targets `1.0.0`; use raw configuration to select this exact published +contract. + +## Policy behavior + +| Section | Policy and configuration | +|---|---| +| `filesystem` | `readwritePaths`, `readonlyPaths`, and `deniedPaths` grant or deny listed paths; each backend also provides its documented baseline access. | +| `network.egress` | Defaults to `deny`. `allow` and `deny` rules match numeric IP/CIDR destinations (`to[].cidr`, optional `except`) and optional protocol/port ranges; explicit deny wins. | +| `network.ingress` | `default` controls private-network inbound traffic; `hostLoopback` controls host-loopback connectivity in both directions. Both independently default to `deny`. | +| `runtimeConfig.networkProxy` | Specifies a runtime HTTP/S proxy endpoint. ProcessContainer, Bubblewrap, and Seatbelt use deny-default egress with empty direct rule lists. WSLC uses all-allow bridged networking and a cooperative proxy. See the [WSLC network guide](../../backends/wslc/wsl-container-getting-started.md#network-proxy-cooperative-unprivileged). | +| `ui` | `disable`, `clipboard` (`none`, `read`, `write`, or `all`), and `injection`. The selected backend's guide describes which UI restrictions it enforces. | +| `fallback.allowDaclMutation` | Allows Windows host-DACL mutation as a filesystem fallback; defaults to `true`. Set to `false` to refuse this fallback. | +| `lifecycle` | `destroyOnExit` and `preservePolicy` control cleanup and retained policy. | +| `process` | `cwd`, `env` (`KEY=VALUE` entries), `inheritDefaultEnv`, and `timeout` (milliseconds). The default environment applies when `env` is absent; supplied `env` (including `[]`) replaces it, and `inheritDefaultEnv: true` layers supplied entries over it. | + +Backend-specific settings belong under their matching section, such as +`processContainer`, `wslc`, `lxc`, or `seatbelt`. In particular, +`processContainer.filesystem.enumeratePaths` requires a BaseContainer host +with PSEC enumeration support. Exact-contract acceptance defines the request +shape; a backend may apply, reject, or ignore an accepted field. Check the +[schema guide](../../schema.md#ui-policy) and the selected backend's guide +to confirm which restrictions it enforces before relying on them. + +The directional network rule semantics and backend support summary in the +[1.0.0 policy](../1.0.0/policy.md#directional-network-policy) also apply +to this contract. + +## State-aware policy + +This contract supports IsolationSession and WSLC lifecycle operations. +IsolationSession uses unrestricted networking. One-shot and provision +requests supply `network` with `egress.default`, `ingress.default`, and +`ingress.hostLoopback` explicitly set to `allow`, matching the example below. +For the public API and phase order, see the +[container lifecycle guide](../../container-lifecycle.md); backend guides +describe phase-specific policy support. Raw JSON callers can also consult +the [wire contract](../../development/architecture/container-lifecycle.md#7-wire-contract). + +## Moving to 1.0.0 + +Use [the 1.0.0 contract](../1.0.0/policy.md) for stable raw config and new +high-level SDK authoring. To migrate, use canonical containment and backend +section names, then set `version` to `1.0.0` once the request matches that +contract. + +## Raw JSON authoring + +Raw one-shot requests require `"version": "0.9.0-alpha"` and a non-empty +`process.commandLine`. The default `containment` selects the abstract `process` +intent: `processcontainer` on Windows, `bubblewrap` on Linux, and `seatbelt` +on macOS. Supported selections are `process`, `processcontainer`, `lxc`, +`bubblewrap`, `seatbelt`, `isolation_session`, and `wslc`. This contract also +accepts the `appcontainer` and `macos_sandbox` containment aliases +and `appContainer` and `macos_sandbox` backend-section spellings; use the +canonical names for new requests. Supply backend settings under the section +for the selected backend. + +```json +{ + "version": "0.9.0-alpha", + "containment": "processcontainer", + "process": { "commandLine": "cmd.exe /c echo hello" }, + "network": { + "egress": { "default": "deny" }, + "ingress": { "default": "deny", "hostLoopback": "deny" } + } +} +``` + +State-aware requests select a closed `provision`, `start`, `exec`, `stop`, or +`deprovision` root with `phase`. Provision supplies +`containment`; later phases use the returned `sandboxId`. Exec requires a +`process` with a non-empty `commandLine`. An IsolationSession provision +request must describe its unrestricted network posture: + +```json +{ + "version": "0.9.0-alpha", + "phase": "provision", + "containment": "isolation_session", + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + } +} +``` diff --git a/docs/containment-configuration/1.0.0/policy.md b/docs/containment-configuration/1.0.0/policy.md new file mode 100644 index 000000000..b7f538a06 --- /dev/null +++ b/docs/containment-configuration/1.0.0/policy.md @@ -0,0 +1,161 @@ +# MXC Containment Policy Spec v1.0.0 + +> **Audience:** MXC consumers + +`1.0.0` is the current **stable** containment policy contract. High-level +[Rust](../../api-reference/rust/v1/README.md), +[.NET](../../api-reference/dotnet/v1/README.md), and +[Node](../../api-reference/node/v1/README.md) V1 SDK APIs select this contract +automatically. Its published +[schema](../../../schemas/stable/mxc-config.schema.1.0.0.json) is immutable. + +## Policy behavior + +| Section | Policy and configuration | +|---|---| +| `filesystem` | `readwritePaths`, `readonlyPaths`, and `deniedPaths` specify path grants and denials; each backend also provides its documented baseline access. | +| `network.egress` | `default` (`allow` or `deny`) and direct `allow`/`deny` rules with IP/CIDR destinations and optional protocol/port selectors. Deny wins over allow; the default is `deny`. | +| `network.ingress` | `default` controls private-network inbound traffic; `hostLoopback` controls bidirectional host-loopback traffic. Both independently deny by default. | +| `runtimeConfig.networkProxy` | Runtime HTTP/S proxy setting. ProcessContainer, Bubblewrap, and Seatbelt use deny-default egress with empty direct rule lists. WSLC requires bridged networking (all three directional defaults `allow`) and uses a cooperative proxy. See the [WSLC network guide](../../backends/wslc/wsl-container-getting-started.md#network-proxy-cooperative-unprivileged). | +| `ui` | `disable: true`, `clipboard: "none"`, and `injection: false` are the defaults; the selected backend's guide describes its UI enforcement. | +| `fallback.allowDaclMutation` | Consents to the host-DACL filesystem fallback; defaults to `true`. | +| `lifecycle` | `destroyOnExit` and `preservePolicy` govern cleanup. | +| `process` | Optional `cwd`, `env`, `inheritDefaultEnv`, and `timeout` (milliseconds). The backend environment applies when `env` is absent; supplied `env` (including `[]`) replaces it, and `inheritDefaultEnv: true` layers supplied entries over it. | + +Backend-specific fields, for example `processContainer.filesystem.enumeratePaths` +or `wslc.image`, go in the corresponding backend section. Exact-contract +acceptance defines the request shape; the selected backend may apply, reject, +or ignore an accepted field. Check its guide and the +[UI policy guidance](../../schema.md#ui-policy) to confirm enforcement before +relying on a restriction. + +## Directional network policy + +The defaults for egress, private-network ingress, and host loopback are all +`deny`. `ingress.hostLoopback` resolves independently of `ingress.default`. +Direct egress rules match numeric IP/CIDR destinations (`to`) and optional +protocol and destination-port selectors (`ports`). Destination filtering +uses numeric IP/CIDR rules; application-layer filtering belongs to the +caller-managed proxy. The default `to` matches both IP families, and the +default `ports` matches all protocols and ports. Supplied arrays contain +at least one selector. `to[].except` excludes contained CIDRs; +`ports[].protocol` accepts `tcp`, `udp`, `icmp`, or `any`. `port` is 1–65535; +`endPort` is an inclusive range end that requires `port` and must be greater +than or equal to it. ICMP selectors use the protocol alone. Explicit deny +rules take precedence over allow rules. + +For example, this *direct-egress* request allows TCP/443 to the illustrative +`192.0.2.0/24` range except `192.0.2.7`, and denies everything else. The +example command prints a message to illustrate the policy shape. + +```json +{ + "version": "1.0.0", + "containment": "processcontainer", + "process": { "commandLine": "cmd.exe /c echo direct-egress policy" }, + "network": { + "egress": { + "default": "deny", + "allow": [{ + "to": [{ "cidr": "192.0.2.0/24", "except": ["192.0.2.7/32"] }], + "ports": [{ "protocol": "tcp", "port": 443 }] + }] + }, + "ingress": { "default": "deny", "hostLoopback": "deny" } + } +} +``` + +Each backend enforces a supported subset of the shared network policy: + +| Backend | Supported posture | Requirements | +|---|---|---| +| [ProcessContainer](../../backends/process-container/networking.md) | PSEC-capable hosts enforce direct CIDR/port rules and loopback proxy endpoints; AppContainer maps direction defaults to capabilities. | Proxy use requires `ingress.default: "allow"`. Identity-scoped proxies set `processContainer.network.allowedProxyPeer` and `hostLoopback: "deny"`; identity-less host proxies use `hostLoopback: "allow"` (a weaker development/testing posture). | +| [Bubblewrap](../../backends/bwrap/bubblewrap-backend.md) | Direct CIDR/port rules in a private network namespace, or a caller-managed loopback proxy. | Set `ingress.default` and `hostLoopback` to `deny`. | +| [LXC](../../backends/lxc/lxc-backend.md) | Direct CIDR/port rules for ordinary IP traffic. | Set private-network ingress and host loopback to `deny`. Attached workloads retain `CAP_NET_RAW`, so `AF_PACKET` traffic bypasses the namespace `OUTPUT` chain; these rules are not a confinement guarantee for untrusted workloads. | +| [Seatbelt](../../backends/seatbelt/seatbelt-backend.md) | Default egress actions or a loopback proxy endpoint. | `hostLoopback: "allow"` requires `ingress.default: "allow"`; its guide describes the limits of inbound enforcement. | +| [WSLC](../../backends/wslc/wsl-container-getting-started.md#network-configuration) | Isolated all-`deny`, or bridged all-`allow` networking with an optional cooperative HTTP/S proxy. | The proxy sets workload environment variables; direct sockets follow the bridged posture. | +| [IsolationSession](../../schema.md#isolationsession-unrestricted-networking-09) | Unrestricted networking. | Explicitly set all three direction defaults to `allow`. | + +Choose either direct rules or a runtime proxy as the connectivity model. +In proxy mode, the caller-managed proxy owns destination filtering; the +backend guide describes its raw-socket enforcement and host requirements. + +## State-aware policy + +This version supports IsolationSession and WSLC lifecycle operations. +IsolationSession uses unrestricted networking: one-shot and provision +requests explicitly set egress, ingress, and host loopback to `allow`. +See the [container lifecycle guide](../../container-lifecycle.md) +for the public API and phase order, and the backend guides for phase-specific +policy support. Raw JSON callers can consult the +[wire contract](../../development/architecture/container-lifecycle.md#7-wire-contract). + +## Changes from 0.9.0-alpha + +The [0.9 contract](../0.9.0/policy.md) and 1.0 contract share their canonical +field and value spellings and their one-shot, IsolationSession, and WSLC +request families. Version 1.0 uses the canonical `processcontainer` / +`processContainer` and `seatbelt` spellings; 0.9 also accepts +`appcontainer` / `appContainer` and `macos_sandbox` aliases. For `vm`, +`microvm`, `hyperlight`, and Windows Sandbox requests, use the +[development contract](../v1-dev/policy.md). + +## Typed SDK authoring + +The V1 SDKs author policy as typed request data and select exact `1.0.0` +internally. For example, Node uses `command`, `filesystem`, and `network` +properties, and the SDK supplies the exact `version` and `process.commandLine`: + +```typescript +import { run, type ContainerRequest } from '@microsoft/mxc-sdk/v1'; + +const request: ContainerRequest = { + command: process.platform === 'win32' + ? 'cmd.exe /c echo hello' + : '/bin/sh -c "echo hello"', + containment: { type: 'process' }, + filesystem: { readonlyPaths: [process.cwd()] }, + workingDirectory: process.cwd(), + network: { + egress: { default: 'deny' }, + ingress: { default: 'deny', hostLoopback: 'deny' }, + }, +}; +const result = await run(request); +console.log(result.stdout); +``` + +`process` selects the host-native process backend. The working directory is +granted read-only here; replace it with paths appropriate for your workload. +See the [Rust](../../api-reference/rust/v1/types.md), +[.NET](../../api-reference/dotnet/v1/types.md), and +[Node](../../api-reference/node/v1/types.md) V1 references for each language's +policy types and operation signatures. + +## Raw JSON authoring + +Raw one-shot requests require `"version": "1.0.0"` and a non-empty +`process.commandLine`. The default `containment` selects the abstract `process` +intent: `processcontainer` on Windows, `bubblewrap` on Linux, and `seatbelt` +on macOS. The closed selections are `process`, `processcontainer`, `lxc`, +`bubblewrap`, `seatbelt`, `isolation_session`, and `wslc`. Supply backend +settings under the section matching the selected backend. + +```json +{ + "version": "1.0.0", + "containment": "processcontainer", + "process": { "commandLine": "cmd.exe /c echo hello" }, + "filesystem": { "readonlyPaths": ["C:\\inputs"] }, + "network": { + "egress": { "default": "deny" }, + "ingress": { "default": "deny", "hostLoopback": "deny" } + } +} +``` + +State-aware requests select a closed `provision`, `start`, `exec`, `stop`, or +`deprovision` root with `phase`. Provision specifies +`containment`; later phases use the returned `sandboxId`. Exec also +requires `process`. diff --git a/docs/containment-configuration/README.md b/docs/containment-configuration/README.md new file mode 100644 index 000000000..2c70389b1 --- /dev/null +++ b/docs/containment-configuration/README.md @@ -0,0 +1,38 @@ +# Containment policy by schema version + +> **Audience:** MXC consumers + +These pages explain the filesystem, network, UI, and execution policies +available in each supported configuration contract. The exact contract +determines which fields a request can contain; the selected backend applies, +rejects, or ignores policy fields according to its documented behavior. +Check the [backend guides](../backends/) and [schema guide](../schema.md) +before relying on a restriction: a valid request alone does not establish +that the backend enforces it. + +| Contract | Status | Documentation | +|---|---|---| +| `0.9.0-alpha` | Published; minimum supported | [Policy](0.9.0/policy.md) | +| `1.0.0` | Published; current stable | [Policy](1.0.0/policy.md) | +| `1.1.0-alpha` | Mutable V1 development contract | [Policy](v1-dev/policy.md) | + +The current parser supports the three contracts above. +The `v1-dev/` directory follows the current V1 development contract; its +exact `version` is `1.1.0-alpha` today. A published `1.1.0` would have its +own versioned directory. + +## Authoring a policy + +For typed authoring, use the [Rust](../api-reference/rust/v1/README.md), +[.NET](../api-reference/dotnet/v1/README.md), or +[Node](../api-reference/node/v1/README.md) V1 SDK reference. These APIs select +the published `1.0.0` contract automatically. The +[stable policy guide](1.0.0/policy.md#typed-sdk-authoring) +shows a typed request; the [container lifecycle guide](../container-lifecycle.md) +covers persistent operations. + +Raw JSON callers declare the exact `version` of a supported contract. +Raw configuration can select the mutable development `1.1.0-alpha` contract; +high-level V1 authoring selects published `1.0.0`. The +[versioning guide](../development/architecture/versioning.md) distinguishes +schema contracts, SDK versions, and host capabilities. diff --git a/docs/containment-configuration/v1-dev/policy.md b/docs/containment-configuration/v1-dev/policy.md new file mode 100644 index 000000000..7f4fc23ed --- /dev/null +++ b/docs/containment-configuration/v1-dev/policy.md @@ -0,0 +1,103 @@ +# MXC Containment Policy (V1 development: 1.1.0-alpha) + +> **Audience:** MXC consumers and developers + +`1.1.0-alpha` is the **mutable development** policy contract. It retains +the restrictions of the published +[1.0.0 policy](../1.0.0/policy.md) and adds development-only backend surfaces. +The [V1 SDKs](../README.md#authoring-a-policy) still target published `1.0.0`; +raw configuration selects this development contract. + +## Shared policy + +The development contract retains the canonical [1.0.0 policy](../1.0.0/policy.md): +one-shot requests require a non-empty `process.commandLine`; the common +`filesystem`, directional `network`, `runtimeConfig.networkProxy`, `ui`, +`fallback`, `lifecycle`, and `process` fields keep their meanings. The exact +development contract defines its JSON fields; `--experimental` separately +authorizes backend execution. As in 1.0.0, the default `containment` selects +the host-native `process` backend, and WSLC uses bridged networking for its +cooperative proxy. The selected backend may apply, reject, or ignore an +accepted field; check its guide and the +[UI policy guidance](../../schema.md#ui-policy) before relying on enforcement. + +## Development-only request surfaces + +| Surface | What the exact contract accepts | +|---|---| +| One-shot `vm` | Abstract VM intent, resolving to `windows_sandbox` on Windows. | +| One-shot `windows_sandbox` | Explicit Windows Sandbox selection; optional `windowsSandbox` compatibility settings. See its [backend policy limits](../../backends/windows-sandbox/windows-sandbox.md#policy-support). | +| One-shot `microvm` | NanVix MicroVM selection. See the [NanVix guide](../../backends/nanvix/nanvix.md) for enforceable policy. | +| One-shot `hyperlight` | Hyperlight selection with optional `hyperlight.runtime` guest choice. See its [backend guide](../../backends/hyperlight/hyperlight-backend.md) for supported policy. | +| `windows_sandbox` state-aware | Closed `provision`, `start`, `exec`, `stop`, and `deprovision` lifecycle roots; filesystem policy is supplied at provision and is immutable thereafter. | +| `wslc` state-aware `provision` | Optional `wslc.provision.portMappings` forwards host-loopback TCP ports to this container; it requires bridged networking. See the [WSLC lifecycle guide](../../backends/wslc/wslc-state-aware.md#port-mappings). | +| `test` | Development-only placeholder for exercising feature plumbing. | + +The published IsolationSession and WSLC lifecycle operations remain available, +subject to their phase-specific policy checks. For lifecycle semantics, see +the [consumer lifecycle guide](../../container-lifecycle.md); backend guides +cover phase-specific policy support. Raw JSON callers can consult the +[wire contract](../../development/architecture/container-lifecycle.md#7-wire-contract). + +The WSLC port-mapping field belongs to this development contract's +state-aware provision request. The typed V1 SDKs target published `1.0.0`. +Mappings use unique host ports from 1 through 65535, container ports from +1 through 65535, and TCP; they are installed at `start` and listen on host +loopback. + +**Contract acceptance and execution authorization are separate.** Selecting MicroVM, +Hyperlight, or Windows Sandbox (including the `vm` intent on Windows) requires +the runtime `--experimental` option (or the equivalent raw API option). +Backend validation still checks policy and host support before execution. See +[versioning](../../development/architecture/versioning.md#experimental-flag). + +## Raw JSON authoring + +Raw requests must declare `"version": "1.1.0-alpha"` and match the mutable +[development schema](../../../schemas/dev/mxc-config.schema.1.1.0-alpha.json). +Its accepted shape can change before publication; the current SDK V1 target +remains `1.0.0`. For example, one-shot Windows Sandbox execution: + +```json +{ + "version": "1.1.0-alpha", + "containment": "windows_sandbox", + "process": { + "commandLine": "powershell -NoProfile -Command \"Write-Output 'hello'\"", + "timeout": 60000 + } +} +``` + +Windows Sandbox state-aware provision can supply a filesystem grant: + +```json +{ + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "windows_sandbox", + "filesystem": { "readonlyPaths": ["C:\\inputs"] } +} +``` + +For WSLC port forwarding, provision with an explicit bridged network posture +and the development version: + +```json +{ + "version": "1.1.0-alpha", + "phase": "provision", + "containment": "wslc", + "network": { + "egress": { "default": "allow" }, + "ingress": { "default": "allow", "hostLoopback": "allow" } + }, + "wslc": { + "provision": { + "portMappings": [{ "windowsPort": 8080, "containerPort": 80 }] + } + } +} +``` + +Later phases use the returned `sandboxId`; `exec` also supplies `process`. diff --git a/docs/development/architecture/backends/isolation-session/oneshot.md b/docs/development/architecture/backends/isolation-session/oneshot.md index 7cdbbb4ca..ea89fcb5e 100644 --- a/docs/development/architecture/backends/isolation-session/oneshot.md +++ b/docs/development/architecture/backends/isolation-session/oneshot.md @@ -4,7 +4,7 @@ ## Problem -MXC supports several sandboxing backends, but none of them runs the workload as a +MXC supports several containment 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: @@ -251,7 +251,7 @@ the rationale for each disposition, and the error mapping live in | `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) | +| `containerId` | accepted, no effect (a label; the backend addresses containers by the OS-assigned agent user name) | | `isolationSession` / one-shot `appId` | rejected as `malformed_request` — IsolationSession one-shot configuration uses only the stable top-level policy | | `processContainer` / `lxc` / `seatbelt` / another backend's section | rejected — only the section matching `containment` is accepted | @@ -268,7 +268,7 @@ 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 +container 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 diff --git a/docs/development/architecture/backends/isolation-session/state-aware-rust.md b/docs/development/architecture/backends/isolation-session/state-aware-rust.md index f7ac7d06c..d8d28011f 100644 --- a/docs/development/architecture/backends/isolation-session/state-aware-rust.md +++ b/docs/development/architecture/backends/isolation-session/state-aware-rust.md @@ -67,8 +67,8 @@ experimental opt-in. the existing 3-tier shutdown (close stdin → `SendCtrlClose` → `Terminate`) reaps the agent. See [Cancellation](#cancellation) below. - **Concurrent state-aware sessions.** v1 targets a single state-aware - sandbox per consumer. This is a scoping choice, not an OS limitation — see - [Concurrent state-aware sandboxes](#concurrent-state-aware-sandboxes). + container per consumer. This is a scoping choice, not an OS limitation — see + [Concurrent state-aware containers](#concurrent-state-aware-containers). ## Per-phase config and metadata shapes @@ -118,7 +118,7 @@ legacy fields, mixing postures, or adding rules or proxy settings is a structura |---|---|---| | `agentUserName` | string | The OS-assigned agent account name returned by provisioning, also carried inside the `sandboxId` payload where it serves as the addressing key for every post-provision phase. Format is OS-internal and not stable across builds. | | `agentUserSid` | string | The security identifier (SID) of the agent user, returned by provisioning. Diagnostic only. | -| `ephemeralWorkspacePath` | string | A directory shared between the calling user and this isolated agent user, through which the caller can stage files into the session. Each isolated user can access only its own workspace; the caller can access every concurrent sandbox's workspace. Created at provision and deleted when the sandbox is deprovisioned. It does **not** change the workload's working directory. | +| `ephemeralWorkspacePath` | string | A directory shared between the calling user and this isolated agent user, through which the caller can stage files into the session. Each isolated user can access only its own workspace; the caller can access every concurrent container's workspace. Created at provision and deleted when the container is deprovisioned. It does **not** change the workload's working directory. | `appId` is deliberately **not** echoed in the metadata — the caller supplied the value, so echoing it would be redundant surface. @@ -170,14 +170,14 @@ transparent. **Determinism.** The payload is serialised from a struct rather than a map, so key order is fixed and the same content always yields the same id string. -**Upgrading with live sandboxes.** **Both** the running session and the agent +**Upgrading with live containers.** **Both** the running session and the agent user account survive a binary upgrade: nothing in MXC tears either down when the executable is replaced, and outliving the process is the premise of the whole state-aware lifecycle — `exec` runs in a different process from `start` and addresses the same live session. A session ends at an explicit `stop`, or when `deprovision` removes the agent user (which terminates any session still running under it). An id the running build cannot decode is refused as `malformed_id` on -every phase that takes one, and a sandbox left behind that way cannot be +every phase that takes one, and a container left behind that way cannot be addressed through MXC afterwards — so stop and deprovision **before** replacing the executable. @@ -327,7 +327,7 @@ Notes on the rows that are not a simple accept/reject: its runtime configuration directly to checked engine binding; the dispatcher does not navigate or reparse experimental JSON. - **`containerId`** is not part of the exact state-aware roots. Lifecycle - requests address the sandbox by its returned `sandboxId` after provision. + requests address the container by its returned `sandboxId` after provision. - **`process` on non-exec state-aware phases** is structurally rejected. Supply process settings only on exec; other phases do not run a workload. - **`process.env`**: every process starts from the agent user's default @@ -397,7 +397,7 @@ remove `phase` and `sandboxId` from the JSON payload and pass them as | Phase | Repeated call | Notes | |---|---|---| -| provision | non-idempotent | Each provision mints a fresh agent user. Two provision calls produce two distinct sandboxes. Acceptable: callers manage `sandboxId` state themselves. | +| provision | non-idempotent | Each provision mints a fresh agent user. Two provision calls produce two distinct containers. Acceptable: callers manage `sandboxId` state themselves. | | start | OS-side dependent | Starting an already-started session surfaces an HRESULT from the OS session-start call; mapped to `backend_error` (no specific MXC code). Callers should not call start twice; if they do, the second call's failure does not corrupt the first session. | | exec | per-call | Each exec creates a fresh agent process via `RunProcessWithOptionsAsync`. No deduplication — repeated `commandLine` runs the command repeatedly. | | stop | OS-side dependent | Stopping an already-stopped session surfaces an HRESULT from `StopSessionAsync`; mapped to `backend_error`. The agent user remains — only the running session is gone. | @@ -405,13 +405,13 @@ remove `phase` and `sandboxId` from the JSON payload and pass them as ## Concurrency -### Multiple sandboxes +### Multiple containers Distinct `sandboxId`s map to distinct OS agent users (each provisioning call mints a fresh account). There is no shared registration between them, so concurrent provisions are independent and all succeed. -### Multiple exec calls against the same sandbox +### Multiple exec calls against the same container The runner's `exec` impl blocks under **`Relayed`**: it reuses the one-shot `create_process` path, and that call runs until the agent process @@ -421,11 +421,11 @@ waiter, so the caller decides when to block. Either way, two concurrent exec calls against the same `sandboxId` are not coordinated by MXC; the OS-side service serialises (or rejects, depending on session state) at its own layer. -### Deprovision and concurrent sandboxes +### Deprovision and concurrent containers `deprovision` removes only its own agent user (`deprovision_agent_user`). -Because each sandbox is a distinct OS agent user with no shared registration, -deprovisioning one sandbox does not affect any other concurrent sandbox — +Because each container is a distinct OS agent user with no shared registration, +deprovisioning one container does not affect any other concurrent container — they remain independently addressable until each is deprovisioned in turn. ## Error mapping @@ -491,7 +491,7 @@ semantic error channel, and **only** for non-provision operations. "agent user not provisioned". The same value arriving as a transport failure has no such provenance — it could be any "not found" from activation or RPC — so promoting it would emit a false `stale_id`, whose remediation is "re-provision; treat the id as - dead", and destroy a healthy sandbox. + dead", and destroy a healthy container. - *Non-provision only:* provision mints the agent user. There is no `sandboxId` yet, so reporting a stale one would be incoherent. @@ -579,10 +579,10 @@ terminator and an earlier refusal no longer applies. ## Known issues -### Concurrent state-aware sandboxes +### Concurrent state-aware containers -v1 targets a single state-aware sandbox per consumer (see the -[Out of scope](#out-of-scope-for-v1) note). Each sandbox is an independent OS +v1 targets a single state-aware container per consumer (see the +[Out of scope](#out-of-scope-for-v1) note). Each container is an independent OS agent user with no shared registration, so this is a v1 scoping choice, not an OS limitation. diff --git a/docs/development/architecture/backends/isolation-session/state-aware-typescript.md b/docs/development/architecture/backends/isolation-session/state-aware-typescript.md index 0253ab79f..6c97845f5 100644 --- a/docs/development/architecture/backends/isolation-session/state-aware-typescript.md +++ b/docs/development/architecture/backends/isolation-session/state-aware-typescript.md @@ -54,7 +54,7 @@ for raw exact APIs. |---|---|---|---| | `containment` | `'isolation_session'` | — (**required**) | SDK-owned containment discriminator. | | `network` | `IsolationSessionNetworkConfig` | — (**required**) | The backend's actual unrestricted posture: `{ egress: { default: 'allow' }, ingress: { default: 'allow', hostLoopback: 'allow' } }`. Legacy fields are rejected. Rules, proxies, mixed postures, and omission are rejected, and `network` is not accepted on post-provision phases. | -| `appId` | string | absent | Optional identifier for the calling application, associating the provisioned agent user with its owning app. **A packaged application must supply its Package Family Name in the form `PFN:`** (for example `PFN:Contoso.App_8wekyb3d8bbwe`). An unpackaged application may pass any string. Carried inside the `sandboxId` so later lifecycle phases can recover it without the caller re-supplying it. Validated structurally only (no control characters, at most 256 characters); rejections surface as `MxcError` with `code: 'policy_validation'`. Whitespace and case are preserved exactly, and an explicitly supplied empty string is a **distinct** value from omitting the field. Provision-phase only — it is fixed for the sandbox's lifetime and is not a field of `StartOptions`. | +| `appId` | string | absent | Optional identifier for the calling application, associating the provisioned agent user with its owning app. **A packaged application must supply its Package Family Name in the form `PFN:`** (for example `PFN:Contoso.App_8wekyb3d8bbwe`). An unpackaged application may pass any string. Carried inside the `sandboxId` so later lifecycle phases can recover it without the caller re-supplying it. Validated structurally only (no control characters, at most 256 characters); rejections surface as `MxcError` with `code: 'policy_validation'`. Whitespace and case are preserved exactly, and an explicitly supplied empty string is a **distinct** value from omitting the field. Provision-phase only — it is fixed for the container's lifetime and is not a field of `StartOptions`. | **Metadata (`IsolationSessionProvisionMetadata`):** @@ -62,7 +62,7 @@ for raw exact APIs. |---|---|---| | `agentUserName` | string | OS-assigned account name, also carried inside the `ContainerId` where it is the addressing key for later phases. | | `agentUserSid` | string | SID of the agent user. Diagnostic only. | -| `ephemeralWorkspacePath` | string | A directory shared between the caller and this isolated user for staging files into the session. Each isolated user sees only its own workspace; the caller can access every concurrent sandbox's workspace. Deleted when the sandbox is deprovisioned. Does not change the working directory. | +| `ephemeralWorkspacePath` | string | A directory shared between the caller and this isolated user for staging files into the session. Each isolated user sees only its own workspace; the caller can access every concurrent container's workspace. Deleted when the container is deprovisioned. Does not change the working directory. | `appId` is deliberately **not** echoed in the metadata — the caller supplied the value, so returning it would be redundant surface. The `ContainerId` remains diff --git a/docs/development/architecture/container-lifecycle.md b/docs/development/architecture/container-lifecycle.md index 110229bd1..d0c2d57bc 100644 --- a/docs/development/architecture/container-lifecycle.md +++ b/docs/development/architecture/container-lifecycle.md @@ -1,4 +1,4 @@ -# MXC State-Aware Sandbox API +# MXC State-Aware Container API > **Audience:** MXC developers @@ -37,25 +37,27 @@ ## 1. Summary -This document proposes a state-aware sandbox API for MXC, surfaced alongside the existing -one-shot `spawnSandbox*` family. Five lifecycle phases are exposed at the SDK level: -provision, start, exec, stop, deprovision. Each is a discrete call. Provision returns an -opaque `SandboxId` string the caller persists and forwards to subsequent calls. The API -surface ships stable from `0.6.0` — state-awareness is not itself gated by an -`--experimental` flag. Per-stage configuration is typed per-backend per-phase -under each backend's permanent top-level section. Runtime authorization remains -independent until that backend's participation graduates (§13). Backends opt in by implementing a new +This document describes a state-aware container API for MXC, surfaced alongside the +one-shot `run`, `spawn`, and `spawnWithPty` operations. Five lifecycle phases +are exposed at the SDK level: provision, start, exec, stop, deprovision. +Each is a discrete call. Provision returns an +opaque `ContainerId` the caller persists and forwards to subsequent calls. The API +surface is available in the supported exact contracts beginning with +`0.9.0-alpha`; the typed V1 SDK selects the published `1.0.0` contract. +Per-stage configuration is typed per-backend per-phase under each backend's +permanent top-level section. Experimental backend participation requires +separate runtime authorization (§13). Backends opt in by implementing a `StatefulSandboxBackend` Rust trait. The existing `ScriptRunner` trait is unchanged. A backend's participation mode (state-aware, ephemeral, or both) is declared by which trait or traits it implements. -The mental model: `spawnSandbox` is the composition of the five phases into one call. -State-aware exposes them individually so callers can hold a sandbox between calls, run +The mental model: `run` or `spawn` manages a container for one workload. +State-aware exposes them individually so callers can hold a container between calls, run multiple workloads inside it, and tear it down explicitly. -Sandbox state is owned by the backend's underlying service. The `SandboxId` is the only +Container state is owned by the backend's underlying service. The `ContainerId` is the only handle the caller gets; persisting it between calls is the caller's responsibility. MXC -retains no state between calls and does not become a sandbox orchestrator. Backends with +retains no state between calls and does not become a container orchestrator. Backends with no meaningful state continue to expose only the one-shot surface; state-aware participation is fully opt-in. @@ -64,7 +66,7 @@ elaborates. | MXC layer | What's new | What's unchanged | |---|---|---| -| TypeScript SDK (§6) | Five lifecycle functions: `provisionContainer`, `startContainer`, `spawnInContainer` / `runInContainer`, `stopContainer`, `deprovisionContainer`, plus `spawnInContainerWithPty` for a caller-controlled interactive terminal when supported by the selected backend. Branded `SandboxId` type tagging ids by backend (`containment` named once at provision, inferred from the id thereafter). Per-(backend, phase) typed `*Config` interfaces (e.g. `IsolationSessionProvisionConfig`) that absorb cross-cutting fields directly — no separate policy parameter. Per-phase typed `*Result` types per backend. `AbortSignal` cancellation for promise-returning operations via the existing `SandboxSpawnOptions`; live exec callers use `MxcProcess.kill()` or dispose the returned `MxcPtyProcess`. Typed `MxcError` class carrying a closed-enum `code`. | `spawnSandbox` family preserved. `ContainmentBackend` extension mechanism reused. The existing wire-format-aligned `ProcessConfig` / `FilesystemConfig` / `NetworkConfig` / `UiConfig` interfaces from `sdk/node/src/types.ts` are reused as field types inside the new state-aware Configs. `SandboxSpawnOptions` reused as the third-arg options bag (gains `signal?: AbortSignal`). Existing typed `*Config` naming convention reused. | +| TypeScript SDK (§6) | Typed `provisionContainer`, `startContainer`, `spawnInContainer` / `runInContainer`, `stopContainer`, and `deprovisionContainer` operations, plus `spawnInContainerWithPty` for interactive execution. A branded `ContainerId` identifies the provisioned container. Operation-specific options carry telemetry; PTY options also carry size. Live process handles expose `kill()` and `dispose()` for termination and cleanup. | The supported V1 entrypoint owns the published exact contract. The SDK keeps request policy separate from operation options and uses the existing native engine and backend validation. | | JSON wire format (§7) | Top-level `phase` discriminator. Top-level `sandboxId`. `containment` carried on provision only; non-provision phases route via the `sandboxId` prefix. Per-phase nesting under each backend's permanent top-level section. Named envelope types as a TypeScript discriminated union over `phase`. Exact registered roots admit only the cross-cutting fields supported by each backend and phase. | One-shot remains the no-`phase` request mode and uses its own exact versioned roots. | | Rust executor (§9) | Exact registered request contracts selected by version, phase, and provision containment; typed neutral operations; checked backend binding; and `StatefulSandboxBackend` dispatch. | `ScriptRunner` trait. Existing one-shot dispatch path. Existing backends function without modification. | | Error model (§8) | Closed enum of 12 error codes. `MxcError` class with `code: ErrorCode`. `details` open object as escape hatch for backend-specific structured information. Exact-root structural failures precede backend validation. | One-shot retains its existing response surface, while exact-contract failures use that surface's structural-error mapping. | @@ -73,14 +75,14 @@ elaborates. ## 2. Context and motivation MXC's existing containment surface runs each invocation as a self-contained lifecycle: set -up the sandbox, execute the workload, tear it down. This shape fits backends whose -sandboxes carry no meaningful state between invocations. +up the container, execute the workload, tear it down. This shape fits backends whose +containers carry no meaningful state between invocations. -It does not fit backends whose sandboxes are inherently persistent. A provisioned isolation +It does not fit backends whose containers are inherently persistent. A provisioned isolation session has a long-lived user profile holding installed tools, configuration, and credentials. A WSL distribution is a long-lived Linux environment with its own filesystem and package set. A Hyper-V virtual machine is a running OS instance. A Docker container -can host a service that lives across many client interactions. For all of these, sandbox +can host a service that lives across many client interactions. For all of these, container state is not a side-effect of the workload; it is part of what the workload depends on. A one-shot API forces these backends to fold the full provision/start/exec/stop/deprovision sequence into every call, discarding any state the workload accumulated. @@ -92,13 +94,13 @@ first-class concept. IsolationSession is the first such backend. The design holds three constraints throughout: - MXC does not take on responsibility for any persistent storage. The durable identifier - of a stateful sandbox belongs to the backend's underlying service; persisting it + of a stateful container belongs to the backend's underlying service; persisting it across calls is the caller's responsibility. - The contract supports easy plug-in by backend developers. Per-phase configuration is typed per-backend in a way that backends with different native lifecycle models can map cleanly. -- MXC's charter stays scoped to managing and executing within sandboxes, ephemeral or - persistent. MXC is the conduit into sandbox APIs, not a state manager itself. +- MXC's charter stays scoped to managing and executing within containers, ephemeral or + persistent. MXC is the conduit into container APIs, not a state manager itself. ## 3. Design philosophy @@ -111,7 +113,7 @@ but does not impose universal semantics on top. For example, a double-stop call whatever the backend reports, and MXC surfaces that response unchanged. **Layered validation.** The SDK validates the envelope (recognised containment, required -fields, branded `SandboxId` type, typed `*Config` shape). The MXC dispatch layer +fields, branded `ContainerId` type, typed `*Config` shape). The MXC dispatch layer re-validates the envelope and adds capability checks. The backend implementation validates per-stage config field values and cross-cutting policy semantics. Each layer validates what it cheaply can, so obvious errors surface without an unnecessary subprocess @@ -126,18 +128,18 @@ capability gaps with no-op stubs. ## 4. Lifecycle model The state-aware API exposes five lifecycle phases. Each is a discrete call. Together they -compose into the full sandbox lifecycle that one-shot `spawnSandbox` runs end-to-end. +compose into the full container lifecycle that one-shot `run` or `spawn` manages end-to-end. | Phase | Valid from state | Resulting state | Output | Purpose | |---|---|---|---|---| -| `provision` | (not provisioned) | provisioned | `sandboxId`, optional metadata | Allocate the sandbox resource | -| `start` | provisioned | running | optional metadata | Bring the sandbox to a state where it can host workloads | +| `provision` | (not provisioned) | provisioned | `ContainerId`, optional metadata | Allocate the container resource | +| `start` | provisioned | running | optional metadata | Bring the container to a state where it can host workloads | | `exec` | running | running | stdout, stderr, exit code | Run a workload; may be called any number of times | -| `stop` | running | provisioned | optional metadata | Take the sandbox out of running; the provisioned resource remains | -| `deprovision` | provisioned | (not provisioned) | optional metadata | Release the provisioned resource; the `SandboxId` becomes invalid | +| `stop` | running | provisioned | optional metadata | Take the container out of running; the provisioned resource remains | +| `deprovision` | provisioned | (not provisioned) | optional metadata | Release the provisioned resource; the `ContainerId` becomes invalid | The five phases form a small state machine over three states: not-provisioned, -provisioned, and running. The `SandboxId` is valid from provision through deprovision; +provisioned, and running. The `ContainerId` is valid from provision through deprovision; once deprovision returns, the id is assumed to no longer route to any backend resource. A backend whose underlying API has no meaningful equivalent for `provision`, `start`, @@ -165,13 +167,15 @@ backend section until they are universalised. ## 5. Identifiers -The `SandboxId` returned by `provision` is the only identifier the caller uses to refer to -the provisioned sandbox in later calls. It is an opaque string at every observable layer -(TS SDK, JSON wire format, CLI output). +The typed SDK returns a `ContainerId` from `provision` and uses it to refer +to the provisioned container in later calls. The same opaque value appears as +`sandboxId` in raw state-aware JSON and as `--container-id` in direct executor +commands. The backend-generated value has a prefix for routing. -The backend generates the `SandboxId` during `provision`. A backend whose underlying API -requires caller-supplied identifiers (e.g., one that uses registration and provisioning -IDs) mints them inside the backend implementation and encodes them into the id string. +The backend generates the underlying identifier during `provision`. A backend +whose underlying API requires caller-supplied identifiers (e.g., one that uses +registration and provisioning IDs) mints them inside the backend implementation +and encodes them into the id string. A backend whose underlying API generates identifiers itself (Docker, future Hyper-V) captures the generated value and encodes it. The first segment is a backend-specific prefix (e.g., `iso:`, `docker:`); past the prefix, the encoding is @@ -184,7 +188,7 @@ without a separate `containment` field on the wire (§7.1). Non-provision roots are deliberately backend-neutral and are not version-affine: an exact contract validates the phase fields, then a recognised `sandboxId` prefix selects the backend. Consequently, a v0.9 start/exec/stop/deprovision -request can operate on a sandbox provisioned through a newer contract when the +request can operate on a container provisioned through a newer contract when the caller holds its valid ID. Provision remains version- and backend-specific. The wire spec and the SDK observably disagree on *which* error fires for an @@ -192,39 +196,39 @@ unrecognised prefix, and this is by design: | Source | Behaviour for an unrecognised `sandboxId` prefix | | ----------------------- | ------------------------------------------------ | -| SDK (TypeScript) | Throws `MxcError { code: 'malformed_id' }` before invoking `mxc_run_state_aware_json` or `mxc_exec_state_aware_json`. The SDK matches the prefix against the closed `StateAwareContainmentBackend` union it was compiled with; an unknown prefix is treated as a malformed id. See `sdk/node/src/state-aware-helper.ts`. | -| SDK (Rust) | `SandboxId::parse` accepts a syntactically valid opaque id without interpreting its prefix. Dispatch returns `MxcError { code: 'unsupported_containment' }` when that prefix is not registered. Empty ids, ids without prefix structure, and ids containing NUL are `malformed_id`. | +| SDK (TypeScript) | Checks the prefix against the closed `LifecycleContainmentKind` union before native dispatch. An unknown prefix produces `MxcError { code: 'malformed_id' }`; the recognised Windows Sandbox `wsb:` prefix produces `unsupported_containment` because it is outside the stable V1 lifecycle surface. See `sdk/node/src/state-aware-helper.ts`. | +| SDK (Rust) | `ContainerId::parse` accepts a syntactically valid opaque id without interpreting its prefix. Dispatch returns `MxcError { code: 'unsupported_containment' }` when that prefix is not registered. Empty ids, ids without prefix structure, and ids containing NUL are `malformed_id`. | | Native FFI entry points | Return `MxcError { code: 'unsupported_containment' }`. The Rust dispatcher parses the prefix successfully but the prefix-to-backend lookup table has no entry for it. See `src/mxc-sdk/src/tools/mxc_common/state_aware_dispatch.rs`. | -A recognised prefix with a malformed body is `malformed_id` from both sources -(§8). The same prefix is exposed on the `StatefulSandboxBackend` trait as +A supported prefix with a malformed body produces `malformed_id` (§8). +The same prefix is exposed on the `StatefulSandboxBackend` trait as `const ID_PREFIX: &'static str` (§9.2) so the default `provision` body can mint synthetic ids with the right prefix; the trait const and the dispatcher's routing table read from the same source, eliminating drift within Rust. A second const, `const BACKEND_KEY: &'static str`, lives alongside `ID_PREFIX` on the trait (§9.2). It carries the wire-format `containment` value for the backend (e.g., -`"isolation_session"`) and matches the SDK's `StateAwareContainmentBackend` member name. +`"isolation_session"`) and matches the SDK's `LifecycleContainmentKind` member name. Checked binding verifies it against the provision backend or the backend resolved from a later operation's ID. Exact adapters have already converted configuration into runtime values; dispatch does not navigate JSON. `ID_PREFIX` and `BACKEND_KEY` are deliberately distinct -strings: `ID_PREFIX` is a compact tag chosen for sandbox-id brevity (e.g. `"iso"`) +strings: `ID_PREFIX` is a compact tag chosen for container-id brevity (e.g. `"iso"`) while `BACKEND_KEY` is the full backend name shared with the SDK type system (e.g. `"isolation_session"`). Backends that pick a long `BACKEND_KEY` for SDK readability -are not forced to repeat that length in every persisted sandbox id. +are not forced to repeat that length in every persisted container id. The SDK exposes the id as a branded TypeScript string parameterised by backend: ```typescript -type SandboxId = - string & { readonly __mxcBrand: 'SandboxId'; readonly __mxcBackend: C }; +type ContainerId = + string & { readonly __mxcBrand: 'ContainerId'; readonly __mxcBackend: C }; ``` The runtime value is a plain string; the brand exists at compile time only. The `__mxcBackend` phantom field carries the backend identity through the type system so non-provision SDK calls can infer their backend from the id without the caller restating it. The brand also prevents callers from accidentally passing other strings (a -`containerId`, a path, a literal) where a `SandboxId` is expected. +path, a literal) where a `ContainerId` is expected. Persisting the id between calls is the caller's responsibility. The caller chooses the storage mechanism. MXC neither tracks the id after `provision` returns nor verifies its @@ -238,13 +242,16 @@ MXC error code. MXC itself retains no caller-side state and performs no validity before the call reaches the backend. Each backend's plan doc (§11.6) documents which native errors map to `stale_id`. -**Disambiguation: `sandboxId` vs `containerId`.** Two different identifiers exist on the -wire format and have different roles: +**Disambiguation: typed `ContainerId` and wire identifiers.** The SDK's +`ContainerId` and the raw state-aware `sandboxId` carry the same +backend-generated routing value. The one-shot JSON `containerId` is a +separate caller-selected label: -| Field | Where it appears | Source | Purpose | -|---|---|---|---| -| `sandboxId` | State-aware wire envelope (§7); SDK return value from `provisionContainer` | System-generated by the backend | Opaque routing identifier; must be passed to subsequent state-aware calls | -| `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 | +| Name | Where it appears | Purpose | +|---|---|---| +| `ContainerId` | Typed SDK result from `provisionContainer` and later lifecycle arguments | Opaque routing identity | +| `sandboxId` | Raw state-aware JSON requests and responses (§7) | The same routing identity, with a backend prefix | +| `containerId` | One-shot JSON request (per `docs/schema.md`) | Caller-selected label, used as e.g. AppContainer profile name | For direct `wxc-exec` lifecycle calls, `--container-id` supplies the opaque lifecycle routing identifier represented as `sandboxId` in raw JSON. It is @@ -253,11 +260,10 @@ for `start`, `exec`, `stop`, and `deprovision`, and is not accepted for `provision`. This is a CLI transport name only; the JSON field and native ABI continue to use `sandboxId`. -State-aware non-provision calls carry `sandboxId` on the request; provision returns it -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`. +Raw state-aware non-provision requests carry `sandboxId`; raw provision +responses return the same field. The typed SDK exposes that value as +`ContainerId`. One-shot JSON requests may carry `containerId` as a label; +they use a distinct request shape from state-aware operations. ## 6. TypeScript SDK @@ -310,10 +316,13 @@ Backend and phase semantics are validated by the native engine. | `stopContainer` | `ContainerId`, `StopOptions?` | `Promise` | | `deprovisionContainer` | `ContainerId`, `DeprovisionOptions?` | `Promise` | -Invocation options control experimental authorization and, for lifecycle and -existing-container operations, optional telemetry. Supplied invocation telemetry -overrides request telemetry but cannot grant persisted consent or override an -administrative restriction. Options do not change the owned wire contract. +Each operation has its own options type. These types carry optional invocation +telemetry; PTY options also carry the initial terminal size. Supplied invocation +telemetry overrides request telemetry but cannot grant persisted consent or +override an administrative restriction. For live execution, use the owning +`MxcProcess` or `MxcPtyProcess` handle to terminate or dispose of the process. +The typed V1 API selects its published exact contract; raw/native entry points +carry experimental authorization separately from request JSON. `validateProvision`, `validateStart`, `validateStop`, `validateDeprovision`, and `validateProcess` use native dry-run validation and return no execution @@ -385,7 +394,7 @@ single call — `phase` fully discriminates which interpretation applies. interface OneShotRequest { phase?: never; // discriminator: absent version: string; - containment: ContainmentType | ContainmentBackend; + containment?: ContainmentType | ContainmentBackend; containerId?: string; process: ProcessConfig; filesystem?: FilesystemConfig; @@ -401,7 +410,7 @@ interface OneShotRequest { interface ProvisionStateAwareRequest { phase: 'provision'; // discriminator version: '1.0.0'; - containment: StateAwareContainmentBackend; + containment: LifecycleContainmentKind; filesystem?: FilesystemConfig; // backend declares per-phase honor network?: NetworkConfig; // backend declares per-phase honor ui?: UiConfig; // backend declares per-phase honor @@ -409,14 +418,14 @@ interface ProvisionStateAwareRequest { provision?: { appId?: string }; }; wslc?: { - provision?: { image?: string; imageTarPath?: string; portMappings?: PortMapping[] }; + provision?: { image?: string; imageTarPath?: string }; }; } interface NonProvisionStateAwareRequest { phase: 'start' | 'exec' | 'stop' | 'deprovision'; // discriminator version: '1.0.0'; - sandboxId: SandboxId; // backend resolved from prefix + sandboxId: string; // backend resolved from prefix process?: ProcessConfig; // exec only filesystem?: FilesystemConfig; // backend declares per-phase honor network?: NetworkConfig; // backend declares per-phase honor @@ -442,8 +451,8 @@ Backend-routing fields: | Field | Type | Required | Description | |---|---|---|---| -| `containment` | `ContainmentType` or `ContainmentBackend` member | One-shot: yes. State-aware: yes for `provision`, absent for `start` / `exec` / `stop` / `deprovision`. | Backend selection on calls that do not yet have a `sandboxId`. | -| `sandboxId` | branded string | State-aware non-provision: yes. Otherwise absent. | Opaque sandbox id returned by `provision`. Carries the backend prefix used to route non-provision calls (§5). | +| `containment` | `ContainmentType` or `ContainmentBackend` member | One-shot: optional (defaults to `process`). State-aware: required for `provision`, absent for `start` / `exec` / `stop` / `deprovision`. | Backend selection on calls that do not yet have a `sandboxId`. | +| `sandboxId` | string | State-aware non-provision: yes. Otherwise absent. | Raw wire spelling of the opaque identity exposed as `ContainerId` by the typed SDK; its prefix routes later phases (§5). | State-aware-only fields: @@ -491,8 +500,9 @@ enumerated here; their definitions live in `docs/schema.md`. ### 7.2 Backend-specific sections Backend-specific configuration uses each backend's permanent top-level JSON -section. The SDK builds these sections internally from the per-(backend, phase) -Configs defined in §6.1: +section. The SDK maps its per-(backend, phase) Configs to the published +fields; raw `1.1.0-alpha` JSON also accepts the WSLC port mappings illustrated +here: ```typescript interface StateAwareBackendSections { @@ -528,8 +538,9 @@ Compile-time enforcement of valid combinations lives on the SDK's per-(backend, Configs (§6.1), not on this illustrative aggregate. Raw-JSON callers writing backend sections directly are validated by the exact Rust contract and `validate_` hooks at runtime (§10.1). Runtime experimental authorization -is supplied separately through `SandboxSpawnOptions.experimental` or the -executor's `--experimental` flag; it is not a request JSON field. +is supplied through a typed native/FFI argument or the executor's +`--experimental` flag; it is not a request JSON field or a typed V1 operation +option. For one-shot calls (phase absent), the top-level backend section directly holds the one-shot config object (e.g., `wslc?: WslcConfig`), as documented in @@ -598,7 +609,7 @@ type NonExecResponseEnvelope = { result: TResult } | { error: ErrorEnve | Phase | `TResult` shape | |---|---| -| `provision` | `{ sandboxId: SandboxId; metadata?: object }` | +| `provision` | `{ sandboxId: string; metadata?: object }` | | `start` | `{ metadata?: object }` | | `stop` | `{ metadata?: object }` | | `deprovision` | `{ metadata?: object }` | @@ -677,8 +688,8 @@ const config: ProvisionRequest<'isolation_session'> = { ingress: { default: 'allow', hostLoopback: 'allow' }, }, }; -const { containerId: sandboxId } = await provisionContainer(config); -// sandboxId = "iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0" +const { containerId } = await provisionContainer(config); +// containerId = "iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0" ``` ```json @@ -717,7 +728,7 @@ backend.provision(&request, Some(provision_config)) ```typescript await startContainer( - sandboxId, + containerId, undefined, ); ``` @@ -753,7 +764,7 @@ backend.start( ```typescript const r = await runInContainer( - sandboxId, + containerId, { command: 'echo hello', timeoutMs: 5000 }, ); // r = { stdout: "hello\n", stderr: "", exitCode: 0 } @@ -790,7 +801,7 @@ native process streams and completion result. #### Phase 4 — stop ```typescript -await stopContainer(sandboxId, {}); +await stopContainer(containerId, {}); ``` ```json @@ -816,7 +827,7 @@ backend.stop("iso:eyJ2ZXJzaW9uIjoxLCJhZ2VudFVzZXJOYW1lIjoiX2lzb19hYmNfMTIzIn0", #### Phase 5 — deprovision ```typescript -await deprovisionContainer(sandboxId, {}); +await deprovisionContainer(containerId, {}); ``` ```json @@ -847,9 +858,10 @@ section when serialising state-aware calls — consumers write `appId` directly fields (`filesystem` / `network` / `runtimeConfig` / `ui`) map to top-level wire fields. Existing-exec proxy authoring is intentionally grouped at `network.runtimeConfig`; the SDK lifts it to the wire-level `runtimeConfig`. -Cross-backend exec fields (`commandLine`, `cwd`, -`env`, `timeout`) flow through the top-level `process` block. The typed SDK requires -`commandLine`. The executor CLI supplies operation and existing sandbox identity through +Cross-backend exec fields (`commandLine`, `cwd`, `env`, `timeout`) flow through +the top-level `process` block. The typed SDK requires +`ExecutionRequest.command` and maps it to wire `process.commandLine`. +The executor CLI supplies operation and existing container identity through `--operation` and `--container-id`; it can complete an `exec` template from arguments after `--` by setting `process.commandLine` before parsing. Trailing commands are rejected for every non-exec operation. The Node SDK receives owned response data and @@ -870,15 +882,15 @@ other state-aware backend, so caller error-handling code is portable across back | Code | Meaning | |---|---| | `malformed_request` | Structural request error: malformed JSON, missing required field, unknown or phase-inappropriate field, recursively unknown backend-specific field, or invalid phase-specific shape | -| `unsupported_containment` | The backend named by `containment` (provision) or implied by a syntactically valid `sandboxId` prefix (non-provision) is not recognised in this build. The TypeScript SDK checks its closed prefix union before dispatch and instead throws `malformed_id`; the typed Rust SDK keeps ids opaque and therefore returns `unsupported_containment` from dispatch, matching raw FFI requests. See §6.4. | +| `unsupported_containment` | The backend named by `containment` (provision) or implied by a syntactically valid `sandboxId` prefix (non-provision) is unsupported by the selected API or build. The TypeScript SDK reports an unknown prefix as `malformed_id`, but reports the recognised experimental Windows Sandbox `wsb:` prefix as `unsupported_containment` on V1; typed Rust and raw FFI dispatch also use `unsupported_containment` for unrecognised prefixes. See §5. | | `unsupported_phase` | The backend does not support the requested call mode (state-aware call against an ephemeral-only backend, or one-shot call against a state-aware-only backend) | | `backend_unavailable` | The backend's runtime dependency is missing or unreachable (service not running, daemon stopped), or the backend is experimental and the caller did not enable experimental features | -| `malformed_id` | The `sandboxId` is structurally invalid or has a recognised prefix but does not deserialize into the backend's native form. The TypeScript SDK also uses this code for a prefix outside its closed `StateAwareContainmentBackend` union; typed Rust and raw FFI calls classify a syntactically valid unknown prefix as `unsupported_containment`. | +| `malformed_id` | The `sandboxId` is structurally invalid or has a recognised prefix but does not deserialize into the backend's native form. The TypeScript SDK also uses this code for an unknown prefix; its recognised `wsb:` prefix instead produces `unsupported_containment`. Typed Rust and raw FFI calls classify a syntactically valid unknown prefix as `unsupported_containment`. | | `stale_id` | The `sandboxId` deserialised but refers to a resource the backend no longer recognises | -| `not_provisioned` | Phase requires a provisioned sandbox; none provided, or the id is in a pre-provision state | -| `not_started` | Phase requires a started sandbox; the id is provisioned but not started | -| `already_started` | `start` called on an already-running sandbox | -| `already_stopped` | `stop` called on an already-stopped sandbox | +| `not_provisioned` | Phase requires a provisioned container; none provided, or the id is in a pre-provision state | +| `not_started` | Phase requires a started container; the id is provisioned but not started | +| `already_started` | `start` called on an already-running container | +| `already_stopped` | `stop` called on an already-stopped container | | `policy_validation` | A request that passed the exact structural contract violates a backend semantic invariant or unsupported value combination | | `backend_error` | Catch-all for backend-specific failures; `details` carries structured information | @@ -1084,7 +1096,7 @@ pub trait StatefulSandboxBackend { const ID_PREFIX: &'static str; /// Wire-format `containment` value for this backend, matching the SDK's - /// `StateAwareContainmentBackend` member name (e.g. `"isolation_session"`). + /// `LifecycleContainmentKind` member name (e.g. `"isolation_session"`). /// Checked binding verifies this backend identity before typed dispatch. /// Distinct from `ID_PREFIX` — see §5 for the rationale. const BACKEND_KEY: &'static str; @@ -1255,7 +1267,7 @@ pub struct ExecHandle { pub stdin: PipeHandle, /// Function to wait for exit; returns how the exec finished. pub waiter: Box Result + Send>, - /// Function to terminate the process (called on AbortSignal). Fallible: a + /// Function to terminate the process through its owning handle. Fallible: a /// platform that refuses the request must be able to say so, because a /// caller that assumes a refused kill succeeded can block forever waiting /// on a process that is still running. @@ -1297,9 +1309,10 @@ descriptor on Linux. The executor's outer driver reads from `ExecHandle.stdout` `stderr`, awaits exit via `waiter`, and calls `terminator` to tear the exec down. It does **not** write to `stdin`. -`mint_random_token()` is a small helper in `mxc_common` that produces a short hex string -(mirroring the SDK's `randomBytes`-based id minting in `sandbox.ts`); it is used by the -default `provision` body to construct synthetic ids for stateless-underneath backends. +`mint_random_token()` is a small helper in `mxc_common` that produces a short +hex string (mirroring the SDK's `randomBytes`-based id minting in +`sdk/node/src/v1/container.ts`); it is used by the default `provision` body +to construct synthetic ids for stateless-underneath backends. Methods take `&mut self`, matching the existing `ScriptRunner::run` signature. Backends do not need to accumulate state between calls within a backend instance — within a @@ -1347,12 +1360,14 @@ that shape and reuses `ExecutionRequest` for five concrete reasons: same `&ExecutionRequest` argument; no new public Rust type closes a semantic gap that does not exist. -5. **No SDK or wire-format change is required.** The TypeScript `ProcessConfig`, - `FilesystemConfig`, `NetworkConfig`, and `UiConfig` interfaces in - `sdk/node/src/types.ts` are public consumer-facing types and remain unchanged. The - wire JSON shape is unchanged. The Rust trait reading `request.script_code`, - `request.policy.allowed_hosts`, etc. is an internal implementation choice - invisible above the Rust layer. +5. **No SDK or wire-format change is required for Rust request reuse.** The + TypeScript `ProcessConfig`, `FilesystemConfig`, `NetworkConfig`, and `UiConfig` + interfaces in `sdk/node/src/types.ts` remain wire-facing shapes. Of these, + only `FilesystemConfig` is exported by the V1 entrypoint. The V1 consumer + surface uses `ContainerRequest`, `ExecutionRequest`, and phase-specific + Configs. The wire JSON shape is unchanged. The Rust trait reading + `request.script_code`, `request.policy.allowed_hosts`, etc. is an internal + implementation choice invisible above the Rust layer. What would justify deviating from `ExecutionRequest` reuse — none of which apply to the v1 surface in this proposal: @@ -1498,7 +1513,7 @@ the backend actually implements. Dispatch-wiring mismatches are compile-time err not runtime registry checks. State-aware backends additionally register two consts on their trait impl alongside -their `ContainmentBackend` variant: `ID_PREFIX` (the sandbox-id tag, used by the +their `ContainmentBackend` variant: `ID_PREFIX` (the container-id tag, used by the dispatcher to resolve non-provision calls to the right backend) and `BACKEND_KEY` (the wire-format `containment` value, used for provision-phase routing and checked typed binding). Both are described @@ -1519,7 +1534,7 @@ shapes. | Layer | Validates | Failure surfaces as | |---|---|---| -| SDK (TypeScript) | Recognised `containment` (provision); branded `SandboxId` (other phases); required cross-backend fields (`process.commandLine` for exec); typed config shape (autocompletion + compile-time check) | Thrown at the call site, before any subprocess runs | +| SDK (TypeScript) | Recognised `containment` (provision); branded `ContainerId` (other phases); required `command` for exec; typed config shape (autocompletion + compile-time check) | Thrown at the call site, before any subprocess runs | | MXC parser (Rust) | Exact registered version and closed request root; required phase fields; phase-inappropriate, unknown, and recursively unknown fields | `error.code: malformed_request`, `unsupported_phase`, `unsupported_containment` | | MXC dispatch common (Rust) | Cross-backend per-phase invariants (e.g., `validate_exec_common` checks `process.commandLine` non-empty) | `error.code: malformed_request`, `policy_validation` | | Backend `validate_` hooks (Rust) | Per-backend per-phase invariants: config field values, cross-cutting policy honor (per the matrix in §10.3), id format checks beyond prefix matching | `error.code: policy_validation`, `malformed_id`, `stale_id`, `backend_error`, `backend_unavailable` | @@ -1605,7 +1620,7 @@ than silently dropped. An omitted `ui` is accepted and applies no restriction. For raw exact WindowsSandbox lifecycle requests, filesystem policy (readwrite/readonly/denied HOST paths) is -applied at provision and frozen for the life of the sandbox; later phases reject it. +applied at provision and frozen for the life of the container; later phases reject it. `network` and `ui` are not yet honored at any phase (network isolation is enforced unconditionally by the in-guest agent). @@ -1659,7 +1674,7 @@ The `StatefulSandboxBackend` trait signatures are in §9.2. Declare: - `const ID_PREFIX: &'static str` — the leading `:` segment for this backend's `sandbox_id` values; also used by the dispatcher for non-provision routing (§5). - `const BACKEND_KEY: &'static str` — the wire-format `containment` value for this - backend, matching the SDK's `StateAwareContainmentBackend` member name (e.g., + backend, matching the SDK's `LifecycleContainmentKind` member name (e.g., `"isolation_session"`). Used by checked binding to verify backend identity and to resolve `provision`-phase requests (§5). - Per-phase config associated types (`ProvisionConfig`, ..., `DeprovisionConfig`). @@ -1700,23 +1715,23 @@ interface MyBackendStartConfig { // ... and similarly for exec, stop, deprovision ``` -Add an arm to `ConfigsForBackend` mapping the new backend's `ContainmentBackend` -member to its five phase Configs: +Add an entry to the existing `LifecycleConfigRegistry` for the new backend's +five phase Configs. `ConfigsForBackend` indexes this registry; it does not +need a conditional arm: ```typescript -type ConfigsForBackend = - C extends 'isolation_session' ? { /* IS phase Configs */ } : - C extends 'my_backend' ? { - provision: MyBackendProvisionConfig; - start: MyBackendStartConfig; - exec: MyBackendExecConfig; - stop: MyBackendStopConfig; - deprovision: MyBackendDeprovisionConfig; - } : never; +type MyBackendPhaseConfigs = { + provision: MyBackendProvisionConfig; + start: MyBackendStartConfig; + exec: MyBackendExecConfig; + stop: MyBackendStopConfig; + deprovision: MyBackendDeprovisionConfig; +}; ``` -If the backend is absent from `ContainmentBackend`, add it there and to -`StateAwareContainmentBackend`. +Register `my_backend: MyBackendPhaseConfigs` inside `LifecycleConfigRegistry`. +If the backend is absent from `ContainmentBackend`, add it there and to the +`LifecycleContainmentKind` extracted union. ### 11.4 Register in the `ContainmentBackend` enum @@ -1760,7 +1775,7 @@ A per-backend document at `docs//.md` is required - **Idempotence behaviour per phase.** Whether double-stop returns success or `already_stopped`; whether double-provision creates a new resource or reuses one; what happens on deprovision-while-running. -- **Concurrency story.** Whether multiple `exec` calls against the same `sandboxId` +- **Concurrency story.** Whether multiple `exec` calls against the same container may run simultaneously, or are serialised by the backend's underlying API. - **Error mapping table.** Which native errors from the backend's underlying API map to which MXC error codes (§8). The catch-all `backend_error` is acceptable when no @@ -1786,21 +1801,21 @@ key docs is updated for any backend addition or significant change. ## 12. Failure semantics State-aware calls can fail at any phase. MXC does not impose a recovery mechanism; -recovery is the caller's responsibility. This section describes the typical sandbox +recovery is the caller's responsibility. This section describes the typical container state after each phase fails, along with common recovery patterns. -### 12.1 Post-failure sandbox state by phase +### 12.1 Post-failure container state by phase -| Phase failure | Sandbox state | Typical caller action | +| Phase failure | Container state | Typical caller action | |---|---|---| -| `provision` fails | No `sandboxId` was returned | Retry, or surface the failure | -| `start` fails | Sandbox is provisioned but not running | `deprovision` to clean up, or retry `start` | -| `exec` fails | Sandbox is running (the failure occurred during exec, not before) | Retry `exec`, or proceed to `stop` / `deprovision` | -| `stop` fails | Ambiguous: sandbox may be stopped, may still be running | Retry `stop`, or `deprovision` and accept potential resource leak from the backend's view | -| `deprovision` fails | Ambiguous: resource may still exist, may have been cleaned up | Treat as best-effort; the next call against the `sandboxId` will surface `stale_id` if the resource is gone | +| `provision` fails | No `ContainerId` was returned | Retry, or surface the failure | +| `start` fails | Container is provisioned but not running | `deprovision` to clean up, or retry `start` | +| `exec` fails | Container is running (the failure occurred during exec, not before) | Retry `exec`, or proceed to `stop` / `deprovision` | +| `stop` fails | Ambiguous: container may be stopped, may still be running | Retry `stop`, or `deprovision` and accept potential resource leak from the backend's view | +| `deprovision` fails | Ambiguous: resource may still exist, may have been cleaned up | Treat as best-effort; the next call against the `ContainerId` will surface `stale_id` if the resource is gone | The "ambiguous" entries are a consequence of MXC's stateless conduit model: MXC does not -track the sandbox's last-known state, so after a failure the caller and the backend may +track the container's last-known state, so after a failure the caller and the backend may disagree on what state the resource is in. A subsequent call resolves the ambiguity by surfacing either success or `stale_id`. @@ -1808,15 +1823,15 @@ surfacing either success or `stale_id`. If the SDK consumer's process dies while a state-aware call is in flight, the executor subprocess may still be running, and the backend's view of the resource depends on -whether the underlying API call completed before the process died. The sandbox state is +whether the underlying API call completed before the process died. The container state is indeterminate. -Recovery uses the persisted `sandboxId`: on consumer restart, an attempt to +Recovery uses the persisted `ContainerId`: on consumer restart, an attempt to `deprovision` either succeeds (cleanup completes) or returns `stale_id` (resource already gone). Either outcome leaves the caller in a known state. This pattern relies -on the consumer having persisted the `sandboxId` before the in-flight call began. +on the consumer having persisted the `ContainerId` before the in-flight call began. -If `provision` itself dies mid-call, the `sandboxId` never reached the caller. Any +If `provision` itself dies mid-call, the `ContainerId` never reached the caller. Any resource that was created is orphaned from the caller's perspective. Some backends offer auto-reap policies tied to caller-process lifetime that can clean up such orphans for ephemeral use; for state-aware use, where lifetimes are explicit and indefinite, an @@ -1830,13 +1845,12 @@ in MXC. Each backend's plan doc (§11.6) carries its specific recovery semantics ## 13. Graduation path -The state-aware API surface (the five lifecycle phases, the wire-format envelope, the -error envelope, the trait) is stable from `0.6.0` onwards — it is not gated by an -`--experimental` flag. The only graduation axis is per-backend: whether a given -backend's state-aware participation, per-stage config shapes, and error mappings are -stable enough to rely on. A backend whose state-aware participation is still -experimental requires `experimental: true` on every state-aware call, just as one-shot -calls against experimental backends do today. +The supported exact contracts begin at `0.9.0-alpha`. The typed V1 APIs select +published `1.0.0`; raw callers select a registered exact contract. The selected +contract determines which backends participate in state-aware operations and +what per-stage configs they accept. Raw/native execution of an experimental +backend requires its runtime authorization control. Typed V1 operations +target published backends and have operation-specific options. ### 13.1 Wire-format placement rule @@ -1861,9 +1875,9 @@ Both surfaces retain their permanent JSON locations. Runtime authorization is removed only from the graduated surface. **Backend's state-aware path graduates.** Per-stage config remains under -top-level `.`. The -`experimental: true` SDK option is not required for that backend's state-aware -calls, and the executor CLI accepts them without `--experimental`. For example, a +top-level `.`. The executor CLI accepts that backend's +state-aware calls without `--experimental`. +Typed SDK availability follows the published contract. For example, a `provision` call against IsolationSession uses this shape: ```json @@ -1887,9 +1901,8 @@ calls, and the executor CLI accepts them without `--experimental`. For example, Each backend's graduation event (ephemeral, state-aware, or both at once) triggers a schema version bump in `docs/development/architecture/versioning.md`, following the existing MXC convention for -graduating features. The version bump and the associated SDK type changes (such as -dropping `experimental: true` requirements for graduated containment values) ship as a -single release. +graduating features. The version bump and associated SDK containment choices and versioned +references ship together. ## 14. Out of scope for v1 @@ -1908,7 +1921,7 @@ path forward. - **Additional lifecycle stages** (snapshot, suspend, attach, restore). Backends with native support can expose them privately under their permanent backend section until universalisation. -- **Cross-machine `SandboxId` portability.** Ids are opaque, but their interpretation +- **Cross-machine `ContainerId` portability.** Ids are opaque, but their interpretation is backend-local in v1. A portable format with explicit scope tags is separate work. - **Container-wide timeouts enforced by MXC.** Tracking elapsed time across calls would require state. Backends impose their own timeout semantics through their diff --git a/docs/development/architecture/telemetry.md b/docs/development/architecture/telemetry.md index 187e814ef..919062d36 100644 --- a/docs/development/architecture/telemetry.md +++ b/docs/development/architecture/telemetry.md @@ -121,7 +121,7 @@ targets for this work. MXC identities, numeric status fields, and schema field paths only. `MXC.Verbose` is separately limited to the sanitized, typed inventory below. - Events do not contain command lines, environment values, complete file paths, - UPNs, tokens, sandbox output, raw ETL, actionable denial documents, general + UPNs, tokens, container output, raw ETL, actionable denial documents, general logger text, or free-form error text. - `MXC.PolicyHash` uses the same effective container identity as the runner (`CLI` when no container ID was supplied), then applies the standard identity @@ -163,9 +163,9 @@ result (with `mxc.exit_code` = 1 and `mxc.outcome` = `failure`). The state-aware lifecycle (`provision` / `start` / `exec` / `stop` / `deprovision`) is also instrumented: each dispatched phase emits one `MXC.Execution` tagged with `mxc.phase`. Non-`exec` phases and `exec` dry-runs -report success with `mxc.exit_code` = 0; a completed `exec` reports the sandbox +report success with `mxc.exit_code` = 0; a completed `exec` reports the contained process exit code; a dispatch error reports `failure` plus an `MXC.Error`. As in -the one-shot path, a clean non-zero sandbox exit is not treated as an MXC error. +the one-shot path, a clean non-zero contained process exit is not treated as an MXC error. | Field | Type | Description | |-------|------|-------------| @@ -205,7 +205,7 @@ versioned `*.verbose.json` sibling, validates it as a closed provider enum, drops every verbose property name and value and the schema name, sums the counts of signatures that become identical, and serializes that telemetry-specific projection as compact JSON. The event never -contains the actionable denials file, raw ETL, commands, sandbox output, or +contains the actionable denials file, raw ETL, commands, container output, or general logger text. One verbose document may require multiple ETW events. Every `mxc.content` @@ -242,7 +242,7 @@ MXC's optional diagnostic events contain: - MXC version and channel - Whether the build has debug assertions enabled (`IsDebugging`) -- Caller-requested sandbox kind and the concrete backend selected on the host +- Caller-requested containment kind and the concrete backend selected on the host - Run outcome, exit code, duration, bounded failure category, and lifecycle phase - `UTCReplace_AppSessionGuid`, which asks the telemetry pipeline to supply a @@ -255,7 +255,7 @@ MXC's optional diagnostic events contain: names and values are dropped. MXC does not emit commands, credentials, complete file paths, usernames, -workload-derived properties, sandbox output, raw ETL, actionable denial +workload-derived properties, container output, raw ETL, actionable denial documents, general logger text, or free-form error text. ### Privacy review status @@ -272,7 +272,7 @@ contract are documented in The state-aware lifecycle runs each phase (`provision` → `start` → `exec` → `stop` → `deprovision`) as a **separate `wxc-exec` process**. The `UTCReplace_AppSessionGuid` common field is therefore per-process and cannot join -events from different phases of the same sandbox. The stable join key is the +events from different phases of the same container. The stable join key is the **Microsoft Correlation Vector (MS-CV)**, emitted under TraceLogging's reserved `__TlgCV__` field. @@ -403,7 +403,7 @@ disclosure. Its title, body, action labels, and privacy link are documented in and must be rendered verbatim by every EXE and SDK presenter. The optional ETW events contain MXC version/channel, debug-build state, -caller-requested sandbox kind, selected backend, bounded outcomes and failure +caller-requested containment kind, selected backend, bounded outcomes and failure categories, numeric status values, lifecycle phase, policy fingerprints, and opaque/redacted identities. They do not contain commands, file paths, credentials, customer content, or free-form error text. @@ -505,11 +505,11 @@ collected by a fleet log agent. * **No config values, no filesystem paths, no command lines.** Config field *paths* (`process.commandLine`) are permitted — they are bounded and already public in the schema. Field *values* are not. -* **No raw user identities.** Identity-bearing sandbox records use a constant +* **No raw user identities.** Identity-bearing container records use a constant redaction marker instead of a user identifier. A truncated SHA-256 is not used: a low-entropy identity could be recovered by dictionary attack. The cost is - that these sandboxes have no MXC-side join key in the local log. -* **No caller-supplied identifiers verbatim.** A sandbox identity derived from + that these containers have no MXC-side join key in the local log. +* **No caller-supplied identifiers verbatim.** A container identity derived from configuration (the AppContainer profile name is the caller's `containerId`) is retained only when it matches one of the closed set of shapes MXC itself mints — the literal default `CLI`, `sandbox-<16 hex>`, or the state-aware @@ -524,7 +524,7 @@ collected by a fleet log agent. Fields are record-specific. Process-boundary records include `backend`, `identity`, `tier` (for `process_container`), and `pid`. Early records emitted -before a sandbox exists carry only the fields shown in the table below; in +before a container exists carry only the fields shown in the table below; in particular, `mxc.PolicyHash` has `backend`, `policy_hash`, and `config_schema_version`, `mxc.EnforcementDegraded` has `backend`, `identity`, and `tier`, and `mxc.ConfigRejected` has `correlation_id` and `backend` plus its @@ -532,7 +532,7 @@ rejection fields. `correlation_id` is a per-invocation opaque hex token, minted once per process and stable for its lifetime. It exists because a rejection is refused *before* a -sandbox identity is assigned, so it is the only key that groups several +container identity is assigned, so it is the only key that groups several rejection records from the same invocation. A successful launch emits no `mxc.ConfigRejected` at all. @@ -542,7 +542,7 @@ rejection records from the same invocation. A successful launch emits no | `mxc.SandboxIdentity` | After a successful state-aware phase | `backend`, `identity`, `phase` | | `mxc.EnforcementDegraded` | ProcessContainer dispatch resolved below the preferred tier | `backend`, `identity`, `tier`, `needs_dacl_augmentation`, `effective_enforcement_level`, `degradation_reasons`, `degradation_reason_count` | | `mxc.NetworkPolicyApplied` | AppContainer: after network setup, before process launch. BaseContainer: success after launch. Both tiers: failure when network setup fails | `backend`, `identity`, `tier` (no `pid` field), plus `enforcement_mode`, `default_policy`, `proxy_port`, `firewall_rules_created`, `firewall_applied`, `status` | -| `mxc.ProcessExited` | Sandboxed process exited on its own | `exit_code` | +| `mxc.ProcessExited` | Contained process exited on its own | `exit_code` | | `mxc.ProcessTimedOut` | `scriptTimeout` breached | `timeout_ms` | | `mxc.ProcessKillFailed` | A kill/terminate call failed (**failure only**) | `kill_method`, `error_code` | | `mxc.SandboxTornDown` | Per-run resources released, once per handle | ProcessContainer: `backend`, `identity`, `tier`, `pid`, `status`, `firewall_rules_removed`, `firewall_removal_ok`, `bfs_removed`, `proxy_stopped`, `preserve_policy`, `container_released`, `skip_reason`. IsolationSession: `backend`, `identity`, `phase`, `status`, `session_stopped`, `agent_user_deprovisioned`, `client_unregistered` | @@ -564,18 +564,18 @@ BaseContainer network record. Tier selection can *degrade* (proceed with weaker enforcement) or *fail* (refuse to run). Telling those apart is the whole point of the distinction -below — a reader who cannot separate "a sandbox ran with reduced isolation" +below — a reader who cannot separate "a container ran with reduced isolation" from "a benign race aborted the launch" cannot use this log for security decisions. **`FallbackError` (MXC-owned, `fallback_detector.rs`) is always security-relevant and always fail-closed.** It aborts tier selection; no -sandbox runs. It exists precisely so MXC never silently broadens access when +container runs. It exists precisely so MXC never silently broadens access when it cannot honour the requested policy: | Variant | Meaning | Why it is security-relevant | |---|---|---| -| `DaclFallbackDisabled` | The selected tier would have to mutate host DACLs, but the caller set `fallback.allowDaclMutation = false`. | The caller explicitly forbade host mutation. Running anyway would modify the host outside the sandbox contract. | +| `DaclFallbackDisabled` | The selected tier would have to mutate host DACLs, but the caller set `fallback.allowDaclMutation = false`. | The caller explicitly forbade host mutation. Running anyway would modify the host outside the containment contract. | | `WriteDacUnavailable` | `WRITE_DAC` is unavailable on a path needing ACE augmentation (or the path would not open). | The `deniedPaths` policy cannot be enforced. Proceeding would run with the deny silently absent. | | `SystemRootUnresolved` | `%SystemRoot%` could not be resolved. | MXC refuses to guess `C:\Windows`: an attacker who can scrub the environment could otherwise force a silent Tier 2 → Tier 3 downgrade. | @@ -724,7 +724,7 @@ that it was skipped. | Process outcome (M-ETW-1) | ✅ | ✅ | ✅ | ✅ (shared `create_process`) | | Enforcement degradation (M-ETW-2) | ✅ (shared dispatcher; records the tier actually selected) | ✅ | n/a — no tier/fallback ladder exists for this backend | n/a | | Policy hash (M-ETW-3) | ✅ | ✅ | ✅ | ✅ | -| Network policy (M-ETW-4) | ✅ (`enforcement_mode: capabilities` — policy travels in the sandbox spec and the OS enforces it, so `firewall_rules_created` is honestly `0`) | ✅ (`enforcement_mode: capabilities` for supported directional requests; egress default is reported as `allow` or `block`) | n/a — MXC rejects network and proxy policy for this backend before provisioning | n/a | +| Network policy (M-ETW-4) | ✅ (`enforcement_mode: capabilities` — policy travels in the container spec and the OS enforces it, so `firewall_rules_created` is honestly `0`) | ✅ (`enforcement_mode: capabilities` for supported directional requests; egress default is reported as `allow` or `block`) | n/a — MXC rejects network and proxy policy for this backend before provisioning | n/a | | Sandbox teardown (M-ETW-5) | ✅ | ✅ | ✅ | ✅ (`stop` and `deprovision` phases) | | IsolationSession telemetry (M-ETW-6) | n/a | n/a | ✅ Applicable lifecycle events use `Microsoft.MXC`; no separate OS provider is assumed | ✅ Same provider path | | Configuration rejection (M-ETW-7) | ✅ | ✅ | ✅ | ✅ (`phase` names the rejecting phase) | @@ -749,7 +749,7 @@ matrix. See [Platform scope](#platform-scope). | Requirement | Existing OS coverage | MXC local coverage | Join/correlation notes | |---|---|---|---| -| Process outcome (M-ETW-1) | Existing OS process-lifecycle records cover normal exit. The OS does not provide a verified timeout or kill-failure record for this requirement. | `mxc.ProcessTimedOut` and `mxc.ProcessKillFailed` cover the MXC boundary for both one-shot and state-aware paths. | Join the OS lifecycle identity to the MXC sandbox identity where available; use the process ID for process records. | +| Process outcome (M-ETW-1) | Existing OS process-lifecycle records cover normal exit. The OS does not provide a verified timeout or kill-failure record for this requirement. | `mxc.ProcessTimedOut` and `mxc.ProcessKillFailed` cover the MXC boundary for both one-shot and state-aware paths. | Join the OS lifecycle identity to the MXC container identity where available; use the process ID for process records. | | Enforcement degradation (M-ETW-2) | Not applicable to this backend: `isolation_session` has no MXC process-container tier/fallback model. | `mxc.EnforcementDegraded` covers process-container tier selection and includes `effective_enforcement_level`. | No isolation-session tier join is expected. | | Policy hash (M-ETW-3) | No policy hash field is emitted by the isolation-session OS provider. | `mxc.PolicyHash` records the effective MXC policy locally, excluding secrets and command content. | Correlate by the invocation/lifecycle context; the hash is an MXC record, not an OS field. | | Network policy (M-ETW-4) | Not applicable to `isolation_session`: MXC rejects its network and proxy policy before OS provisioning. | `mxc.NetworkPolicyApplied` covers process-container network setup on every tier, including the OS-enforced BaseContainer (`capabilities`) case. | This row changes only if the separate M1 network-proxy requirement is implemented. | diff --git a/docs/development/architecture/versioning.md b/docs/development/architecture/versioning.md index c86558c2c..e230c2595 100644 --- a/docs/development/architecture/versioning.md +++ b/docs/development/architecture/versioning.md @@ -103,7 +103,7 @@ A future breaking schema line adds a side-by-side V2 namespace rather than replacing V1. Version-independent APIs stay at the package root: errors and error codes, -running-sandbox handles and output/wait types, platform/backend discovery, +running-container handles and output/wait types, platform/backend discovery, telemetry consent, schema-version constants, raw exact-JSON APIs that take caller-declared versions, and executor-backed raw config APIs. @@ -198,12 +198,12 @@ commands and independent drift/history gates. Exact development requests adapt directly to a `StateAwareOperation` and cross-cutting `ExecutionRequest`. The operation determines its phase: provision retains a backend tag and optional runtime configuration, while -start, exec, stop, and deprovision carry their required sandbox ID. +start, exec, stop, and deprovision carry their required container ID. `ParsedStateAwareRequest` exposes read-only accessors, not independently writable phase, containment, or payload fields. Successful production requests retain neither raw backend JSON nor source text. -The engine resolves provision by containment and later phases by the sandbox +The engine resolves provision by containment and later phases by the container ID prefix. After the existing experimental and build-availability gates, its checked binding helpers produce `BoundStateAwareRequest` for both relayed lifecycle dispatch and streaming exec. An incompatible operation/backend pair @@ -222,7 +222,7 @@ normalization. Source-aware errors remain at exact structural deserialization. Exact fixtures cover structural acceptance and rejection, including `appId: null`. Recording backends cover binding, configuration delivery, validation order, dry-run behavior, and both exec topologies without requiring -live sandboxes. +live containers. Version-specific adapters convert registered JSON contract types into the private `CommonRequestIR` intermediate representation. Shared normalization in @@ -756,7 +756,7 @@ standard exact-contract diagnostic. 1. **Security of the experimental flag:** Should `--experimental` require additional privilege or be restricted to debug builds? A malicious caller could - pass `--experimental` to enable a feature that weakens the sandbox boundary. + pass `--experimental` to enable a feature that weakens the containment boundary. 2. **Conflicting experimental features:** If two experimental features have conflicting requirements (e.g., one denies a namespace, another relaxes it), diff --git a/docs/development/build-and-test/ci-validation-infrastructure.md b/docs/development/build-and-test/ci-validation-infrastructure.md index 9834ea6d6..5914d2507 100644 --- a/docs/development/build-and-test/ci-validation-infrastructure.md +++ b/docs/development/build-and-test/ci-validation-infrastructure.md @@ -306,7 +306,7 @@ Every one of the three scripts also takes the workload-interpreter inventory ### Workload interpreters Some suites do not just exercise MXC's primitives — they run *real programs* -inside the sandbox and assert on what those programs produce. Each preparation +inside the container and assert on what those programs produce. Each preparation script inventories those programs on the host up front, so a missing one is reported once, as a preparation result, rather than repeatedly as a confusing mid-suite failure. diff --git a/docs/development/build-and-test/pull-requests.md b/docs/development/build-and-test/pull-requests.md index 39bb5abee..591c8379b 100644 --- a/docs/development/build-and-test/pull-requests.md +++ b/docs/development/build-and-test/pull-requests.md @@ -19,7 +19,7 @@ From `sdk/node`, run `npm run typecheck` before pushing SDK changes. It builds the SDK and unit tests, packs the SDK, installs it into an isolated temporary integration project, and compiles every integration test. To repeat only the packed-package check after building the SDK, run `npm run typecheck:integration`. -Neither command runs sandbox workloads. +Neither command runs contained workloads. The integration jobs install a packed SDK rather than building `sdk/node/dist` in the checkout. Test-only private type imports must resolve from the installed diff --git a/docs/development/guides/authoring-a-new-feature.md b/docs/development/guides/authoring-a-new-feature.md index df7e91975..c7a508b37 100644 --- a/docs/development/guides/authoring-a-new-feature.md +++ b/docs/development/guides/authoring-a-new-feature.md @@ -18,8 +18,8 @@ Read these in order: -1. [Configuration schema](../../schema.md): supported policy fields and their - default behavior. +1. [Containment policy spec](../../containment-configuration/1.0.0/policy.md): the stable exact +request shape and supported policy fields. 2. [Versioning Design](../architecture/versioning.md): how policy/schema/SDK versions relate and when to bump. diff --git a/docs/development/guides/diagnostics.md b/docs/development/guides/diagnostics.md index 3994f26ba..4c4dfd314 100644 --- a/docs/development/guides/diagnostics.md +++ b/docs/development/guides/diagnostics.md @@ -7,8 +7,8 @@ A unified diagnostic view across every layer of the MXC stack: | Layer | Source | What you see | |-------|--------|--------------| | **SDK** | `mxc-sdk` (TypeScript) | SDK version, policy construction | -| **Runtime** | `wxc-exec.exe` (Rust) | Input config, parsed request, sandbox spec, process lifecycle, timing | -| **OS** | MXC OS-side ETW provider | Kernel-side sandbox creation and validation events | +| **Runtime** | `wxc-exec.exe` (Rust) | Input config, parsed request, container spec, process lifecycle, timing | +| **OS** | MXC OS-side ETW provider | Kernel-side container creation and validation events | | **Internals** | Kernel-General ETW (learning mode) | Access checks that would have been denied, logged instead of blocked | All layers stream into a single `mxc-diagnostic-console.exe` window in real time. @@ -60,7 +60,7 @@ it: - `mxc-diagnostic-console.exe` prints an error and **exits with code 1**. - `wxc-exec.exe` prints `[MXC Diagnostics] Refusing an unauthenticated diagnostic pipe` to stderr and continues running **without** pipe output. The - sandboxed workload still runs normally; only the diagnostics are lost. + contained workload still runs normally; only the diagnostics are lost. If the console starts but never shows any events, an unset or malformed token on the `wxc-exec` side is the first thing to check — the two processes must agree on @@ -238,13 +238,13 @@ From an elevated PowerShell window: > **Do not delete the marker first.** Removing it neither stops WPR nor proves > that the recording is safe to discard. It only removes MXC's durable warning > that host trace state could not be verified. This recovery procedure changes -> only host-side WPR bookkeeping; it does not change sandbox policy or +> only host-side WPR bookkeeping; it does not change containment policy or > enforcement. ## What Gets Logged - Input JSON config and parsed `ExecutionRequest` (env values redacted, script truncated) -- Sandbox spec details (size, UI flags, capabilities, filesystem/network policy) +- Container spec details (size, UI flags, capabilities, filesystem/network policy) - Process lifecycle (command line, identity, child PID, exit code, elapsed time) - Section markers for key execution stages - **Structured audit records** — one JSON object per line, prefixed `{"event":"mxc.` @@ -253,10 +253,10 @@ From an elevated PowerShell window: Alongside the human-readable prose above, both sinks carry machine-readable audit records: process exit / timeout / kill outcome, enforcement-tier -degradation, policy hash, network policy applied, sandbox teardown, config -rejection, and the sandbox identity join key. +degradation, policy hash, network policy applied, container teardown, config +rejection, and the container identity join key. -Records are joined by `identity` once a sandbox exists. A config rejection is +Records are joined by `identity` once a container exists. A config rejection is refused *before* an identity is assigned, so `mxc.ConfigRejected` instead carries a `correlation_id` — an opaque hex token minted once per `wxc-exec` invocation and stable for that invocation, which groups several rejection records from the diff --git a/docs/development/guides/process-container-adding-os-features.md b/docs/development/guides/process-container-adding-os-features.md index 9f7e3aa5a..5c3962be0 100644 --- a/docs/development/guides/process-container-adding-os-features.md +++ b/docs/development/guides/process-container-adding-os-features.md @@ -15,8 +15,8 @@ see [Windows OS-version policy support](../../backends/process-container/os-vers ## Prerequisites -1. Read the [supported configuration schema](../../schema.md) for policy and - request fields. +1. Read the [stable containment policy spec](../../containment-configuration/1.0.0/policy.md) + for the current exact request and policy fields. 2. Read [authoring-a-new-feature.md](authoring-a-new-feature.md), especially Step 1 (feature spec) and Step 2 (OS changes). 3. Submit a feature spec so reviewers understand the end-to-end flow. diff --git a/docs/development/plans/bubblewrap-backend.md b/docs/development/plans/bubblewrap-backend.md index 04566b209..1e9f67f8f 100644 --- a/docs/development/plans/bubblewrap-backend.md +++ b/docs/development/plans/bubblewrap-backend.md @@ -6,7 +6,7 @@ [Bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`) is a lightweight, unprivileged sandboxing tool for Linux. It uses Linux kernel namespaces (user, mount, PID, network, IPC, UTS) -to create sandboxed environments *without* requiring root privileges or a container runtime like +to create isolated container environments *without* requiring root privileges or a container runtime like LXC. It's the same technology backing Flatpak sandboxing. Key advantages over LXC for MXC: @@ -275,10 +275,10 @@ same iptables-based approach used by the LXC backend. specified, use `--unshare-net` for zero-overhead full isolation (no iptables needed). 2. When `allowedHosts` or `blockedHosts` are specified, **do not** use `--unshare-net` - (the sandbox shares the host network namespace). Instead: + (the container shares the host network namespace). Instead: - Discover the bwrap child PID - - Create a per-sandbox iptables chain via `NetworkIptablesManager` - - Apply allow/block rules scoped to the sandbox process using `--pid-owner` match + - Create a per-container iptables chain via `NetworkIptablesManager` + - Apply allow/block rules scoped to the contained process using `--pid-owner` match or cgroup-based scoping - Clean up rules after execution diff --git a/docs/development/plans/linux-wsl-roadmap-june-2026.md b/docs/development/plans/linux-wsl-roadmap-june-2026.md index 82f630561..bc166bfd2 100644 --- a/docs/development/plans/linux-wsl-roadmap-june-2026.md +++ b/docs/development/plans/linux-wsl-roadmap-june-2026.md @@ -91,7 +91,7 @@ File:line citations reference paths under `src/backends//...` and `src/ | # | Item | Status | Description | Effort | |---|---|---|---|---| | 13 | **(N1) Default-deny outbound** | 🟡 Actionable | Already in place: iptables FORWARD hook with default DROP when firewall mode + veth detected. New work: ensure hook is always applied; fail-fast if veth not found rather than silently skipping. | M | -| 14 | **(N2) Host-loopback control (`hostLoopback`)** | 🟠 Runtime API dependency | LXC rejects `hostLoopback: "allow"`. A private network namespace makes sandbox `127.0.0.1`/`::1` different from host loopback, so both directions need explicit cross-namespace plumbing. Container-to-host requires a host-loopback relay or translated gateway endpoint plus OUTPUT enforcement. Host-to-container requires a host-loopback-bound relay or DNAT/forward and an INPUT chain that allows `NEW` only for that forwarded path while dropping direct veth/LAN ingress. The shared allow/deny policy does not identify listener ports, so a separate runtime port-mapping contract is required; `hostLoopback: "allow"` authorizes mappings but cannot create them by itself. Until that contract and dual-stack `iptables`/`ip6tables` or `nftables` enforcement exist, reject `hostLoopback: "allow"` rather than guessing ports or exposing the container IP. `-i lo` remains intra-container only, and `ESTABLISHED,RELATED` remains allowed. Depends on the IPv6 path in item #19. | L | +| 14 | **(N2) Host-loopback control (`hostLoopback`)** | 🟠 Runtime API dependency | LXC rejects `hostLoopback: "allow"`. A private network namespace makes container `127.0.0.1`/`::1` different from host loopback, so both directions need explicit cross-namespace plumbing. Container-to-host requires a host-loopback relay or translated gateway endpoint plus OUTPUT enforcement. Host-to-container requires a host-loopback-bound relay or DNAT/forward and an INPUT chain that allows `NEW` only for that forwarded path while dropping direct veth/LAN ingress. The shared allow/deny policy does not identify listener ports, so a separate runtime port-mapping contract is required; `hostLoopback: "allow"` authorizes mappings but cannot create them by itself. Until that contract and dual-stack `iptables`/`ip6tables` or `nftables` enforcement exist, reject `hostLoopback: "allow"` rather than guessing ports or exposing the container IP. `-i lo` remains intra-container only, and `ESTABLISHED,RELATED` remains allowed. Depends on the IPv6 path in item #19. | L | > **Example (N2).** With `ingress.hostLoopback: "deny"` (default), the host cannot reach an MCP server in the container > and the container cannot reach a service on host loopback. With `"allow"`, both directions are authorized, but the @@ -196,7 +196,7 @@ File:line citations reference paths under `src/backends//...` and `src/ | # | Item | Status | Description | Effort | |---|---|---|---|---| -| 24 | **(N6) Per-sandbox scoping** | ✅ Addressed | Each Bwrap sandbox has its own network namespace (when `--unshare-net` is used) or process identity. No gap. | — | +| 24 | **(N6) Per-sandbox scoping** | ✅ Addressed | Each Bwrap container has its own network namespace (when `--unshare-net` is used) or process identity. No gap. | — | | 25 | **(N8) Delegation** | ⛔ Non-actionable | Same Linux platform limitation as LXC — no portable network access check at config time. | M | ### Misc @@ -515,9 +515,9 @@ These items depend on the WSLC SDK team and are not unilaterally schedulable. | 3 | **Registry-auth handshake** | Private registry auth | WSLC can only pull from public registries. SDK ABI reserves the `auth_info` slot but the implementation (Basic/Bearer/ACR/GHCR/ECR, token caching, custom-CA HTTPS) isn't shipped yet. | | 4 | **Deny-mount / path-exclusion primitive** | Filesystem #5 (`deniedPaths` enforcement) | LXC and Bubblewrap mask a `deniedPaths` entry that sits under a mounted parent by overlaying it (`/dev/null` or `tmpfs`). The WSLC SDK exposes only a flat volume-mount surface with no overlay/exclusion primitive, so a denied subtree under a mounted parent cannot be masked. MXC now **rejects** such configs at the WSLC runner preflight (Filesystem #5, [PR #650](https://github.com/microsoft/mxc/pull/650)) rather than silently leaving the path accessible; real *enforcement* (masking while the parent stays mounted) still needs an SDK exclusion primitive. (Note: this is the *basic subtree-deny* gap — spec-exact D5 "visible + ACCESS_DENIED" remains non-actionable on every Linux backend regardless, see Filesystem #12.) | -> **Why network enforcement must be container-scoped (host vs. VM vs. container).** Network policy can be enforced at three layers: the Windows **host** (Windows Firewall), the WSL2 **VM**, or the **container** network namespace inside the VM. GA decision **D6 (per-sandbox scoping)** requires every sandbox's policy to be independent — concurrent WSLC containers must not affect each other's access — and names the container network namespace as WSLC's scoping identity. A machine-wide **host** firewall can't attribute traffic to one container vs. another, so it violates D6 (and per **D8**, host firewalls apply *on top of* enforcement, never *as* it). A **VM-wide** rule fails the same way when one utility VM hosts multiple containers — sandbox A's rules would bleed into sandbox B. Only the **container namespace** is inherently per-sandbox, which is why it's the required enforcement point. The catch: MXC can't install rules into that namespace today (`Privileged` doesn't grant `CAP_NET_ADMIN`, and the VM may lack iptables tooling). Hence SDK dep #1 — a VM-level API that applies rules **scoped to a specific container's namespace**: physically enforced at the VM boundary, logically attributed to one container. +> **Why network enforcement must be container-scoped (host vs. VM vs. container).** Network policy can be enforced at three layers: the Windows **host** (Windows Firewall), the WSL2 **VM**, or the **container** network namespace inside the VM. GA decision **D6 (per-sandbox scoping)** requires every container's policy to be independent — concurrent WSLC containers must not affect each other's access — and names the container network namespace as WSLC's scoping identity. A machine-wide **host** firewall can't attribute traffic to one container vs. another, so it violates D6 (and per **D8**, host firewalls apply *on top of* enforcement, never *as* it). A **VM-wide** rule fails the same way when one utility VM hosts multiple containers — container A's rules would bleed into container B. Only the **container namespace** is inherently per-container, which is why it's the required enforcement point. The catch: MXC can't install rules into that namespace today (`Privileged` doesn't grant `CAP_NET_ADMIN`, and the VM may lack iptables tooling). Hence SDK dep #1 — a VM-level API that applies rules **scoped to a specific container's namespace**: physically enforced at the VM boundary, logically attributed to one container. > -> **Contrast with Hyperlight/Nanvix, and the state-aware wrinkle.** Hyperlight (network disabled for supported requests, per-instance) and Nanvix (all-deny or unrestricted networking, per-guest) keep their network posture scoped to each VM instance/process — no shared surface to bleed across. WSLC today is also effectively 1 sandbox : 1 VM (the one-shot flow creates a session, one container, then tears it down), but the highest-value WSLC optimization — **state-aware session reuse** (Misc #29), keeping a warm VM to amortize startup cost — makes one VM host **multiple** containers, at which point a host- or VM-wide rule genuinely bleeds across co-resident sandboxes. That is exactly when namespace-scoped enforcement (SDK dep #1) stops being merely cleaner and becomes mandatory. +> **Contrast with Hyperlight/Nanvix, and the state-aware wrinkle.** Hyperlight (network disabled for supported requests, per-instance) and Nanvix (all-deny or unrestricted networking, per-guest) keep their network posture scoped to each VM instance/process — no shared surface to bleed across. WSLC today is also effectively 1 container : 1 VM (the one-shot flow creates a session, one container, then tears it down), but the highest-value WSLC optimization — **state-aware session reuse** (Misc #29), keeping a warm VM to amortize startup cost — makes one VM host **multiple** containers, at which point a host- or VM-wide rule genuinely bleeds across co-resident containers. That is exactly when namespace-scoped enforcement (SDK dep #1) stops being merely cleaner and becomes mandatory. --- diff --git a/docs/development/plans/nanvix-integration.md b/docs/development/plans/nanvix-integration.md index ea17d2741..d908f767b 100644 --- a/docs/development/plans/nanvix-integration.md +++ b/docs/development/plans/nanvix-integration.md @@ -4,7 +4,7 @@ ## Problem -MXC (Microsoft eXecution Container) runs untrusted code in sandboxed environments. Today it supports multiple backends: **AppContainer** (process-level isolation), **Windows Sandbox** (full VM), **LXC** and **WSLC** (Linux containers via WSL). +MXC (Microsoft eXecution Container) runs untrusted code in containers. Today it supports multiple backends: **AppContainer** (process-level isolation), **Windows Sandbox** (full VM), **LXC** and **WSLC** (Linux containers via WSL). There is a need for a **micro-VM backend** that can execute various forms of code generated by agents — including Python, JavaScript, C, C++, and Rust applications — with full hardware isolation. @@ -89,7 +89,7 @@ Inside the NanVix VM: 1. **Staging-directory mount for script delivery** — NanVix's cmdline has a 255-byte limit and splits on spaces, so the script cannot be passed as an argument. Instead, the runner writes a `bootstrap.py` (user script + small loader preamble) into a per-invocation temp directory and bind-mounts that directory into the guest via `nanvixd -mount `. Python then executes `bootstrap.py` from the mount. Host stdin is closed (`Stdio::null()`) — no stdin relay is involved. Zero changes to CPython or NanVix. -2. **Raw Python source in `process.commandLine` field** — For AppContainer/Sandbox, `process.commandLine` is a shell command. For the microvm backend, it's raw Python source code. The runner handles interpreter invocation internally. This avoids users needing to understand NanVix's cmdline constraints. +2. **Raw Python source in `process.commandLine` field** — For AppContainer/Windows Sandbox, `process.commandLine` is a shell command. For the microvm backend, it's raw Python source code. The runner handles interpreter invocation internally. This avoids users needing to understand NanVix's cmdline constraints. 3. **Pre-built artifacts, not built from source** — NanVix binaries (`nanvixd.exe`, `kernel.elf`, `python3.initrd`, `nanvix_rootfs.img`) are downloaded from GitHub pre-releases. MXC does not compile or build NanVix components. diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 3173c4958..db56ccc35 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -2,7 +2,7 @@ > **Audience:** MXC consumers -MXC sandboxes are **deny-by-default**: when a workload touches a file, registry +MXC containers are **deny-by-default**: when a workload touches a file, registry key, or other resource the policy does not grant, the access is blocked and the OS returns the usual "Access is denied" error. For non-trivial workloads this is operationally fragile — the author must enumerate every path the workload will @@ -117,7 +117,7 @@ stays enforced: ``` 2. **App / user-configurable (`captureDenials` block / `learningModeLogging`).** - An app wants to let its users "configure" their own sandbox. Each user + An app wants to let its users "configure" their own container. Each user workflow differs, so the app records what was blocked, presents it through its own UX, and re-generates the config with the new paths/capabilities. Deny-by-default stays enforced — the workload behaves exactly as it would in @@ -174,9 +174,9 @@ ungranted access is handled while it is recorded: ### Output file the caller consumes -After the sandboxed workload exits, MXC decodes the captured denials and writes +After the contained workload exits, MXC decodes the captured denials and writes the policy JSON deliverable a host application reads to regenerate its -sandbox policy: +containment policy: ```json { @@ -384,7 +384,7 @@ succeeds. WPR's source ETL is host-wide, so the elevated guarded-WPR helper never transfers that file across the privilege boundary for `captureDenials` or -`--audit`. After the sandbox process tree terminates, the helper uses the +`--audit`. After the contained process tree terminates, the helper uses the retained, job-attested process handles and their exact PID/creation/exit `FILETIME` ranges to relog a second ETL. The retained ETL contains only supported Learning Mode events whose event header diff --git a/docs/schema.md b/docs/schema.md index 0526af50c..1d0555eb3 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -8,6 +8,9 @@ MXC uses a JSON configuration file. The current stable schema is at For development, the exact schema at [`schemas/dev/mxc-config.schema.1.1.0-alpha.json`](../schemas/dev/mxc-config.schema.1.1.0-alpha.json) includes experimental features and may change without notice. +See [containment policy by schema version](containment-configuration/README.md) for the +supported `0.9.0-alpha`, stable `1.0.0`, and development `1.1.0-alpha` +contracts and their differences. Editors that support JSON Schema will provide autocomplete and validation when you add a `"$schema"` reference to your config file. Use the stable schema for @@ -259,17 +262,17 @@ that can be executed independently. `process.cwd` is optional. When it is set, it is passed to the backend verbatim — an unusable value fails the launch rather than being silently replaced. When it is **omitted**, backends do not simply inherit the launcher's -working directory: under a deny-by-default sandbox that directory is usually +working directory: under deny-by-default containment that directory is usually unreadable, and the result ranges from a confusing silent relocation (Windows restarts the child at the drive root) to `getcwd()` errors on the child's -stderr. Each backend therefore substitutes a directory the sandbox can actually +stderr. Each backend therefore substitutes a directory the container can actually use: | Backend | Default when `process.cwd` is omitted | |---------|----------------------------------------| | Windows ProcessContainer (AppContainer / BaseContainer) | First `readwritePaths` entry that is an existing directory, else the first such `readonlyPaths` entry, else the system drive root (`%SystemDrive%\`). Never `NULL`. | | Seatbelt (macOS) | Same precedence, with `~` expanded as the profile expands it; falls back to `/`. | -| Bubblewrap (Linux) | No substitution — a policy grant is never adopted. `--chdir` is emitted only for an explicit `process.cwd`, which from 0.9 is also normalized against the sandbox root and used as `HOME`. With no explicit `cwd` there is no `--chdir` and `HOME` is unset — see [`docs/backends/bwrap/bubblewrap-backend.md`](backends/bwrap/bubblewrap-backend.md). | +| Bubblewrap (Linux) | No substitution — a policy grant is never adopted. `--chdir` is emitted only for an explicit `process.cwd`, which from 0.9 is also normalized against the container root and used as `HOME`. With no explicit `cwd` there is no `--chdir` and `HOME` is unset — see [`docs/backends/bwrap/bubblewrap-backend.md`](backends/bwrap/bubblewrap-backend.md). | | LXC / WSL Container | The container root — see [`docs/backends/lxc/lxc-backend.md`](backends/lxc/lxc-backend.md). | | MicroVM (NanVix) / Hyperlight | Not applicable — these backends reject a working directory outright. | @@ -337,7 +340,7 @@ containment tier selected at runtime: - **AppContainer (Tier 2/3):** enforced by host-filesystem DENY ACEs, applied before the run and removed on exit. This path is gated by `allowDaclMutation`, requires `WRITE_DAC` on each denied path, and temporarily modifies host security descriptors. - Because the ACEs are keyed on the sandbox's derived AppContainer SID, two concurrent + Because the ACEs are keyed on the container's derived AppContainer SID, two concurrent runs sharing the same `containerId` can revoke each other's ACEs — use distinct `containerId` values for parallel runs. @@ -382,7 +385,7 @@ whether it applies, rejects, or ignores the section. **IsolationSession and WSLc refuse any supplied `ui` at every phase on both surfaces**, and each accepts an omitted one without applying any UI restriction — so the section's default-deny reading does not hold on either. The reasons differ: no `ui` posture is truthful -for a session-isolated sandbox (see +for a session-isolated container (see [IsolationSession state-aware Rust architecture](development/architecture/backends/isolation-session/state-aware-rust.md)), while WSLc has no mechanism to enforce UI restrictions on a container (see [`backends/wslc/wslc-state-aware.md`](backends/wslc/wslc-state-aware.md)). @@ -419,7 +422,7 @@ force a particular backend. | Value | Description | |-------|-------------| | `"processcontainer"` | (Default) Windows process-level isolation. Resolves to AppContainer (legacy) or BaseContainer (newer OS sandbox API) at run time depending on host capabilities and the `--experimental` flag. | -| `"windows_sandbox"` | Windows Sandbox VM isolation. Dual-mode: a transient **one-shot** runner that launches a fresh disposable VM per execution, and a **state-aware** lifecycle backed by a long-lived per-sandbox daemon. | +| `"windows_sandbox"` | Windows Sandbox VM isolation. Dual-mode: a transient **one-shot** runner that launches a fresh disposable VM per execution, and a **state-aware** lifecycle backed by a long-lived per-container daemon. | | `"wslc"` | Linux containers via the WSL Container SDK | | `"lxc"` | Native LXC container isolation. No abstract intent resolves to LXC; request it explicitly. | | `"microvm"` | MicroVM isolation via Windows HyperV Platform (NanVix microkernel) | @@ -438,7 +441,7 @@ The exact development schema documents a multi-phase envelope shape for the state-aware lifecycle (`provision` / `start` / `exec` / `stop` / `deprovision`). Where the one-shot config above is a self-contained `ExecutionRequest` to run once, a state-aware envelope identifies which -phase is being driven against an existing provisioned sandbox. +phase is being driven against an existing provisioned container. State-aware envelopes use an exact backend-specific contract: diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index b78535c95..c2fc9048d 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -106,7 +106,7 @@ resizing support. Initial dimensions default to 24 rows by 80 columns. PTY stderr is merged into `Output`. Closing `Input` requests terminal EOF in canonical mode; raw-mode applications must use their own completion protocol. Seatbelt rejects PTY mode with `guiAccess` or legacy `launchMethod: "open"`. -Unsupported combinations are rejected before sandbox creation. +Unsupported combinations are rejected before container creation. ## Lifecycle API @@ -216,7 +216,7 @@ Backend/platform discovery, errors, telemetry, and helpers are also in | Terminal process outcome | `WaitResult` | All types above are in `Microsoft.Mxc.Sdk.V1`. See the -[networking guide](https://github.com/microsoft/mxc/blob/main/docs/schema.md#directional-networking-supported-contracts); +[networking guide](https://github.com/microsoft/mxc/blob/main/docs/schema.md#directional-networking-supported-contracts) for policy behavior and the [SDK API reference](https://github.com/microsoft/mxc/blob/main/docs/api-reference/README.md) for complete signatures and types. @@ -233,7 +233,7 @@ IsolationSession provision metadata is present, `AgentUserName`, `AgentUserSid`, and `EphemeralWorkspacePath` are required non-null strings. `MxcPlatform.GetPlatformSupport()` reports whether the SDK can launch a -sandbox on the current host. `MxcPlatform.GetAvailableBackends()` reports +container on the current host. `MxcPlatform.GetAvailableBackends()` reports host backend capabilities; availability is advisory and launch-time validation still applies. diff --git a/sdk/node/README.md b/sdk/node/README.md index b757b6591..806ff1a96 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -105,7 +105,7 @@ and resizing support. Initial dimensions default to 24 rows by 80 columns. Terminal stderr is merged into `output`. Closing `input` requests terminal EOF when supported; raw-mode applications must use their own completion protocol. Seatbelt rejects PTY mode with `guiAccess` or legacy `launchMethod: "open"`. -Unsupported combinations are rejected before sandbox creation. +Unsupported combinations are rejected before container creation. ## Lifecycle API diff --git a/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs b/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs index ccd40d089..882e2eb55 100644 --- a/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs +++ b/src/mxc-sdk/src/bin/wslc_daemon/control_server.rs @@ -573,14 +573,14 @@ where /// Exec: validate-then-admit, then stream the run's stdout/stderr live as /// [`StreamFrame`]s, followed by a terminal frame. /// -/// The sandbox is validated (exists + started) *before* the `Ok` admission is +/// The container is validated (exists + started) *before* the `Ok` admission is /// written, and — critically — admission is **atomic** with the claim the /// worker takes on the container (see [`SessionHandle::exec`]): the worker /// validates, claims the container and hands the run to a thread of its own /// without yielding. A later `Stop`/`Deprovision` naming that container parks /// behind the claim and a later `Exec` is refused with `Busy`, so neither can /// invalidate the checked state. An unknown, not-started or already-busy -/// sandbox therefore comes back as a pre-admission typed +/// container therefore comes back as a pre-admission typed /// [`DaemonResponse::Err`] rather than a post-admission stream `Error` frame. /// /// The exec permit is shared with the run, so capacity frees only once the run diff --git a/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs b/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs index 6fc36d026..b52c26e47 100644 --- a/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs +++ b/src/mxc-sdk/src/bin/wslc_daemon/session_manager.rs @@ -529,7 +529,7 @@ impl SessionHandle { /// Admit and run a command in a started container. Awaits the worker's /// **admission** decision first: on rejection (unknown / not-started / - /// already-busy sandbox) this returns the typed error *before* the caller + /// already-busy container) this returns the typed error *before* the caller /// writes any admission to the client. On admission it returns an /// [`ExecStream`] — the completion receiver (the run's exit code) plus the /// live-output receiver, which the caller drains into `Stdout`/`Stderr` @@ -667,7 +667,7 @@ struct ContainerEntry { retired: bool, container: WslcContainerGuard, - /// Keeps a quarantined sandbox counted against exec capacity, because a run + /// Keeps a quarantined container counted against exec capacity, because a run /// whose termination was never confirmed may still hold a live process. exec_slot: Option, } @@ -684,14 +684,14 @@ struct PendingProvision { /// A command addressed to one container, either about to run or parked behind /// an exec that is still using that container's handle. pub(crate) enum ContainerWork { - /// Validate the sandbox (exists + started) and, if admitted, hand the run to + /// Validate the container (exists + started) and, if admitted, hand the run to /// a thread of its own. The two replies make admission **atomic** with the /// claim on the container: the worker validates, claims the container's /// in-flight slot, answers `admit` and starts the run thread without /// yielding. A later lifecycle command naming that container parks behind /// the claim and a later `Exec` is refused with `Busy`, so none can /// interleave with the run. `admit` carries the pre-run decision (so an - /// unknown, not-started or already-busy sandbox is a pre-admission typed + /// unknown, not-started or already-busy container is a pre-admission typed /// error, never a post-admission stream `Error`); `done` carries the run's /// exit code once [`WorkerCommand::ExecFinished`] lands. Exec(ExecRequest), @@ -2619,7 +2619,7 @@ mod tests { assert_eq!( limiter.available_permits(), 0, - "a quarantined sandbox whose process may still be running must keep its slot" + "a quarantined container whose process may still be running must keep its slot" ); } @@ -2650,7 +2650,7 @@ mod tests { assert_eq!( limiter.available_permits(), 1, - "deprovisioning the quarantined sandbox must return its slot" + "deprovisioning the quarantined container must return its slot" ); } @@ -2991,7 +2991,7 @@ mod tests { )); } - /// Provision and start a sandbox on the live host, returning its id. + /// Provision and start a container on the live host, returning its id. async fn provisioned_and_started(handle: &SessionHandle) -> String { let id = handle .provision(ProvisionConfig { @@ -3278,7 +3278,7 @@ mod tests { handle.shutdown().await.unwrap(); } - /// Two sandboxes must run at the same time rather than one after the other. + /// Two containers must run at the same time rather than one after the other. #[tokio::test] #[ignore = "requires a WSL2 host with alpine:latest already in the daemon session cache"] async fn execs_on_two_sandboxes_overlap() { @@ -3295,7 +3295,7 @@ mod tests { .await .unwrap(); - // Both sandboxes share one utility VM, so their clocks agree. + // Both containers share one utility VM, so their clocks agree. let (first_start, first_end) = stamped_interval(&mut first).await; let (second_start, second_end) = stamped_interval(&mut second).await; assert_eq!(first.done.await.unwrap().unwrap(), ExecTerminal::Exited(0)); diff --git a/src/mxc-sdk/tests/wslc_daemon_ipc.rs b/src/mxc-sdk/tests/wslc_daemon_ipc.rs index e85c2bdad..0b54ad497 100644 --- a/src/mxc-sdk/tests/wslc_daemon_ipc.rs +++ b/src/mxc-sdk/tests/wslc_daemon_ipc.rs @@ -83,7 +83,7 @@ impl Drop for DaemonProcess { } } -/// Provision and start a sandbox over the pipe, returning its id. +/// Provision and start a container over the pipe, returning its id. fn provisioned_and_started(client: &DaemonClient) -> String { use mxc_sdk::wslc_common::daemon_protocol::{ProvisionConfig, StartConfig}; @@ -139,7 +139,7 @@ fn deprovision_all(client: &DaemonClient, sandbox_ids: Vec) { } } -/// Two clients running against their own sandboxes share the daemon: both are +/// Two clients running against their own containers share the daemon: both are /// admitted, each sees only its own output, and the runs overlap in the guest. #[test] #[ignore = "requires a WSL2 host with alpine:latest already in the daemon session cache"] @@ -190,7 +190,7 @@ fn two_clients_exec_concurrently_over_the_pipe() { "each client must receive only its own stream" ); - // Both sandboxes share one utility VM, so their clocks agree. + // Both containers share one utility VM, so their clocks agree. let (alpha_start, alpha_end) = stamped_interval(&results[0].1); let (beta_start, beta_end) = stamped_interval(&results[1].1); let overlap = alpha_end.min(beta_end) - alpha_start.max(beta_start); diff --git a/tests/scripts/lib/LoopbackAnchor.ps1 b/tests/scripts/lib/LoopbackAnchor.ps1 index 07e2dfec2..897fc5d39 100644 --- a/tests/scripts/lib/LoopbackAnchor.ps1 +++ b/tests/scripts/lib/LoopbackAnchor.ps1 @@ -10,7 +10,7 @@ # posture rather than the runner's outbound internet access. # Binds an ephemeral loopback port and serves a fixed body from a background -# runspace, so the caller can stay blocked on a sandbox exec while the +# runspace, so the caller can stay blocked on a container exec while the # contained process connects back. Returns $null if the port cannot be bound. function Start-LoopbackAnchor { # Bind port 0 and read back what the OS assigned, rather than reserving a @@ -63,7 +63,7 @@ function Stop-LoopbackAnchor { & $Anchor.Stop } -# Command line that fetches the anchor from inside a sandbox. +# Command line that fetches the anchor from inside an IsolationSession container. # # Deliberately a bare curl rather than a `&& echo REACHED || echo BLOCKED` # token pair: the one-shot runner invokes wxc-exec with --debug, which echoes diff --git a/tests/scripts/run_isolation_session_state_aware_tests.ps1 b/tests/scripts/run_isolation_session_state_aware_tests.ps1 index 6e9b41f71..b48049cad 100644 --- a/tests/scripts/run_isolation_session_state_aware_tests.ps1 +++ b/tests/scripts/run_isolation_session_state_aware_tests.ps1 @@ -1328,14 +1328,14 @@ try { } # Test 11b: stale_id breadth. Every non-provision phase resolves the agent - # user from the sandbox id, so a deprovisioned id must read as stale on all + # user from the container id, so a deprovisioned id must read as stale on all # of them -- not just the `stop` asserted above. `operation` is checked for # shape rather than an exact value here: which API call first reports # ERROR_NOT_FOUND depends on the OS-side capability set, and test 11 already # pins one exact constant. `deprovision` is covered separately below. if ($deprovisionedOk) { foreach ($phase in @('start', 'exec')) { - Run-StateAwareTest "stale_id ($phase on previously-deprovisioned sandbox)" { + Run-StateAwareTest "stale_id ($phase on previously-deprovisioned container)" { $req = @{ phase = $phase sandboxId = $script:sandboxId @@ -1344,7 +1344,7 @@ try { $req.process = @{ commandLine = 'cmd /c echo stale_should_not_run'; timeout = 30000 } } $r = Invoke-StateAware -Request $req - Assert-True ($r.ExitCode -ne 0) "exit code is non-zero ($phase on a stale sandbox failed as expected)" + Assert-True ($r.ExitCode -ne 0) "exit code is non-zero ($phase on a stale container failed as expected)" $envObj = Parse-Envelope -Stdout $r.Stdout if ($null -eq $envObj) { $envObj = Parse-StderrEnvelope -Stderr $r.Stderr } Assert-True ($null -ne $envObj) "the failure is a parseable envelope" @@ -1357,18 +1357,18 @@ try { Assert-True ($nativeCode -eq '0x80070490') ` "error.nativeCode is '0x80070490' (got '$nativeCode')" Assert-True (-not ($r.Stdout -match 'stale_should_not_run')) ` - "no workload ran against the stale sandbox" + "no workload ran against the stale container" } | Out-Null } } - # Test 11c: `deprovision` against a deprovisioned sandbox. Documented to + # Test 11c: `deprovision` against a deprovisioned container. Documented to # report stale_id like the phases above, but RemoveUser reports success for # an agent user that is already gone, so the runner never sees an # ERROR_NOT_FOUND to promote. Recorded without failing the suite while # #1429 is open. if ($deprovisionedOk) { - Run-StateAwareTest "stale_id (deprovision on previously-deprovisioned sandbox)" { + Run-StateAwareTest "stale_id (deprovision on previously-deprovisioned container)" { $r = Invoke-StateAware -Request @{ phase = 'deprovision'; sandboxId = $script:sandboxId } $envObj = Parse-Envelope -Stdout $r.Stdout if ($null -eq $envObj) { $envObj = Parse-StderrEnvelope -Stderr $r.Stderr } @@ -1828,11 +1828,11 @@ try { # - the mandated all-allow network posture really carries traffic, # - exec output reaches the caller while the command is still running, # - `process.timeout` ends a long command and leaves the session usable, -# - a caller killed mid-exec does not take the sandbox with it, -# - repeating a lifecycle call never corrupts the sandbox, +# - a caller killed mid-exec does not take the container with it, +# - repeating a lifecycle call never corrupts the container, # - the agent account named in the provision metadata is created and removed. -# Provision a sandbox and capture the full provision metadata. +# Provision a container and capture the full provision metadata. function Provision-LifecycleGSandbox { $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_provision.json' $envObj = Parse-Envelope -Stdout $r.Stdout @@ -1940,7 +1940,7 @@ try { # G4: process.timeout. Only the one-shot path had a timeout test; the # state-aware exec deadline was unexercised, as was the question of - # whether a timed-out exec consumes the sandbox. + # whether a timed-out exec consumes the container. Run-StateAwareTest "Lifecycle G: exec honours process.timeout and leaves the session usable" { $req = @{ phase = 'exec' @@ -1964,13 +1964,13 @@ try { Assert-True ($elapsed -lt 45) "the deadline ended the run early (took $([int]$elapsed)s)" $after = Exec-InSession -SandboxId $script:gSandbox.SandboxId -CommandLine 'cmd /c echo after_timeout_marker' - Assert-True ($after.ExitCode -eq 0) "a later exec against the same sandbox exits 0" - Assert-True ($after.Stdout -match 'after_timeout_marker') "the sandbox survives a timed-out exec" + Assert-True ($after.ExitCode -eq 0) "a later exec against the same container exits 0" + Assert-True ($after.Stdout -match 'after_timeout_marker') "the container survives a timed-out exec" } | Out-Null - # G5: recovery. The sandbox outlives the process that created it, so a + # G5: recovery. The container outlives the process that created it, so a # caller dying mid-exec must not strand or tear down the session. - Run-StateAwareTest "Lifecycle G: a caller killed mid-exec leaves the sandbox usable" { + Run-StateAwareTest "Lifecycle G: a caller killed mid-exec leaves the container usable" { $req = @{ phase = 'exec' sandboxId = $script:gSandbox.SandboxId @@ -1989,14 +1989,14 @@ try { Assert-True (-not ($r.Stdout -match 'crash_probe_finished')) "the killed exec did not run to completion" $after = Exec-InSession -SandboxId $script:gSandbox.SandboxId -CommandLine 'cmd /c echo recovered_marker' - Assert-True ($after.ExitCode -eq 0) "a later exec against the same sandbox exits 0" - Assert-True ($after.Stdout -match 'recovered_marker') "the sandbox is still usable after its caller died" + Assert-True ($after.ExitCode -eq 0) "a later exec against the same container exits 0" + Assert-True ($after.Stdout -match 'recovered_marker') "the container is still usable after its caller died" } | Out-Null # G6: repeated start. MXC forwards start/stop straight to the OS service # and does not normalise the repeat outcome, so the assertable contract # is that repeating the call is *safe*: it answers in a well-formed way - # and the sandbox still works afterwards. A hang, a crash, unparseable + # and the container still works afterwards. A hang, a crash, unparseable # output, or a bricked session all fail this. Run-StateAwareTest "Lifecycle G: repeating start is safe" { $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_start.json' -SandboxId $script:gSandbox.SandboxId @@ -2009,8 +2009,8 @@ try { Write-Host " repeat start refused with '$(if ($envObj) { $envObj.error.code } else { '' })'" -ForegroundColor DarkGray } $after = Exec-InSession -SandboxId $script:gSandbox.SandboxId -CommandLine 'cmd /c echo after_repeat_start_marker' - Assert-True ($after.ExitCode -eq 0) "the sandbox still execs after a repeated start" - Assert-True ($after.Stdout -match 'after_repeat_start_marker') "the repeated start did not corrupt the sandbox" + Assert-True ($after.ExitCode -eq 0) "the container still execs after a repeated start" + Assert-True ($after.Stdout -match 'after_repeat_start_marker') "the repeated start did not corrupt the container" } | Out-Null } @@ -2021,7 +2021,7 @@ try { } # G7: repeated stop. Same contract as the repeated start: whatever the - # OS reports, the sandbox must remain deprovisionable (asserted by G8 + # OS reports, the container must remain deprovisionable (asserted by G8 # running immediately after this). if ($gStoppedOk) { Run-StateAwareTest "Lifecycle G: repeating stop is safe" { @@ -2033,7 +2033,7 @@ try { if ($null -eq $envObj) { $envObj = Parse-StderrEnvelope -Stderr $r.Stderr } Assert-True ((Envelope-Arm $envObj) -eq 'error') "a refused repeat stop is a well-formed error envelope" $code = if ($envObj) { [string]$envObj.error.code } else { '' } - Assert-True ($code -ne 'stale_id') "a stopped-but-provisioned sandbox is not reported as stale (got '$code')" + Assert-True ($code -ne 'stale_id') "a stopped-but-provisioned container is not reported as stale (got '$code')" } } | Out-Null } @@ -2055,7 +2055,7 @@ try { } if ($gDeprovPassed) { $gDeprov = $true - Run-StateAwareTest "Lifecycle G: repeating deprovision reports a stale sandbox" { + Run-StateAwareTest "Lifecycle G: repeating deprovision reports a stale container" { $r = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_deprovision.json' -SandboxId $script:gSandbox.SandboxId $envObj = Parse-Envelope -Stdout $r.Stdout if ($null -eq $envObj) { $envObj = Parse-StderrEnvelope -Stderr $r.Stderr } @@ -2069,7 +2069,7 @@ try { Stop-LoopbackAnchor -Anchor $script:gAnchor if ($null -ne $script:gSandbox -and -not $gDeprov) { Write-Host "" - Write-Host "[cleanup] best-effort deprovision of Lifecycle G sandbox ($($script:gSandbox.SandboxId))" -ForegroundColor DarkGray + Write-Host "[cleanup] best-effort deprovision of Lifecycle G container ($($script:gSandbox.SandboxId))" -ForegroundColor DarkGray try { $null = Invoke-StateAware -ConfigFile 'isolation_session_state_aware_deprovision.json' -SandboxId $script:gSandbox.SandboxId } catch { } diff --git a/tests/scripts/run_wslc_state_aware_tests.ps1 b/tests/scripts/run_wslc_state_aware_tests.ps1 index c2c7e03b2..f84bad044 100644 --- a/tests/scripts/run_wslc_state_aware_tests.ps1 +++ b/tests/scripts/run_wslc_state_aware_tests.ps1 @@ -1679,7 +1679,7 @@ try { # ---------------- Lifecycle J: exec concurrency ---------------- # Placed ahead of H because H drives the daemon to exit. Each scenario needs two -# phases in flight at once -- overlapping runs on separate sandboxes, a refused +# phases in flight at once -- overlapping runs on separate containers, a refused # same-container second exec, and a lifecycle command issued mid-run -- so all # three use Start-StateAware rather than the sequential Invoke-StateAware. $script:ccSandboxA = $null @@ -1689,33 +1689,33 @@ $ccBDeprovisionedOk = $false try { $ccAReady = $false $ccBReady = $false - $ccAProvOk = Run-StateAwareTest "J: provision sandbox A" { + $ccAProvOk = Run-StateAwareTest "J: provision container A" { $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision.json' $envObj = Assert-ResultEnvelope $r "concurrency A provision" if ($envObj) { $script:ccSandboxA = [string]$envObj.result.sandboxId } } - $ccBProvOk = Run-StateAwareTest "J: provision sandbox B" { + $ccBProvOk = Run-StateAwareTest "J: provision container B" { $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_provision.json' $envObj = Assert-ResultEnvelope $r "concurrency B provision" if ($envObj) { $script:ccSandboxB = [string]$envObj.result.sandboxId } } if ($ccAProvOk) { - $ccAReady = Run-StateAwareTest "J: start sandbox A" { + $ccAReady = Run-StateAwareTest "J: start container A" { $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $script:ccSandboxA $null = Assert-ResultEnvelope $r "concurrency A start" } } if ($ccBProvOk) { - $ccBReady = Run-StateAwareTest "J: start sandbox B" { + $ccBReady = Run-StateAwareTest "J: start container B" { $r = Invoke-StateAware -ConfigFile 'wslc_state_aware_start.json' -SandboxId $script:ccSandboxB $null = Assert-ResultEnvelope $r "concurrency B start" } } - # J1: both sandboxes share one utility VM, so their clocks agree and the two + # J1: both containers share one utility VM, so their clocks agree and the two # runs can be compared directly. Serialized runs cannot overlap at all. if ($ccAReady -and $ccBReady) { - Run-StateAwareTest "J: execs on two sandboxes overlap in wall clock" { + Run-StateAwareTest "J: execs on two containers overlap in wall clock" { $stamped = "sh -c 'date +%s; sleep 6; date +%s'" $sleepA = @{ phase = 'exec'; sandboxId = $script:ccSandboxA; process = @{ commandLine = $stamped; timeout = 60000 } } $sleepB = @{ phase = 'exec'; sandboxId = $script:ccSandboxB; process = @{ commandLine = $stamped; timeout = 60000 } } @@ -1742,7 +1742,7 @@ try { # J2: the refusal is a pre-admission error, so it reaches the client as an # envelope on stderr rather than a terminal stream frame. if ($ccAReady) { - Run-StateAwareTest "J: second exec on the same sandbox is refused as busy" { + Run-StateAwareTest "J: second exec on the same container is refused as busy" { $blocker = @{ phase = 'exec'; sandboxId = $script:ccSandboxA; process = @{ commandLine = "sh -c 'echo blocker-ready; sleep 6; echo blocker-done'"; timeout = 30000 } } $second = @{ phase = 'exec'; sandboxId = $script:ccSandboxA; process = @{ commandLine = 'echo should-not-run'; timeout = 30000 } } @@ -1787,11 +1787,11 @@ try { } | Out-Null } - # J4: a long exec on A must not delay a full lifecycle on another sandbox, - # so C's own run has to fall inside A's. Both sandboxes share one utility VM, + # J4: a long exec on A must not delay a full lifecycle on another container, + # so C's own run has to fall inside A's. Both containers share one utility VM, # so their in-container stamps are on the same clock. if ($ccAReady) { - Run-StateAwareTest "J: a long exec on A does not block lifecycle work on another sandbox" { + Run-StateAwareTest "J: a long exec on A does not block lifecycle work on another container" { $blocker = @{ phase = 'exec'; sandboxId = $script:ccSandboxA; process = @{ commandLine = "sh -c 'echo blocker-ready; date +%s; sleep 25; date +%s; echo blocker-done'"; timeout = 60000 } } $blockerHandle = Start-StateAwareLines -Request $blocker $ready = Wait-StateAwareMarker -Handle $blockerHandle -Marker 'blocker-ready'