diff --git a/rfcs/0032-gateway-containment-windows-isolation-session.md b/rfcs/0032-gateway-containment-windows-isolation-session.md new file mode 100644 index 00000000..a111ce0c --- /dev/null +++ b/rfcs/0032-gateway-containment-windows-isolation-session.md @@ -0,0 +1,867 @@ +--- +title: Gateway Containment and Windows Isolation Sessions +authors: + - Paul Campbell +created: 2026-08-18 +last_updated: 2026-08-18 +status: draft +issue: +rfc_pr: https://github.com/openclaw/rfcs/pull/61 +--- + +# Proposal: Gateway Containment and Windows Isolation Sessions + +## Summary + +We contain the work an agent does, but not the process that decides to do it. On +Windows the Gateway runs as the signed-in user, so every plugin, credential, and +model-directed decision carries that user's full reach over their files, +registry, and tokens. + +This proposes containing the Gateway itself — and, importantly, doing it from +*outside* the Gateway. A small trusted launcher provisions an OS-managed +boundary and starts the Gateway inside it. The Gateway needs no containment code +of its own; it simply never runs anywhere else. + +Windows Isolation Sessions are the first provider: the OS mints a dedicated +**Agent User** account and runs the Gateway in a session bound to it. The +account is provisioned and deprovisioned explicitly, so we keep it between runs +and agent memory and state persist across reboots. That provider is +preview-quality today, so this asks for the contract and the direction, plus a +readiness bar to clear before anyone recommends it. + +## Motivation + +### The Gateway is our biggest uncontained surface + +We already have containment seams, and they're all narrower than the Gateway. +`SandboxBackend` (`src/agents/sandbox/`, with Docker and SSH backends) is +per-session and scoped to a workspace. It contains the commands an agent runs, +and it does real work: a sandboxed tool call that tries to read your SSH keys or +write a startup entry doesn't reach them. + +What it doesn't contain is the process that loads plugins, holds channel and +provider credentials, runs the scheduler, and picks the commands in the first +place. + +That's backwards, and it leaves the full blast radius reachable by three +distinct routes. A tool call that isn't sandboxed — because the session didn't +select a backend, or the operation doesn't route through one — runs with your +identity. A container escape puts the attacker back on the host as you. And the +Gateway process is never sandboxed at all, so anything the agent loop, a plugin, +or the scheduler does directly skips the boundary rather than escaping it. + +Down any of those routes, on a Windows workstation, the blast radius of a prompt +injection or a hostile skill is your whole profile: documents, browser and +credential stores, `HKCU`, startup entries, SSH keys. Tool sandboxing narrows +the first route and does nothing about the third, which is the one this RFC is +about. + +ACLs don't help here. They don't separate code running as you from data owned by +you — that's exactly the grant they encode. Neither does a different working +directory or a restricted shell. Any control the Gateway can decline to apply is +a policy, not a boundary, and agent software is the one category where the +process can be talked into changing its mind. + +### A compromised Gateway can't contain itself + +This constrains the design more than anything else here. + +If the Gateway is what we're treating as compromised, it can't also be what +decides whether to be contained. A containment check inside the Gateway is code +the attacker already controls — patch it, configure it away, or just never reach +it. By the time it would run, untrusted code is already executing with the +identity the check was supposed to remove. Plugins are no better, since they +load into the process being contained. + +So the boundary has to exist *before* the Gateway does, established by something +outside it. That something is a launcher: a separate, minimal executable that +runs as the user, provisions the boundary, and starts the Gateway inside. The +Gateway then has no uncontained mode to reach — not because it declines one, but +because it's never started outside the boundary at all. It needs no containment +code and no containment config. + +That also sets the bar for the launcher. It's the part of the trusted computing +base we actually own, so it stays small, loads no plugins, and never runs +agent-directed work. + +### Today's Windows options are all bad trades + +| Option | Trade | +|---|---| +| Run as the user (default) | Full reach, full blast radius | +| WSL (today's recommendation, per [#58](https://github.com/openclaw/rfcs/pull/58)) | Real boundary, but a second OS with its own filesystem, packages, credentials, and update cadence | +| VM or Windows Sandbox | Stronger boundary, but continuous resource cost and a lifecycle built for disposable sessions, not an always-on daemon | + +None works as a default. The first isn't contained; the others are heavy enough +that nobody runs them for a background process. + +### The OS now offers something better shaped + +Windows is exposing containment built around a distinct *identity* rather than a +whole guest OS. Per the public +[`microsoft/mxc`](https://github.com/microsoft/mxc) project, its +`isolation_session` backend asks a Windows service to mint an **Agent User** — +an account with an opaque OS-assigned name — start a session bound to it, and +run processes inside. Both the account and the session are explicitly +provisioned and explicitly removed; neither is torn down implicitly. + +[MXC states the +requirements](https://github.com/microsoft/mxc/blob/0aaa2afa6588d4aee34b35efb290308bb6f84fa1/docs/isolation-session/oneshot.md) +plainly: "OS-isolated identity so the workload's actions cannot pollute the +calling user's NTFS / registry / token state", with an OS-managed session +lifecycle. + +The unit is a session and an account, not a guest OS — no guest memory footprint +or boot time. Provisioning isn't free, which is exactly why MXC's state-aware +lifecycle exists: one session hosts many executions "without re-paying the +provisioning / session-start cost each time." For a daemon that starts once and +stays up, that's the right shape. + +### How this fits with work already in flight + +- [**#58 (MSIX packaging)**](https://github.com/openclaw/rfcs/pull/58) lists + "Defining runtime isolation" as a non-goal and says a separate RFC may define + a session-based runtime model. This is that RFC. #58 also already describes + the launcher we need — see below. +- [**#42026 (control plane / per-agent + runtimes)**](https://github.com/openclaw/openclaw/issues/42026) splits the + gateway so each agent runs in its own container or process, giving "true + secret isolation" between agents. Different axis. It partitions *which + component holds which secrets*; this changes *what principal a component runs + as*. Fully decomposed on Windows, every runtime still runs as you. Both are + worth having, so the contract below targets a *unit of containment* rather + than a monolithic Gateway. +- [**#55 (OpenShell worker + provider)**](https://github.com/openclaw/rfcs/pull/55) overlaps most and + deserves care: it also runs the long-lived Gateway inside a sandbox, and goes + further by brokering model credentials through `inference.local` so neither + Gateway nor worker holds provider credential values. That directly fixes the + residual risk this RFC can't — a contained Gateway still holds its own + secrets. The difference is deployment context. #55's Gateway sandbox is + *operator-created* on a container platform, which suits cloud and server + deployments; it doesn't help a personal Windows machine where there is no + operator and no OpenShell. The two are complementary: #55 is the better answer + wherever it's available, and its credential brokering is the direction this + RFC should follow rather than compete with. +- [**RFC 0027 (OpenClaw Enterprise)**](0027-openclaw-enterprise.md), accepted, + already defines a `SandboxDriver` whose invariants are close to the ones + below: the platform verifies the selected driver "supports the complete + policy" and rejects deployment when enforcement is "unsupported, unavailable, + or ambiguous"; the driver "establishes and verifies containment before the + Harness starts"; and "an implementation that cannot verify enforcement is + ineligible for the operation", with no substitution of an alternate. That's + capability honesty, boundary-before-start, and fail-closed — so treat what + follows as applying an accepted pattern in a new place rather than inventing + one. Two differences matter: 0027 assumes a control plane a personal machine + doesn't have, and its driver explicitly "does not manage the Namespace's + OpenClaw gateway". Even in the accepted enterprise design, the Gateway is the + piece left outside the boundary — which is the gap this RFC closes. + +## Goals + +- Establish the boundary in a launcher outside the Gateway, so a compromised + Gateway has no uncontained mode to reach. +- Add no containment code, config, or runtime branch to the Gateway, and no + protocol change. This is *not* a claim that the Gateway is unaffected — a new + principal means a different profile, registry hive, and credential store, and + what that breaks is tracked below. +- Define a platform-agnostic `ContainmentProvider` contract so this isn't a + Windows fork of the startup path. +- Make each provider's limits explicit and machine-readable, so one that can't + enforce something says so instead of implying it. +- Fail closed, loudly, when a selected provider isn't available. +- Leave existing uncontained deployments alone, with a real migration and + rollback path for anyone opting in. +- Define the readiness bar to clear before recommending — later, defaulting — + contained execution. + +## Non-Goals + +- **Packaging and distribution.** That's + [#58](https://github.com/openclaw/rfcs/pull/58). This RFC doesn't depend on + MSIX; it needs *a* launcher, and #58 proposes one for Windows. #58 is itself + an open draft, so this RFC depends on a proposal rather than on shipped code. +- **Containing what the Gateway can command.** Authorized desktop nodes and + other action fulfillers sit outside this boundary and need their own + containment story. Out of scope here. +- **Replacing per-session sandboxes.** `SandboxBackend` contains a session's + work; this contains the process running it. They nest. +- **Competing with [#55](https://github.com/openclaw/rfcs/pull/55).** Where an + operator-managed container platform is available, #55 is the better answer and + its credential brokering is stronger. This targets machines where that isn't + an option. +- **Deciding how the Gateway is decomposed.** That's + [#42026](https://github.com/openclaw/openclaw/issues/42026). +- **Specifying the Windows API.** Owned by Windows; consumed, not defined, here. +- **Making containment the default,** or requiring it, now. +- **Changing auth, pairing, discovery, or the wire protocol.** + +## Proposal + +### Where things run + +Three parts, and which side of the boundary each lands on is the whole point. + +**The launcher runs outside, as the user.** It's the entry point. It selects a +provider, provisions the boundary, stages the payload, starts the unit inside, +reports posture, and exits. It's the part of the trusted computing base we own, +so it stays minimal, loads no plugins, and never executes agent-directed work. + +On Windows this is #58's host app, not a new component. #58 defines it as "the +packaged entry point" behind an `openclaw.exe` execution alias, whose job is +"package activation, payload verification and staging, and launching or stopping +the packaged Gateway", and which explicitly "does not proxy Gateway traffic, +distribute Gateway credentials, or approve clients, nodes, or channel users." +This RFC adds one responsibility: establish the boundary first. + +**The unit of containment runs inside.** Today that's the Gateway, along with +the agent loop, plugins, scheduler, and its working state. If +[#42026](https://github.com/openclaw/openclaw/issues/42026) lands, the unit is +whichever process holds the credentials and runs the agent loop — likely each +runtime, and possibly the control plane too, since it keeps channels, routing, +and cron. The provider contract below still applies, but which unit to contain, +and how many, becomes a live question rather than a given. + +**The protocol surface is untouched, but the environment isn't.** Clients, +channels, and nodes reach the Gateway through existing endpoints, and the +Gateway still owns its own config, credentials, and pairing records — the +launcher provisions an environment, it doesn't broker credentials. + +What does change is everything a process inherits from its principal: profile +path, registry hive, and credential store. Two consequences we should not gloss +over. Migration has to move or re-establish that state (see +[Rollout](#rollout)). And per-session sandbox backends assume things about their +environment — the Docker backend needs to reach a daemon, the SSH backend needs +keys and known hosts — none of which is guaranteed for the Agent User. Whether +those keep working under containment is an implementation question phase 2 has +to answer, and if the answer is no for some backend, that is a real capability +loss rather than a detail. + +### The `ContainmentProvider` contract + +The launcher implements and consumes this. A provider answers two questions: +what boundary can this host actually give me, and how do I start and stop +something inside it. + +**Capabilities.** A provider declares its boundary honestly, because an +overstated boundary is worse than none. These are enumerated values, not free +text, so the launcher can compare two providers and refuse a weaker one: + +| Capability | Values | +|---|---| +| `identityIsolation` | `none` (same principal), `shared` (one distinct principal), `perInstance` (fresh principal per environment) | +| `statePersistence` | `none`, `acrossRuns` (survives restart, dies on teardown), `external` (provider keeps no state) | +| `hostPathProjection` | `none`, `explicitPaths` | +| `stagingChannel` | `none`, or a directory with a declared lifetime (`ephemeral`, `durable`) and visibility (`callerSeesAll`, `mutual`) | +| `networkPosture` | `unrestricted`, `egressFiltered`, `isolated` | +| `hostUiReach` | `full`, `none` | +| `lifecycleOwner` | `os` (teardown guaranteed), `caller` (we must drive it) | +| `sessionLifetime` | `perCall` (environment dies with the calling process), `persistent` (survives until explicitly stopped) | + +`hostPathProjection` and `stagingChannel` are deliberately separate. Being able +to hand a file across is not the same as giving an agent the user's working +tree, and merging them is how someone ends up believing they have the second. + +The descriptor is versioned. The launcher refuses a version it doesn't +understand rather than treating absent fields as "unsupported" — an unrecognised +capability must never quietly become a claim about the boundary. + +**Lifecycle.** The middle phases mirror the provider's own rather than inventing +new ones; `probe` is the launcher's own step, not something the provider +defines: + +``` +probe -> provision -> start -> exec -> stop -> deprovision +``` + +`probe` reports availability and capabilities with no side effects. `exec` is +what actually launches the Gateway inside the environment; the earlier phases +only build the place it runs. + +**The launcher is transient.** It runs, provisions or rehydrates the +environment, `exec`s, confirms the Gateway came up, and exits. It is not a +resident supervisor, and containment does not add a second always-on process. + +That works because of two things. The session outlives the launcher — it is +stopped explicitly, not when the calling process goes away — so the environment +is still there next time. And while the provider's own `exec` call is +synchronous, the workload can detach *inside* the boundary: the script the +launcher execs starts the Gateway as a detached child and returns, so the +Gateway outlives the call that created it. Nothing about the provider prevents a +long-lived daemon; the detachment just happens on the inside rather than being +handed out by the API. + +Supervision lives inside the boundary too — a small in-session launcher script +owns the Gateway process, records its PID and status, and captures its exit — +which keeps the host-side launcher out of the steady-state picture entirely. + +**So what starts it?** Not a resident process. On Windows the packaged flow +registers a scheduled task at logon that re-runs the launcher, which rehydrates +the existing session and starts the Gateway if it isn't already up. That's what +makes the Gateway survive logoff and reboot, and it keeps each launcher +invocation short-lived. Health and status are observational: a later invocation +reads the status file and probes the port rather than holding a live handle. + +**Idempotency is per-operation, not universal.** The provider's phases behave +differently on repeat, and a launcher that assumes otherwise leaks accounts: + +| Operation | On repeat | What the launcher must do | +|---|---|---| +| `provision` | **Not idempotent** — mints a fresh identity every call | Write a durable record *before* calling, so a retry after a lost response doesn't blindly provision again | +| `start` / `stop` | Provider-dependent failure on repeat | Track state; don't retry blindly | +| `exec` | Runs the command again, no deduplication | Guard with the launcher's own supervision state | +| `deprovision` | Returns "stale" once the identity is gone | Treat stale as success | + +Only `deprovision` is destructive, but only `deprovision` is safely repeatable, +which is the opposite of the convenient case. `provision` returns an opaque +identifier the launcher persists and uses to address the environment later. + +One failure the launcher genuinely cannot fix on its own: if it dies *during* +provisioning, the identity may exist while the identifier needed to address it +does not. That environment is unreachable through the normal contract, and the +provider treats cleaning it up as an out-of-band operator task. So a write-ahead +record narrows the window but doesn't close it, and shipping this needs an +out-of-band reclamation path for environments no launcher can name. That belongs +in the readiness bar, and it is one of the costs of the provider being +preview-quality. + +Because the environment survives until something explicitly deprovisions it — +which is what we want — the launcher has to reconcile against what already +exists rather than assume a clean slate. Every invocation rehydrates persisted +state and adopts the running environment instead of provisioning a second one. +That's the same mechanism that makes the transient-launcher model work, so it +isn't extra machinery; it just has to be right. + +**Failure outcomes** are a closed set, so the launcher can act without parsing +provider text: + +| Outcome | Meaning | Behavior | +|---|---|---| +| `unavailable` | Provider can't run here — absent, gated off, unsupported build | Fail closed, unless a fallback provider of equal or greater strength is configured | +| `policy_rejected` | Config was supplied that this provider can't enforce | Fail closed, always | +| `stale` | Referenced environment is gone | Re-provision if starting, else surface | +| `lifecycle_failed` | Operational failure | Retry per policy, then fail closed | + +Fallback never means "run uncontained." It means "try the next provider that +still satisfies the requested capabilities." A deployment that wants uncontained +execution turns containment off, which is a visible configuration change rather +than a silent runtime degradation. + +`policy_rejected` never falls back at all, even to a stronger provider. Asking +for a boundary that can't be delivered is a config error, not an availability +problem. + +**Where the configuration lives.** With the launcher, not in `openclaw.json`. +Provider selection, the requested capabilities, and the fallback policy are read +by the launcher before the Gateway exists, so putting them in Gateway config +would mean the contained process owned the settings governing its own +containment — the same inversion the Motivation rejects. It also keeps the +promise that OpenClaw core gains no containment surface: no new config keys, no +new schema, nothing for a deployment to set in the wrong place. The Gateway +never reads or writes these values, and can't. + +### The Windows provider + +`provision` mints the Agent User, `start` boots a session bound to it, `exec` +runs the Gateway, `stop` ends the session, and `deprovision` removes the +account. Each Agent User is distinct with no shared registration, so concurrent +units are independent. + +In normal operation only `start`/`exec`/`stop` recur. `provision` happens once +at setup and `deprovision` only when the deployment is being decommissioned or +deliberately reset — which is what lets Gateway state persist across runs. + +```mermaid +flowchart TB + subgraph host["Windows host"] + user["User profile
files, HKCU, tokens"] + client["Clients, channels, nodes"] + task["Logon task"] + launcher["Launcher (trusted)
transient, no plugins"] + end + + svc["Isolation Session
service, SYSTEM
owns lifecycle"] + + subgraph iso["Isolation session"] + sup["In-session supervisor"] + gw["OpenClaw Gateway
state in Agent User
profile, persists"] + plugins["Plugins, scheduler,
agent loop"] + sb["Per-session sandboxes"] + sup --> gw + gw --- plugins + gw --- sb + end + + task -->|"at logon"| launcher + launcher -->|"provision, stage"| svc + svc -->|"mints Agent User"| iso + launcher -->|"exec, then exit"| sup + client -->|"existing endpoints,
unchanged protocol"| gw + user -.->|"no inherited grants"| iso +``` + +**Figure 1.** A logon task runs the launcher, which establishes the boundary, +`exec`s a supervisor inside it, and exits. The session and the Gateway outlive +it; the Gateway never runs outside the boundary. + +This is an **identity** boundary and the descriptor has to say so. Per [MXC's +backend +docs](https://github.com/microsoft/mxc/blob/0aaa2afa6588d4aee34b35efb290308bb6f84fa1/docs/isolation-session/state-aware-rust.md), +the network is unrestricted — a process inside can listen on a port reachable +over localhost — so `networkPosture` is `unrestricted`. There's no +UI-restriction primitive either, though contained code can't reach the host's +desktop or clipboard. + +Host paths are the interesting part, and they drive much of the design. +Arbitrary path projection is rejected outright — you can't map in the user's +documents folder. What exists instead is one OS-created directory per sandbox, +documented as "a directory shared between the calling user and this isolated +agent user, through which the caller can stage files into the session", with +three properties that matter: + +- **Asymmetric.** Each Agent User sees only its own; the caller sees every + concurrent sandbox's. The caller is the privileged side. +- **Ephemeral.** Created at provision, deleted at deprovision — so it tracks the + Agent User's lifetime, not each run. Still a channel, not storage: the + Gateway's durable state belongs in the profile, not here. +- **Not the working directory.** It doesn't change where the workload runs. + +Provisioning also takes an optional application identifier — documented as "the +Package Family Name for a packaged app", which MXC "neither interprets nor +verifies" and which nothing consumes yet. It is carried verbatim so "a future OS +contract acting on the calling application's identity needs no breaking change." +Today that is inert forward-compatibility metadata, not an enforced link. Worth +noting because it is where package identity from +[#58](https://github.com/openclaw/rfcs/pull/58) would plug in if that OS +contract ever lands — but this RFC should not claim a mechanical link that does +not exist yet. + +**These facts are pinned deliberately.** Every claim here is cited against +`microsoft/mxc` commit +[`0aaa2af`](https://github.com/microsoft/mxc/commit/0aaa2afa6588d4aee34b35efb290308bb6f84fa1), +verified 2026-08-18. This is not pedantry: an earlier draft of this RFC +described an optional caller-supplied identity bundle that the backend accepted +at provision. That surface was removed upstream within about a week, and the +current docs state that `appId` is the only caller knob beyond the network +acknowledgment. Anything preview-gated should be re-verified, not trusted from +memory. + +Availability: the backend is experimental, gated behind both an explicit flag +and an OS feature flag, and needs a recent [Insider +build](https://learn.microsoft.com/en-us/windows-insider/release-notes/experimental/preview-build-26300-8553). + +### State lives in the Agent User profile + +The provider supports provisioning an Agent User per run, but this design +deliberately doesn't. Deprovisioning is an explicit act, and the account +survives everything short of it, so the shape we want is **provision once, keep +it**. The Gateway's configuration, credentials, pairing records, conversation +history, and memory live in that account's profile and persist across runs and +reboots. There's no host-side shadow copy of Gateway state, and no need for one. + +That's a better outcome than it first appears. Durable state stays *inside* the +boundary rather than parked on the host where the user's own token could reach +it, so containment isn't just relocating the credentials it was supposed to +protect. The staging channel goes back to being what its name says — a way to +hand payload in at setup — rather than a data plane the Gateway depends on at +runtime. + +One consequence to design for: **deprovision destroys the agent's memory along +with the environment**, so wiping a possibly compromised Agent User and losing +its accumulated history are the same operation. The fix is straightforward — the +launcher exposes backup and restore through the staging directory, so state can +be exported before a reset and reinstated after — but it has to exist before +deprovision can be recommended as remediation. + +**Don't run the Gateway from the staging directory.** It's ephemeral, +caller-writable, and explicitly not the working directory. Stage through it, +then materialize into the profile. + +**Host reach drops to a staging channel.** No desktop, and no user working tree. +"Fix a file in my repo" isn't expressible unless something explicitly stages +that content across — a product decision about mediated access, not a +transparent capability. See [Unresolved questions](#unresolved-questions). + +### Bypass resistance + +Containment you can skip by accident is a default, not a boundary. Two paths +need closing: + +- **A second entry point.** If a source checkout, stale shortcut, or copied + binary still starts a Gateway directly, the boundary is optional in practice. +- **`openclaw gateway`.** It starts a Gateway by design. + [#58](https://github.com/openclaw/rfcs/pull/58) already makes this a release + requirement for its own payload activation — that host operations and native + `openclaw gateway` commands "cannot create conflicting managed Gateway + instances or bypass staged-payload activation" — and the same applies here. + +Since the Gateway can't be trusted to refuse to start, this can't be a check +inside it. It comes from deployment shape: the managed install exposes the +launcher as its entry point, and a Gateway started some other way is a +*different, unmanaged install* rather than a containment failure of the managed +one. Making that difference observable is part of the readiness bar. + +### Readiness bar + +Recommend contained execution for a deployment class only once: + +- The capability descriptor is accurate and the launcher refuses what a provider + can't enforce. +- Gateway state survives restart and reboot in the Agent User profile. +- The launcher can back up and restore that state through the staging directory, + so remediation and rollback don't mean losing the agent's memory. +- Anything the host reads back from the staging channel is treated as untrusted + input, with the parsing boundary identified and tested. +- Clients, channels, and nodes connect with no protocol change and no extra user + step. +- Ordinary CLI use doesn't pay provisioning cost per command. +- Repeat invocations adopt the existing environment rather than provisioning a + second one, and there's an out-of-band path for environments no launcher can + address. +- The Gateway survives the launcher exiting, and comes back after logoff, + reboot, and its own crash. +- The Docker and SSH sandbox backends either work under containment or their + loss is a documented, accepted tradeoff. +- Downgrade below the containment-capable launcher is prevented by packaging. +- Posture is reported by the launcher, fallback is loud, and a managed install + is distinguishable from one started outside it. +- The platform primitive is generally available and its owner is willing to call + it a security boundary. The [`microsoft/mxc` + README](https://github.com/microsoft/mxc/blob/0aaa2afa6588d4aee34b35efb290308bb6f84fa1/README.md) + currently says the opposite about its preview backends, which alone blocks + claiming this as a defense. + +Until then it ships experimental and opt-in. + +### Security properties + +The invariants a correct implementation has to hold, stated so they can be +checked rather than assumed: + +- The boundary exists before any Gateway code runs; there is no window in which + the Gateway executes uncontained and is contained afterwards. +- The Gateway holds no containment code, configuration, or decision. Nothing it + can do to itself removes the boundary. +- A provider that cannot enforce a requested capability refuses, rather than + accepting it and enforcing something weaker. +- No failure path silently produces uncontained execution. Fallback selects + another provider satisfying the same capabilities, or the start fails. +- The contained unit runs as a principal that never held the user's grants, + rather than as the user with restrictions applied. +- Containment posture is asserted by the launcher. A statement from the Gateway + about its own containment is not evidence. +- Data crossing the boundary is one-directional in trust: anything the host + reads back from the staging channel is untrusted input. + +## Threat model + +The threat is a Gateway induced to act against you: prompt injection reaching +the agent loop, a hostile plugin or skill, a compromised dependency. Assume +arbitrary code execution inside the Gateway. Out of scope: an attacker who +already has your credentials, local admin, or kernel access, and a malicious +OpenClaw build. + +The right comparison throughout is **against an uncontained Gateway today**, not +against a perfect sandbox. Several things below survive containment, and it +matters a great deal whether they survive *worse*, *the same*, or *newly*. + +### What containment removes + +The attacker no longer runs as you. Your profile, `HKCU`, browser and credential +stores, SSH keys, and per-user startup entries are refused by the OS rather than +by our own policy. It can't drive your desktop directly — no screen capture off +your session, no synthetic input into your other apps. What it writes lands in +the Agent User's profile, not yours. + +It also devalues a tool-sandbox escape. Today, breaking out of a per-session +sandbox lands the attacker on the host as you. With the Gateway contained, the +same escape lands them in the Agent User — still bad, but bounded by the same +boundary as the Gateway itself. The two seams genuinely compose rather than +overlapping. + +That is the whole claim. It is narrower than "the Gateway is sandboxed." + +### What is unchanged from today + +These survive containment, but a contained Gateway is **no worse than the +uncontained one you're running now**. They are not regressions, and none of them +is an argument against adopting containment — they're an argument against +overselling it. + +- **Anything readable by `Users`, `Authenticated Users`, or `Everyone`.** The + contained account is a normal Windows account, so machine-wide files, `HKLM`, + and world-readable content stay reachable. Containment removes *your* grants, + not the machine's public surface. +- **Local IPC and host services.** Named pipes, RPC/COM, ALPC, shared memory, + and anything on loopback. Services that trust a caller by origin rather than + by credential — dev servers, local databases, other Gateways, local MCP + servers — remain reachable. This is why containment must never be described as + a network control. +- **Outbound egress,** which is unrestricted. +- **Resource exhaustion** of host CPU, disk, and memory. +- **Persistence — relocated, not removed.** Because the Agent User is meant to + be kept between runs, an attacker who establishes persistence in that profile + keeps it across restarts and reboots, exactly as they would today. The + difference is *where* it lands: uncontained it goes in your profile, your + `HKCU`, and your startup; contained it is confined to the Agent User. Better + scoped and cleanly removable — but note the removal is `deprovision`, which + also destroys the agent's memory, so treat it as a real remediation cost + rather than a free reset. +- **Anything a compromised Gateway can order someone else to do.** Most + concretely an authorized desktop node (see [RFC + 0025](0025-default-pluggable-computer-use.md)): the Gateway doesn't need + desktop access if it can command a node that has it. Containing the Gateway + does not contain what the Gateway is allowed to drive. Nodes need their own + containment story; that's out of scope here, and this RFC should not be read + as claiming model-directed action is fully contained. +- **You.** A contained agent can still talk you into running something yourself. + +### What containment shifts or newly introduces + +This is the list that deserves scrutiny, because these are the costs rather than +the leftovers. + +- **The Gateway's own credentials stay with it.** Provider keys, channel + credentials, and pairing records live inside the Agent User profile by + construction. Containment limits reach into your assets; it does nothing to + stop code running as the Gateway from exfiltrating what the Gateway + legitimately holds. Fixing that needs credential brokering, not containment — + see [#55](https://github.com/openclaw/rfcs/pull/55), which does exactly that. + Keeping state inside the boundary at least avoids the worse alternative of + parking those credentials on the host where your own token could reach them. +- **The staging channel, in both directions.** Contained code writes into a + directory a host-side process running as you later reads, so anything the host + parses from it is untrusted input. This is a genuinely new boundary-crossing + surface and the likeliest place for a bug. Keeping it to setup-time staging, + rather than a runtime data plane, keeps that surface small. +- **A long-lived account holding accumulated secrets.** Persisting across runs + is the point, but the Agent User profile accrues credentials, history, and + memory over months, and it isn't covered by the user's own backup. The + launcher's backup and restore path covers that; the residual question is who + else can read the backup once it's outside the boundary. +- **The launcher,** which runs as you and can provision, start, stop, and stage + into the boundary. It's short-lived, so it isn't a standing target the way a + resident daemon would be — but its binary and its registered entry point are, + since replacing either means owning every subsequent start. +- **The autostart registration.** Something has to start the Gateway at logon, + and whatever holds that registration can change what gets started. On Windows + that's a scheduled task, which is a well-understood thing to protect but is + now part of the design rather than an incidental detail. + +### The trusted computing base + +Bigger than the launcher, and worth naming honestly: + +| Component | Why it's trusted | +|---|---| +| Launcher and its provider implementation | Establishes the boundary and stages into it | +| Its binary and autostart registration | Replacing either owns every subsequent start | +| The provider SDK and native bindings | Everything reaches the OS through them | +| The SYSTEM-hosted OS session service | Owns account and session lifecycle | +| Windows kernel and session separation | The enforcement itself | +| Staging channel parser | Cross-boundary data path | +| Package integrity and signing | Protects the above | + +Two consequences. "The provider declares its capabilities honestly" is a +contract, not a security mechanism — providers must be trusted built-ins or +independently verified, not arbitrary third-party code. And **posture reported +by the Gateway is not evidence**: a compromised Gateway can claim to be +contained, so anything that must be trustworthy has to observe from outside. + +Finally, the platform's own documentation declines to call these profiles +security boundaries today, and session separation does not by itself stop a +kernel-level escape. Contained execution is defense in depth and a direction of +travel, not a control to rely on. + +## Rollout + +**Nothing changes by default.** Containment is opt-in and off. Deployments that +don't configure a provider behave exactly as they do now, everywhere. + +**Turning it on is a one-time migration.** The Gateway moves to the Agent User, +so state under your profile isn't automatically visible to it. Adoption stages +config, credentials, and pairing records across into the Agent User profile +once, and says clearly when it can't — a migration that silently produces an +empty Gateway looks identical to a working one until the first channel fails to +authenticate. After that the Agent User profile is the Gateway's home and +nothing needs re-staging on subsequent runs. + +Credentials protected by the old account's DPAPI keys won't decrypt under the +new principal, so migration means re-establishing those secrets rather than +copying ciphertext. Anything that can't be re-established without user action +has to say so rather than failing at runtime. + +**Rollback is routine, not recovery.** Turning containment off returns to the +previous behavior without data loss, which means adoption must never destroy +pre-migration state while moving it — and it's why the launcher's backup and +restore path matters: state has to come *out* of the Agent User profile before +deprovisioning, which destroys it irreversibly. + +**Downgrade is a real gap, not a requirement we can write our way out of.** A +launcher version predating this contract ignores the containment config and +starts uncontained — the silent posture change this RFC argues against. We can't +retroactively teach an older build to reject config it has never heard of, so +this has to be handled outside the config: a minimum launcher version enforced +by the deployment, or an install that refuses to downgrade below the version +that introduced containment. Whichever, it belongs to whoever owns packaging, +and the readiness bar depends on it existing. + +**Phases.** None of the first three require the preview OS dependency to begin. + +1. **Contract only.** Provider interface, versioned descriptor, failure + taxonomy, selection, fail-closed, posture reporting — in the launcher, with a + null provider. No change to OpenClaw core. Proof: tests through the real + interface for selection, refusal, and reporting. +2. **Windows provider, experimental.** Implement the lifecycle against the OS + primitive with an honest descriptor. Proof: a Gateway that starts contained, + is reachable from a client, and runs under an account that demonstrably isn't + the signed-in user; plus a host without the capability failing closed with a + reason. +3. **State, migration, and backup.** One-time migration into the Agent User + profile, backup and restore through the staging directory, and tested + restart, reboot, upgrade, and downgrade. +4. **Bypass resistance.** Make the launcher the managed entry point and make an + unmanaged Gateway distinguishable. Proof: an admin-observable signal that + doesn't involve asking the Gateway. +5. **Readiness review.** Maintainer decision against the bar. + +Phases 1–2 belong to whoever owns the launcher — on Windows, the +[#58](https://github.com/openclaw/rfcs/pull/58) work. Phase 4 builds on #58's +entry-point work rather than duplicating it. + +## Decision requested + +**Is launcher-established, opt-in Gateway containment a direction we want to +support, given that the first provider offers identity isolation only and is +preview-gated?** + +A yes authorizes phase 1: the contract, in the launcher, no provider, no default +change — and no change to OpenClaw core, since the Gateway gains no containment +code. It doesn't commit us to the Windows provider, a default posture, or a +timeline. + +Be clear about what a yes accepts in principle, though, because these are +structural rather than incidental: + +- **A launcher as the supported entry point.** Starting the Gateway goes through + it, including at logon, so the deployment owns an entry point it didn't have + before. The launcher is short-lived, so this isn't a resident process — but it + is a new required step in front of the Gateway. +- **A larger trusted computing base,** including the provider SDK, a + SYSTEM-hosted OS service, and package integrity. +- **A boundary that is identity-only.** It doesn't constrain the network, and it + doesn't contain what the Gateway is authorized to command. + +If those are unacceptable, it's better to say so now than after phase 1. + +## Rationale + +**Why contain the Gateway, not just the session.** Per-session sandboxes assume +the agent's *work* is untrusted. Prompt injection attacks its *judgment*. Once +that's the threat, the thing holding the credentials and choosing the actions +has to be inside a boundary too. + +**Why the launcher, not a hook in the Gateway.** A seam in OpenClaw core would +be easier to ship and would sit where the rest of the runtime config lives. It's +wrong for one decisive reason: the Gateway is what the threat model treats as +compromised, so a decision it makes about its own containment is a decision the +attacker makes. A separate executable costs an entry-point contract and buys a +boundary that exists before any untrusted code runs. That OpenClaw core changes +not at all is a bonus — nothing to misconfigure, and no preview OS dependency in +the cross-platform build. + +**Why a provider contract, not Windows-specific code.** The problem isn't +Windows-specific; only this primitive is. macOS and Linux have different +mechanisms with different capability profiles. An explicit descriptor lets those +arrive later and forces each to state its limits in a form we can act on. +Without it, the first implementation becomes the de facto contract and its +unstated assumptions get baked in. + +**Why not just split the Gateway.** +[#42026](https://github.com/openclaw/openclaw/issues/42026) gives each agent its +own runtime and secrets — genuinely worth doing, and not a substitute. It +partitions between agents while every resulting process still runs as you. +Decomposition means a compromised agent can't reach another agent's secrets; +containment means it can't reach yours. + +**Why not WSL.** It contains by moving the Gateway into a second OS, with its +own filesystem, packages, credentials, update cadence, and failure modes, and it +distances the Gateway from the environment you actually work in. An isolation +session keeps it on Windows and changes only the principal. + +**Why not a VM or Windows Sandbox.** Stronger boundary, and right for some +deployments. Poor default for an always-on daemon: continuous resource cost, +noticeable startup, and a lifecycle built for disposable sessions. The +isolation-session cost profile is what makes contained execution plausible as an +eventual default rather than an expert mode. + +**Why not AppContainer.** MXC's default Windows backend is AppContainer-based, +so this is a real option rather than a strawman — it's just the wrong shape for +a Gateway. AppContainer is a *restriction* model: it keeps your token and takes +access away, so every resource the workload needs has to be enumerated up front. +A Gateway that loads arbitrary plugins, spawns a Node runtime, and starts MCP +servers has an open-ended and unpredictable resource set, which is exactly the +case an allowlist handles worst. There's a deployment cost too: the tier +available on every current Windows release enforces filesystem policy through +host path ACEs, and standing that up needs a separate **elevated** host-prep +step that adds ACEs for AppContainer SIDs to the system-drive root. Those ACEs +are removable again by a matching unprepare step, so this isn't permanent damage +— but requiring an administrator to modify machine-wide ACLs before a per-user +agent can run is a poor fit for a personal machine. An isolation session gives a +separate principal with an ordinary account instead, so nothing has to be +enumerated and nothing machine-wide is touched. + +**Why fail closed.** A control that silently degrades to no control produces the +worst outcome: someone who believes they're protected and isn't. + +## Unresolved questions + +### Blocking + +- **How does a contained Gateway reach the user's files?** Arbitrary paths can't + be projected; there's a staging directory. So the question isn't whether + sharing is possible but what to do with a mediated channel: stage an + explicitly selected working set in and results out, run a filesystem bridge + over the existing protocol like the one already used for sandboxes, wait for + an OS projection primitive, or accept that a contained Gateway works only on + its own workspace. Each reopens the boundary differently, and the choice + decides how useful this actually is. +- **How is exported state protected?** Backup and restore through the staging + directory is the mechanism, but an export leaves the boundary carrying the + Gateway's credentials and history. What protects it at rest, and what happens + to the Agent User profile when the machine is reimaged? +- **Is an identity-only boundary worth adopting?** Nothing is enforced on the + network and loopback survives. Do we require a network-capable provider before + recommending this, or is reduced reach into user assets enough on its own? + +### Non-blocking + +- **Do Windows node capabilities survive?** Computer use, screen capture, and + input injection (see [RFC 0025](0025-default-pluggable-computer-use.md)) need + the user's desktop, which contained code can't reach. Does the node stay + outside and connect in, and what does that do to the boundary's value? +- **Should the contained Gateway carry its own identity?** Giving the unit its + own credentials rather than the Gateway holding provider keys would fix the + biggest residual risk above. The provider exposed a caller-supplied identity + bundle recently and no longer does, so there's nothing to build on today — but + [#55](https://github.com/openclaw/rfcs/pull/55) already solves this with + credential brokering, and that's the more promising direction regardless of + what the OS offers. +- **Can the Gateway start without a logon?** A logon task covers the normal + case, but a Gateway expected to be reachable on a machine nobody has signed + into needs something else, and registering that something generally needs + elevation. Whether headless operation is in scope is a product question. +- **How do we express a posture most hosts can't support** without fragmenting + the Windows experience? +- **Should this contract live here or in + [#58](https://github.com/openclaw/rfcs/pull/58)?** It belongs to a launcher + #58 already owns. Separate keeps it platform-neutral; folded in keeps the host +app's responsibilities in one document. Pick one rather than letting both +describe the launcher. +- **What would let us call this a security boundary?** The platform won't make + that claim for preview profiles. We should say in advance what evidence we'd + need before describing this as a defense to users. +- **Is `0032` the right number?** It was the lowest unclaimed integer, but + several open proposals currently share numbers, so allocation clearly isn't + sequential. A maintainer should confirm or reassign.