Diagrams of where OpenClaw tool execution runs and what contains it, across the common sandbox configurations. Each figure fuses the machine / container / process boundaries with the tool-call containment. The configuration figures use two agents on one gateway paired with a generic node; per-platform node containment has its own section.
- The gateway hosts the agent loops and stays on the host; only tool execution
(
exec,read,write,edit,apply_patch,process) moves into a sandbox. The packaged MSIX is the exception — the whole gateway runs inside its isolation session. - Node commands (
camera.*,canvas.*,system.run,location.get, and more) always cross to the paired node and run under the node's own containment, independent of the gateway sandbox. tools.elevatedbypasses the sandbox onto the host escape path.
scope and workspaceAccess are independent settings; any combination is valid.
scope sets how many sandboxes exist and which workspace they reuse:
scope |
Sandboxes | Workspace reused |
|---|---|---|
session |
One per session | Per-session slug |
agent |
One per agent | Per-agent |
shared |
One for all sandboxed sessions | Single shared; per-agent backend overrides ignored |
workspaceAccess sets what the sandbox sees:
workspaceAccess |
Real agent workspace | Sandbox workdir |
|---|---|---|
none |
Not mounted | Isolated, read-only |
ro |
Mounted read-only when distinct | Isolated, read-only |
rw |
Mounted read-write as the workdir | — |
The sandbox covers tool execution inside OpenClaw's own embedded agent loop, so which runtime executes a turn changes whether the sandbox contains its tool calls.
The embedded openclaw runtime (the default) runs every exec and file tool
through the OpenClaw tool pipeline, and therefore the configured sandbox backend
when one is enabled.
The embedded codex runtime (the Codex app-server harness) runs as a gateway
subprocess over stdio JSON-RPC.
When a sandbox is active, its native tool calls are routed through a loopback
exec-server bridge into the same sandbox backend — or, without that bridge,
through injected sandbox_exec / sandbox_process shim tools that run on the
backend.
When no sandbox is active, Codex runs its own native tools inside the subprocess,
uncontained.
External ACP harnesses (runtime: "acp" — Claude Code, Gemini CLI, the Codex
ACP adapter, and other acpx harnesses) are spawned by the acpx plugin as
subprocesses on the gateway host.
The harness owns its own native tools and filesystem, so its tool calls execute
on the host outside OpenClaw's sandbox; OpenClaw only relays permission
requests back to the gateway for approval and never interposes the exec itself.
Because that is a host-escape path, sandboxed sessions are blocked from spawning
ACP sessions (sandbox="require" is rejected).
| Runtime | Where it runs | Tool calls contained by the sandbox? |
|---|---|---|
Embedded openclaw |
Gateway agent loop | Yes — through the OpenClaw tool pipeline |
Embedded codex (app-server) |
Gateway subprocess | Yes when a sandbox is active (exec-server bridge or sandbox_exec shims); native and uncontained when off |
ACP harness (runtime: "acp") |
Host subprocess (acpx) | No — runs on the host; permission relay only; blocked from sandboxed sessions |
CLI backends (e.g. claude-cli) |
Local CLI process | No — separate path, not the OpenClaw tool loop |
flowchart TB
subgraph host["Gateway host"]
subgraph laneA["Embedded openclaw"]
oc["Embedded openclaw<br/>agent-a, agent-b"]:::agent
pipe["OpenClaw tool<br/>pipeline"]:::proc
subgraph sbA["Sandbox backend"]
exA["Tool execution"]:::exec
end
end
subgraph laneB["Embedded Codex"]
cx["Codex app-server<br/>gateway subprocess"]:::proc
bridge["Sandbox exec-server<br/>loopback bridge"]:::proc
subgraph sbB["Sandbox backend"]
exB["Tool execution<br/>(sandboxed)"]:::exec
end
nat["native tools<br/>(uncontained)"]:::danger
end
subgraph laneC["External ACP harness"]
acp["ACP harness<br/>subprocess<br/>on gateway host"]:::proc
hr["Host resources<br/>fs / net / secrets"]:::danger
relay["Gateway<br/>permission relay"]:::proc
acpn["Sandboxed sessions<br/>cannot spawn ACP"]:::note
end
end
oc -->|contained| pipe
pipe -->|contained| exA
cx -->|native to sandbox| bridge
bridge -->|contained| exB
cx -.->|sandbox OFF| nat
acp -->|native on host| hr
acp -.->|request_permission| relay
classDef agent fill:#E7F5FF,stroke:#1971C2
classDef proc fill:#CFE4FA,stroke:#0078D4
classDef exec fill:#B2F2BB,stroke:#2F9E44
classDef danger fill:#FFE3E3,stroke:#D13438
classDef note fill:#F3F2F1,stroke:#868E96
style host fill:#F3F9FF,stroke:#0078D4,stroke-width:3px
style laneA fill:#FCFCFD,stroke:#868E96
style laneB fill:#FCFCFD,stroke:#868E96
style laneC fill:#FCFCFD,stroke:#868E96
style sbA fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
style sbB fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
linkStyle 0 stroke:#2F9E44,stroke-width:2px
linkStyle 1 stroke:#2F9E44,stroke-width:2px
linkStyle 2 stroke:#2F9E44,stroke-width:2px
linkStyle 3 stroke:#2F9E44,stroke-width:2px
linkStyle 4 stroke:#D13438,stroke-width:2px
linkStyle 5 stroke:#D13438,stroke-width:2px
linkStyle 6 stroke:#868E96,stroke-width:2px
| Configuration | Gateway runs | Tool calls run | Isolation boundary | Tool-call identity |
|---|---|---|---|---|
| Plain (sandbox off) | On host | In-process on the host | None | Gateway user |
| Docker — agent scope | On host | One container per agent | Container: network none, read-only root, cap-drop ALL, no-new-privileges |
Container user |
| Docker — session scope | On host | One container per session | Same hardened defaults, isolated per session | Container user |
| Docker — shared scope | On host | One shared container | Same hardened defaults, shared across agents and sessions | Container user |
| MXC process container | On host | Fresh ephemeral container per call | Same user; AppContainer least-privilege token; UI disabled; network blocked by default | Same signed-in user |
| Packaged MSIX | Inside the isolation session | Inside the session, with a per-call process container by default | Separate per-execution AgentUser account plus a nested process container and a shared-workspace bridge |
AgentUser account (separate SID) |
| SSH backend | On host | On a remote host over SSH | Remote host; workspace /tmp/openclaw-sandboxes/<id>, uploaded once (mirror) |
Remote SSH user |
| OpenShell backend | On host | On a managed remote sandbox over SSH | Remote sandbox (/sandbox + /agent); mirror (up/down each exec) or remote (seed once) |
Remote sandbox user |
flowchart LR
subgraph B["Boundaries"]
direction TB
b1["Machine boundary<br/>gateway host or node"]:::machine
b2["Node machine or device"]:::device
b3["Container sandbox<br/>Docker / MXC PC /<br/>mobile OS sandbox"]:::sandbox
b4["MXC isolation<br/>process container<br/>or iso session"]:::isolate
b5["Host-sandbox bridge<br/>shared workspace"]:::bridge
b6["Workspace or workspace-visible state"]:::workspace
end
subgraph E["Edges"]
direction TB
s1((" ")) --> t1["Contained tool call"]
s2((" ")) --> t2["Uncontained tool call on the host"]
s3((" ")) -.-> t3["Crosses a machine boundary, or a permission gate"]
s4((" ")) -.-> t4["Denied or not granted"]
s5((" ")) -.-> t5["Control or staging flow"]
end
classDef machine fill:#F3F9FF,stroke:#0078D4,stroke-width:3px
classDef device fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
classDef sandbox fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
classDef isolate fill:#FFF4CE,stroke:#F7630C,stroke-width:3px,stroke-dasharray:5 5
classDef bridge fill:#FFEC99,stroke:#F08C00,stroke-width:2px
classDef workspace fill:#C5F6FA,stroke:#0C8599,stroke-width:2px
linkStyle 0 stroke:#2F9E44,stroke-width:2px
linkStyle 1 stroke:#D13438,stroke-width:2px
linkStyle 2 stroke:#E8590C,stroke-width:2px
linkStyle 3 stroke:#D13438,stroke-width:2px
linkStyle 4 stroke:#868E96,stroke-width:2px
Sandboxing is off (mode: off, the default).
Tool calls run in the gateway process as the gateway user, so a call has the
full blast radius of the host.
Node commands still cross to the node.
flowchart TB
subgraph host["Gateway host — one machine"]
subgraph gw["Gateway daemon"]
a1["agent-a"]:::agent
a2["agent-b"]:::agent
end
tx["Tool execution<br/>exec / read / write<br/>NO sandbox (host)"]:::danger
hr["Host OS resources<br/>fs / net / profile /<br/>secrets"]:::hostres
end
subgraph node["Paired node"]
nc["Exposed commands<br/>camera / canvas /<br/>system.run / location"]:::proc
end
a1 -->|uncontained| tx
a2 -->|uncontained| tx
tx -->|full access| hr
a1 -.->|node.invoke| nc
classDef agent fill:#E7F5FF,stroke:#1971C2
classDef danger fill:#FFE3E3,stroke:#D13438,stroke-width:2px
classDef hostres fill:#FFE3E3,stroke:#D13438
classDef proc fill:#CFE4FA,stroke:#0078D4
style host fill:#F3F9FF,stroke:#0078D4,stroke-width:3px
style node fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style gw fill:#CFE4FA,stroke:#0078D4
linkStyle 0 stroke:#D13438,stroke-width:2px
linkStyle 1 stroke:#D13438,stroke-width:2px
linkStyle 2 stroke:#D13438,stroke-width:2px
linkStyle 3 stroke:#E8590C,stroke-width:2px
backend: docker keeps the gateway on the host and moves tool execution into
containers hardened with network: none, a read-only root, tmpfs scratch,
cap-drop ALL, and no-new-privileges.
Under scope: agent, one container backs every sandboxed session of that agent
(its main session plus any group or channel sessions).
Elevated exec still escapes to the host.
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "docker",
scope: "agent",
workspaceAccess: "rw",
},
},
},
}flowchart TB
subgraph host["Gateway host — one machine"]
subgraph gw["Gateway daemon on host"]
a1["agent-a<br/>main + group sessions"]:::agent
a2["agent-b<br/>main + other sessions"]:::agent
end
def["Docker defaults<br/>net: none / ro root<br/>tmpfs<br/>cap-drop ALL /<br/>no-new-privileges"]:::note
subgraph ca["agent-a container"]
ea["Tool execution<br/>exec / read / write"]:::exec
wa["Scope workspace<br/>key: agent:agent-a"]:::workspace
end
subgraph cb["agent-b container"]
eb["Tool execution"]:::exec
wb["Scope workspace<br/>key: agent:agent-b"]:::workspace
end
esc["Elevated host path<br/>bypasses the sandbox"]:::escape
end
subgraph node["Paired node"]
nc["Node commands<br/>camera / canvas /<br/>system.run / location"]:::proc
end
a1 -->|contained| ea
ea --> wa
a2 -->|contained| eb
eb --> wb
a1 -.->|elevated exec| esc
a1 -.->|node.invoke| nc
classDef agent fill:#E7F5FF,stroke:#1971C2
classDef exec fill:#B2F2BB,stroke:#2F9E44
classDef workspace fill:#C5F6FA,stroke:#0C8599
classDef note fill:#FFF9DB,stroke:#F08C00
classDef escape fill:#FFE8CC,stroke:#E8590C
classDef proc fill:#CFE4FA,stroke:#0078D4
style host fill:#F3F9FF,stroke:#0078D4,stroke-width:3px
style node fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style gw fill:#CFE4FA,stroke:#0078D4
style ca fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
style cb fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
linkStyle 0 stroke:#2F9E44,stroke-width:2px
linkStyle 1 stroke:#2F9E44,stroke-width:2px
linkStyle 2 stroke:#2F9E44,stroke-width:2px
linkStyle 3 stroke:#2F9E44,stroke-width:2px
linkStyle 4 stroke:#E8590C,stroke-width:2px
linkStyle 5 stroke:#E8590C,stroke-width:2px
scope: session gives each sandboxed session its own container and workspace —
the strongest per-session isolation the Docker backend offers.
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "docker",
scope: "session",
workspaceAccess: "ro",
},
},
},
}flowchart TB
subgraph host["Gateway host — one machine"]
subgraph gw["Gateway daemon on host"]
am["agent-a / main"]:::agent
ag["agent-a / group-1"]:::agent
bm["agent-b / main"]:::agent
end
subgraph s1["session container: agent-a / main"]
e1["Tool execution"]:::exec
w1["Session workspace<br/>unique slug"]:::workspace
end
subgraph s2["session container: agent-a / group-1"]
e2["Tool execution"]:::exec
w2["Session workspace<br/>unique slug"]:::workspace
end
subgraph s3["session container: agent-b / main"]
e3["Tool execution"]:::exec
w3["Session workspace<br/>unique slug"]:::workspace
end
end
subgraph node["Paired node"]
nc["Node commands"]:::proc
end
am -->|contained| e1
e1 --> w1
ag -->|contained| e2
e2 --> w2
bm -->|contained| e3
e3 --> w3
am -.->|node.invoke| nc
classDef agent fill:#E7F5FF,stroke:#1971C2
classDef exec fill:#B2F2BB,stroke:#2F9E44
classDef workspace fill:#C5F6FA,stroke:#0C8599
classDef proc fill:#CFE4FA,stroke:#0078D4
style host fill:#F3F9FF,stroke:#0078D4,stroke-width:3px
style node fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style gw fill:#CFE4FA,stroke:#0078D4
style s1 fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
style s2 fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
style s3 fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
linkStyle 0 stroke:#2F9E44,stroke-width:2px
linkStyle 1 stroke:#2F9E44,stroke-width:2px
linkStyle 2 stroke:#2F9E44,stroke-width:2px
linkStyle 3 stroke:#2F9E44,stroke-width:2px
linkStyle 4 stroke:#2F9E44,stroke-width:2px
linkStyle 5 stroke:#2F9E44,stroke-width:2px
linkStyle 6 stroke:#E8590C,stroke-width:2px
scope: shared reuses one container and workspace for every sandboxed session
and ignores per-agent docker, ssh, and browser overrides.
Container state and shared-workspace files are observable across agents and
sessions.
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "docker",
scope: "shared",
workspaceAccess: "none",
},
},
},
}flowchart TB
subgraph host["Gateway host — one machine"]
subgraph gw["Gateway daemon on host"]
a1["agent-a"]:::agent
a2["agent-b"]:::agent
end
subgraph sh["Shared container"]
ex["Shared tool execution<br/>exec / read / write"]:::exec
ws["Shared workspace<br/>key: shared"]:::workspace
warn["Shared state visible<br/>across sessions"]:::warn
end
end
subgraph node["Paired node"]
nc["Node commands"]:::proc
end
a1 -->|contained| ex
a2 -->|contained| ex
ex --> ws
a1 -.->|node.invoke| nc
classDef agent fill:#E7F5FF,stroke:#1971C2
classDef exec fill:#B2F2BB,stroke:#2F9E44
classDef workspace fill:#C5F6FA,stroke:#0C8599
classDef warn fill:#FFF4CE,stroke:#F7630C
classDef proc fill:#CFE4FA,stroke:#0078D4
style host fill:#F3F9FF,stroke:#0078D4,stroke-width:3px
style node fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style gw fill:#CFE4FA,stroke:#0078D4
style sh fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
linkStyle 0 stroke:#2F9E44,stroke-width:2px
linkStyle 1 stroke:#2F9E44,stroke-width:2px
linkStyle 2 stroke:#2F9E44,stroke-width:2px
linkStyle 3 stroke:#E8590C,stroke-width:2px
On Windows, backend: mxc (the @openclaw/mxc-sandbox plugin) runs each tool
call in a fresh, ephemeral ProcessContainer as the same calling user under an
AppContainer least-privilege token — no separate account is provisioned.
The UI is disabled and the network is blocked by default; workspaceAccess plus
any mxcPolicyPaths files decide which paths are visible.
Containers are per-call, so scope only reuses the workspace, not the container.
The separate per-execution account belongs to the isolation-session model in
configuration 6.
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "mxc",
workspaceAccess: "none",
},
},
},
plugins: {
entries: {
mxc: {
config: {
containment: "process",
network: "none",
},
},
},
},
}flowchart TB
subgraph host["Gateway host — one Windows machine"]
subgraph gw["Gateway daemon on host"]
a1["agent-a"]:::agent
a2["agent-b"]:::agent
end
lx["wxc-exec launcher<br/>new id per call"]:::proc
subgraph pca["Ephemeral PC (agent-a)"]
ea["exec / read / write"]:::exec
pa["Same user +<br/>AppContainer SID<br/>least privilege /<br/>UI off<br/>net: none default"]:::note
end
subgraph pcb["Ephemeral PC (agent-b)"]
eb["exec / read / write"]:::exec
pb["Destroyed when the call exits"]:::note
end
wsp["Scope workspace<br/>session/agent/shared<br/>access: none/ro/rw"]:::workspace
sk["Eligible skill copies<br/>sandbox-readable<br/>write-protected"]:::skill
hr["Broader resources<br/>not exposed"]:::hostres
end
subgraph node["Paired node"]
nc["Node commands"]:::proc
end
a1 -.->|tool call| lx
a2 -.->|tool call| lx
lx -->|spawn contained| ea
lx -->|spawn contained| eb
ea --> wsp
eb --> wsp
ea --> sk
ea -.->|not granted| hr
a1 -.->|node.invoke| nc
classDef agent fill:#E7F5FF,stroke:#1971C2
classDef exec fill:#B2F2BB,stroke:#2F9E44
classDef workspace fill:#C5F6FA,stroke:#0C8599
classDef skill fill:#E5DBFF,stroke:#7048E8
classDef note fill:#F3F2F1,stroke:#868E96
classDef hostres fill:#FFE3E3,stroke:#D13438
classDef proc fill:#CFE4FA,stroke:#0078D4
style host fill:#F3F9FF,stroke:#0078D4,stroke-width:3px
style node fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style gw fill:#CFE4FA,stroke:#0078D4
style pca fill:#FFF4CE,stroke:#F7630C,stroke-width:3px,stroke-dasharray:5 5
style pcb fill:#FFF4CE,stroke:#F7630C,stroke-width:3px,stroke-dasharray:5 5
linkStyle 0 stroke:#868E96,stroke-width:2px
linkStyle 1 stroke:#868E96,stroke-width:2px
linkStyle 2 stroke:#2F9E44,stroke-width:2px
linkStyle 3 stroke:#2F9E44,stroke-width:2px
linkStyle 4 stroke:#2F9E44,stroke-width:2px
linkStyle 5 stroke:#2F9E44,stroke-width:2px
linkStyle 6 stroke:#2F9E44,stroke-width:2px
linkStyle 7 stroke:#D13438,stroke-width:2px
linkStyle 8 stroke:#E8590C,stroke-width:2px
The sequence below traces one tool call end to end.
The backend resolves the workspace, builds a fresh ContainerConfig
(destroyOnExit, least-privilege token, UI off, network blocked), and writes a
short-lived payload file.
The host then spawns a plugin-side Node launcher that calls
@microsoft/mxc-sdk spawnSandboxFromConfig to create a throwaway
ProcessContainer for just that command; on exit the container is destroyed and
finalizeExec clears the payload and temp dir.
sequenceDiagram
participant AG as Agent loop (gateway)
participant TP as OpenClaw tool pipeline
participant MXC as MXC backend (@openclaw/mxc-sandbox)
participant LN as Node launcher (@microsoft/mxc-sdk)
participant PC as ProcessContainer (fresh, AppContainer)
participant FS as Scoped workspace + policy paths
AG->>TP: tool call (exec: command, cwd, env)
TP->>MXC: buildExecSpec(command, workdir, env)
MXC->>MXC: resolve scope + workspaceAccess, make ephemeral temp dir
MXC->>MXC: buildMxcContainerConfig (fresh id, destroyOnExit, least-priv, UI off, net block)
MXC->>MXC: write payload file (0600)
MXC-->>TP: exec spec (launcher argv + payload path)
TP->>LN: spawn node launcher --payload-file
rect rgb(238, 238, 238)
note over LN,FS: Per-call ProcessContainer - destroyed on exit
LN->>PC: spawnSandboxFromConfig(): create fresh AppContainer
PC->>FS: run command (writable sandbox workdir, agent workspace none / ro / rw)
FS-->>PC: only scoped paths visible
PC-->>LN: stdout / stderr / exit code
end
LN-->>TP: stream output + exit code
TP->>MXC: finalizeExec()
MXC->>MXC: remove payload file + temp dir
TP-->>AG: tool result
The packaged build runs the whole gateway and its agent loops inside an MXC
isolation session under a separate, per-execution AgentUser account (a distinct
SID).
The MSIX host stays on the host only to orchestrate the session lifecycle, with a
declared shared workspace as the host-to-session bridge.
By default each tool call is further contained in its own nested ProcessContainer,
so the session supplies the separate identity and the inner container adds
per-call AppContainer restrictions.
flowchart TB
subgraph host["Windows host — signed-in user"]
mh["MSIX NativeAOT host<br/>session lifecycle<br/>orchestration"]:::hostproc
br["Shared workspace<br/>host-session bridge"]:::bridge
subgraph iso["MXC isolation session (AgentUser)"]
subgraph gw["Gateway (AgentUser)"]
a1["agent-a"]:::agent
a2["agent-b"]:::agent
end
st["Session-visible workspace and state"]:::workspace
subgraph pca["Inner PC (agent-a)"]
ea["exec / read / write"]:::exec
end
subgraph pcb["Inner PC (agent-b)"]
eb["exec / read / write"]:::exec
end
end
end
subgraph node["Paired node"]
nc["Node commands"]:::proc
end
mh -.->|lifecycle| gw
br -.->|folder share| st
a1 -->|tool call| ea
a2 -->|tool call| eb
ea --> st
eb --> st
a1 -.->|node.invoke| nc
classDef agent fill:#E7F5FF,stroke:#1971C2
classDef exec fill:#B2F2BB,stroke:#2F9E44
classDef workspace fill:#C5F6FA,stroke:#0C8599
classDef hostproc fill:#D0BFFF,stroke:#7048E8
classDef bridge fill:#FFEC99,stroke:#F08C00
classDef proc fill:#CFE4FA,stroke:#0078D4
style host fill:#F3F9FF,stroke:#0078D4,stroke-width:3px
style node fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style iso fill:#FFF4CE,stroke:#F7630C,stroke-width:4px,stroke-dasharray:6 4
style gw fill:#CFE4FA,stroke:#0078D4
style pca fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
style pcb fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
linkStyle 0 stroke:#868E96,stroke-width:2px
linkStyle 1 stroke:#868E96,stroke-width:2px
linkStyle 2 stroke:#2F9E44,stroke-width:2px
linkStyle 3 stroke:#2F9E44,stroke-width:2px
linkStyle 4 stroke:#2F9E44,stroke-width:2px
linkStyle 5 stroke:#2F9E44,stroke-width:2px
linkStyle 6 stroke:#E8590C,stroke-width:2px
Two backends keep the gateway on the host but run tool execution on a remote host over SSH; the gateway process never executes tool commands directly.
The built-in SSH backend (backend: "ssh") opens an SSH connection to a
target host and runs each tool command there under a remote workspace
(/tmp/openclaw-sandboxes/<id>).
The local workspace is uploaded once when the session starts (mirror-style);
the built-in SSH backend has no remote-canonical mode.
The OpenShell backend (backend: "openshell") is NVIDIA's managed variant,
shipped as the @openclaw/openshell-sandbox plugin.
Instead of a plain SSH target, the local openshell CLI talks to an OpenShell
gateway (control plane) and does get-or-create: it looks up the scope's
sandbox and, on a miss, provisions a fresh one — choosing the from source
image, an optional creation policy, attached providers (credentials made
available inside the sandbox), and optional gpu resources — then fetches its
SSH config and connects over the same SSH bridge as the built-in backend.
Managed workspaces live under /sandbox and /agent.
It adds two workspace modes: mirror (default — local stays canonical, synced up
before and down after each exec) and remote (the remote workspace is canonical,
seeded from local once).
The sandbox browser and sandbox.docker.binds are unsupported on OpenShell, and
in mirror mode the filesystem bridge re-checks real paths on every operation to
block symlink escapes.
Node commands still cross to the node.
{
agents: {
defaults: {
sandbox: {
mode: "all",
backend: "openshell",
scope: "session",
workspaceAccess: "rw",
},
},
},
plugins: {
entries: {
openshell: {
enabled: true,
config: {
from: "openclaw", // source image for first-time create
mode: "remote", // mirror (default) | remote
policy: "strict", // OpenShell creation policy
providers: ["openai"], // credentials attached at creation
gpu: true, // request GPU resources
},
},
},
},
}flowchart TB
subgraph host["Gateway host"]
subgraph gw["Gateway daemon"]
a1["agent-a"]:::agent
a2["agent-b"]:::agent
end
cli["openshell CLI (local)<br/>OpenShell only"]:::proc
end
subgraph cp["OpenShell control plane"]
cpm["get-or-create<br/>managed sandbox<br/>from source image<br/>policy / providers<br/>optional GPU"]:::note
end
subgraph remote["Remote sandbox host"]
ex["Tool execution<br/>exec / read / write"]:::exec
ws["Remote workspace<br/>SSH: /tmp/openclaw-<br/>sandboxes/{id}<br/>OpenShell: /sandbox<br/>+ /agent"]:::workspace
sync["Workspace sync<br/>SSH: mirror (init)<br/>OpenShell: mirror or<br/>remote (seed once)"]:::note
end
subgraph node["Paired node"]
nc["Node commands"]:::proc
end
a1 -.->|exec over SSH| ex
a2 -.->|exec over SSH| ex
ex -->|reads/writes| ws
cli -.->|get / create| cp
cp -.->|provision + ssh-config| remote
a1 -.->|node.invoke| nc
classDef agent fill:#E7F5FF,stroke:#1971C2
classDef proc fill:#CFE4FA,stroke:#0078D4
classDef exec fill:#B2F2BB,stroke:#2F9E44
classDef workspace fill:#C5F6FA,stroke:#0C8599
classDef note fill:#F3F2F1,stroke:#868E96
style host fill:#F3F9FF,stroke:#0078D4,stroke-width:3px
style gw fill:#CFE4FA,stroke:#0078D4
style cp fill:#F3F9FF,stroke:#0078D4,stroke-width:2px
style remote fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style node fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
linkStyle 0 stroke:#E8590C,stroke-width:2px
linkStyle 1 stroke:#E8590C,stroke-width:2px
linkStyle 2 stroke:#2F9E44,stroke-width:2px
linkStyle 3 stroke:#868E96,stroke-width:2px
linkStyle 4 stroke:#868E96,stroke-width:2px
linkStyle 5 stroke:#E8590C,stroke-width:2px
Eligible skills are synchronized into a sandbox-readable location with prompt
paths remapped to the in-container paths, so skills stay visible without granting
the sandbox write access to them.
On a writable (rw) workspace the backends handle the protected skill roots
(skills/, .agents/skills/, and the materialized sandbox-skills workspace)
differently:
| Backend | Protected skill roots under a writable workspace |
|---|---|
| Docker | Mounted as read-only overlays over the writable workspace |
| MXC | Command fails before launch if a protected root sits under a writable parent |
flowchart TB
subgraph none["workspaceAccess: none"]
n1["Isolated workdir<br/>read-only"]:::sandbox
n2["Skills synced here<br/>skills/ (copy)"]:::skill
n3["Real agent workspace — NOT mounted"]:::denied
end
subgraph ro["workspaceAccess: ro"]
r1["Isolated workdir<br/>read-only"]:::sandbox
r2["Real agent workspace<br/>/agent — read-only"]:::hostws
r3["Skills synced<br/>into sandbox workdir"]:::skill
end
subgraph rw["workspaceAccess: rw"]
w1["Real agent workspace<br/>/workspace — rw"]:::hostws
w2["Workspace skill roots<br/>skills + .agents<br/>must remain read-only"]:::skill
w3["Other skills to<br/>sandbox-skills/"]:::skill
end
subgraph enf["Writable-workspace"]
d1["Docker<br/>protected roots as<br/>read-only overlays"]:::docker
m1["MXC ProcessContainer<br/>fails if a protected<br/>root<br/>under a rw parent"]:::mxc
end
classDef sandbox fill:#E3FAFC,stroke:#0C8599,stroke-dasharray:5 5
classDef skill fill:#E5DBFF,stroke:#7048E8
classDef denied fill:#FFE3E3,stroke:#D13438
classDef hostws fill:#CFE4FA,stroke:#0078D4
classDef docker fill:#E3FAFC,stroke:#0C8599
classDef mxc fill:#FFF4CE,stroke:#F7630C
The paired node's containment is independent of the gateway sandbox and varies by
platform.
Every platform pairs over an authenticated WebSocket; what differs is whether the
node runs a local system.run subprocess and what contains it.
Desktop nodes can run local commands.
Only Windows contains node system.run in an MXC process container by default
(with a host fallback when MXC is unavailable, unless strict blocking is on);
macOS and Linux gate execution through an approval prompt and OS permissions but
run it at user privilege.
flowchart TB
gw["OpenClaw Gateway"]:::gateway
subgraph win["Windows node"]
wc["WindowsNodeClient<br/>role: node"]:::proc
wm["Local MCP server<br/>127.0.0.1:8765<br/>loopback + token"]:::note
wcaps["In-process caps<br/>camera / screen /<br/>canvas (WinRT)"]:::inproc
wap["ExecApprovalPolicy<br/>allow / deny / prompt"]:::gated
wex["system.run<br/>MXC ProcessContainer"]:::contained
wfb["host fallback<br/>if MXC absent<br/>unless strict mode"]:::note
end
subgraph mac["macOS node"]
mc["GatewayNodeSession"]:::proc
mcaps["In-process caps<br/>camera / screen /<br/>canvas / notify (TCC)"]:::inproc
mw["TS node-host<br/>worker subprocess<br/>EXEC_HOST=app"]:::proc
map["ExecApprovalsSocket<br/>app approval prompt"]:::gated
mex["system.run via<br/>ShellExecutor<br/>user privilege<br/>(not OS-contained)"]:::uncontained
end
subgraph lin["Linux node"]
lc["CLI node service"]:::proc
lp["linux-node plugin<br/>notify-send /<br/>FFmpeg / GeoClue2"]:::inproc
lca["Desktop companion<br/>Canvas over<br/>Unix socket"]:::proc
lex["Host tools<br/>user privilege<br/>no extra sandbox"]:::uncontained
end
gw -.->|node.invoke| wc
gw -.->|node.invoke| mc
gw -.->|node.invoke| lc
wc --> wcaps
wc -.->|gate| wap
wap -->|contained by MXC| wex
wex -.->|fallback| wfb
mc --> mcaps
mc -.->|delegate| mw
mw -.->|approval| map
map --> mex
lc --> lp
lp --> lex
lc -.->|canvas socket| lca
classDef gateway fill:#F3F9FF,stroke:#0078D4,stroke-width:2px
classDef proc fill:#CFE4FA,stroke:#0078D4
classDef inproc fill:#B2F2BB,stroke:#2F9E44
classDef gated fill:#FFF4CE,stroke:#F7630C
classDef contained fill:#B2F2BB,stroke:#2F9E44,stroke-width:3px,stroke-dasharray:5 5
classDef uncontained fill:#FFE3E3,stroke:#D13438
classDef note fill:#F3F2F1,stroke:#868E96
style win fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style mac fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style lin fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
linkStyle 0 stroke:#E8590C,stroke-width:2px
linkStyle 1 stroke:#E8590C,stroke-width:2px
linkStyle 2 stroke:#E8590C,stroke-width:2px
linkStyle 3 stroke:#2F9E44,stroke-width:2px
linkStyle 4 stroke:#E8590C,stroke-width:2px
linkStyle 5 stroke:#2F9E44,stroke-width:2px
linkStyle 6 stroke:#868E96,stroke-width:2px
linkStyle 7 stroke:#2F9E44,stroke-width:2px
linkStyle 8 stroke:#868E96,stroke-width:2px
linkStyle 9 stroke:#E8590C,stroke-width:2px
linkStyle 10 stroke:#2F9E44,stroke-width:2px
linkStyle 11 stroke:#2F9E44,stroke-width:2px
linkStyle 12 stroke:#2F9E44,stroke-width:2px
linkStyle 13 stroke:#868E96,stroke-width:2px
Mobile nodes run every capability in-process inside the OS application sandbox —
there is no system.run subprocess — gated by per-capability permissions.
flowchart TB
gw["OpenClaw Gateway"]:::gateway
subgraph ios["iOS node — app"]
subgraph iosb["iOS App Sandbox"]
ic["GatewayConnection<br/>Controller<br/>WS + TLS pinning"]:::proc
icaps["In-process caps<br/>location / camera /<br/>screen / canvas /<br/>notify / health"]:::inproc
ig["TCC permission prompts, per capability"]:::gated
ins["No system.run / no subprocess"]:::note
ibg["Background-restriction gate"]:::note
end
end
subgraph andr["Android node — app"]
subgraph andb["Android App Sandbox"]
ac["GatewaySession, OkHttp WS"]:::proc
acaps["In-process caps<br/>camera / canvas /<br/>location / notify /<br/>talk"]:::inproc
ag2["Runtime permissions + InvokeCommandAvailability"]:::gated
afg["requiresForeground<br/>NodeForegroundService"]:::note
ans["No system.run / no subprocess"]:::note
end
end
gw -.->|node.invoke| ic
gw -.->|node.invoke| ac
ic --> icaps
ig -.->|permission| icaps
ac --> acaps
ag2 -.->|permission| acaps
afg -.->|foreground| acaps
classDef gateway fill:#F3F9FF,stroke:#0078D4,stroke-width:2px
classDef proc fill:#CFE4FA,stroke:#0078D4
classDef inproc fill:#B2F2BB,stroke:#2F9E44
classDef gated fill:#FFF4CE,stroke:#F7630C
classDef note fill:#F3F2F1,stroke:#868E96
style ios fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style andr fill:#F3F0FF,stroke:#7048E8,stroke-width:3px
style iosb fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
style andb fill:#E3FAFC,stroke:#0C8599,stroke-width:3px,stroke-dasharray:5 5
linkStyle 0 stroke:#E8590C,stroke-width:2px
linkStyle 1 stroke:#E8590C,stroke-width:2px
linkStyle 2 stroke:#2F9E44,stroke-width:2px
linkStyle 3 stroke:#E8590C,stroke-width:2px
linkStyle 4 stroke:#2F9E44,stroke-width:2px
linkStyle 5 stroke:#E8590C,stroke-width:2px
linkStyle 6 stroke:#E8590C,stroke-width:2px
| Platform | Node process | OS sandbox on the node app | Local system.run |
Default containment of exec | Capability gating |
|---|---|---|---|---|---|
| Windows | WinUI tray (MSIX runFullTrust) |
None (full trust) | Yes (LocalCommandRunner) |
MXC process container by default; host fallback if unavailable | ExecApprovalPolicy (allow / deny / prompt); loopback MCP bearer token |
| macOS | Menu-bar app | None (not App-Sandboxed) | Yes (TypeScript node-host to ShellExecutor) |
Runs at user privilege; approval-gated, not OS-contained | ExecApprovalsSocket prompt; TCC permissions |
| Linux | CLI openclaw node run plus desktop companion |
None found | Yes (host tools) | Runs at user privilege; no extra process sandbox | Node approval; host-tool availability |
| iOS | App process | iOS App Sandbox (OS-enforced) | No subprocess | Not applicable — capabilities run in-process | TCC per capability; background-restriction gate |
| Android | App process | Android App Sandbox (per-UID) | No subprocess | Not applicable — capabilities run in-process | Runtime permissions; requiresForeground gate |
- Sandboxing — modes, scope, backend, workspace access
- Sandbox vs. tool policy vs. elevated
- Elevated tools
- Exec approvals — node-side execution gate
- Architecture — gateway, nodes, pairing
- Agent workspace
- Agent runtimes · ACP agents
- OpenShell backend
- Skills
- Nodes overview · Node pairing
- Platform node apps: Windows · macOS · Linux · iOS · Android