Skip to content

Instantly share code, notes, and snippets.

@paulcam206
Last active July 20, 2026 20:36
Show Gist options
  • Select an option

  • Save paulcam206/9a617b106b266b890a1ad62869a2acea to your computer and use it in GitHub Desktop.

Select an option

Save paulcam206/9a617b106b266b890a1ad62869a2acea to your computer and use it in GitHub Desktop.
OpenClaw containment topologies

OpenClaw containment topologies

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.

Model

  • 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.elevated bypasses the sandbox onto the host escape path.

Scope and workspace access

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

Agent runtimes and containment

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
Loading

Containment at a glance

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

Legend

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(("&nbsp;")) --> t1["Contained tool call"]
    s2(("&nbsp;")) --> t2["Uncontained tool call on the host"]
    s3(("&nbsp;")) -.-> t3["Crosses a machine boundary, or a permission gate"]
    s4(("&nbsp;")) -.-> t4["Denied or not granted"]
    s5(("&nbsp;")) -.-> 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
Loading

1. Plain, unconfigured OpenClaw

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
Loading

2. Docker sandbox — scope: agent

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
Loading

3. Docker sandbox — scope: session

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
Loading

4. Docker sandbox — scope: shared

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
Loading

5. MXC sandbox (process container)

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
Loading

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
Loading

6. Packaged OpenClaw MSIX (isolation session)

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
Loading

7. Remote sandbox backends — SSH and OpenShell

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
Loading

Workspaces and skills

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
Loading

Node-side containment by platform

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
Loading

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
Loading
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

References

OpenClaw documentation

OpenClaw source

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment