Isolate agent tool execution in Docker containers or SSH sessions with configurable tool policies and workspace mounts.
Sandbox Execution
Docker/SSH backends, workspace mounting, tool policies, and container lifecycle
Sandbox execution isolates tool operations inside Docker containers or SSH sessions, preventing the agent from modifying the host system directly. The sandbox manages workspace mounting, tool allow-lists, browser automation bridges, container lifecycle (startup, idle detection, cleanup), and ACP inheritance modes — all configured through a unified SandboxConfig.
📚 What is this & why does it matter?
When an LLM agent can execute arbitrary code and file operations, it must be sandboxed to prevent accidental (or adversarial) damage to the host. The sandbox module provides defense-in-depth: tool-level allow-lists restrict what the agent can do, while Docker/SSH isolation restricts where it can do it. Without sandboxing, a single rm -rf / from a confused model could destroy the host.
💡 Real-world analogy
The sandbox is like a chemistry lab’s fume hood. Scientists (agents) perform experiments (tool operations) inside the hood (container) rather than on the open bench (host). The hood has its own ventilation (workspace mounts), safety interlocks (tool policies), and an automatic shutoff timer (idle detection). If an experiment goes wrong, the damage is contained inside the hood, not spread across the entire lab.
Contents
Overview
SandboxConfig (mode, backend, image, tool policies), agent workspace path, user cwd.
Sandbox mode (off/non-main/all), backend selection (Docker/SSH), scope (Session/Agent/Shared), tool allow-list.
Running containers, workspace mounts, browser bridges, sandbox runtime info, cleanup on idle/expiry.
flowchart LR
subgraph Config["SandboxConfig"]
Mode["Mode: off | non-main | all"]
Backend["Backend: Docker | SSH"]
Policy["Tool Policy"]
end
subgraph Resolution["Sandbox Resolution"]
Resolve["Resolve sandbox mode"]
Select["Select backend"]
Mount["Configure mounts"]
end
subgraph Execution["Container Lifecycle"]
Start["Start container"]
Tool["Execute tool"]
Idle["Idle detection"]
Cleanup["Cleanup / remove"]
end
Mode --> Resolve
Backend --> Select
Policy --> Select
Resolve --> Select
Select --> Mount
Mount --> Start
Start --> Tool
Tool --> Idle
Idle --> Cleanup
The sandbox resolution pipeline evaluates whether the current tool call requires sandboxing (based on mode), selects the appropriate backend, configures workspace mounts, starts or reuses a container, executes the tool inside it, and monitors for idle timeout or maximum age expiry.
Decision Points
| Decision | What controls it | Why it matters |
|---|---|---|
| Whether to sandbox at all | SandboxConfig.mode: off, non-main, or all | off = no isolation; non-main = sandbox spawned agents only; all = sandbox everything |
| Docker vs. SSH backend | SandboxConfig.backend | Docker is faster for local dev; SSH enables remote sandbox hosts |
| Container scope | Scope: Session, Agent, or Shared | Session = per run; Agent = shared within one agent; Shared = across all agents |
| Tool allow-list | DEFAULT_TOOL_ALLOW array | Restricts which tool categories are permitted inside the sandbox |
| Browser automation | SandboxConfig.browser config | Enables CDP bridge at port 9222 for browser-based tools |
| ACP inheritance | ACP mode: inherit vs. require | inherit = use parent’s sandbox; require = must have own sandbox |
| Container lifecycle | Idle hours + max age days | Prevents resource leaks from abandoned containers |
How It Works
SandboxConfig & SandboxContext Types
The sandbox system is configured through two core types defined in sandbox/types.ts. SandboxConfig is the user-facing configuration; SandboxContext is the runtime state after a sandbox is created:
// sandbox/types.ts — core configuration types
interface SandboxConfig {
mode: "off" | "non-main" | "all"; // When to sandbox
backend: "docker" | "ssh"; // Isolation backend
workspaceRoot?: string; // Override workspace root
dockerImage?: string; // Custom Docker image
sshConfig?: SshConfig; // SSH backend settings
browser?: BrowserConfig; // Browser automation config
toolPolicies?: ToolPolicy[]; // Per-tool overrides
}
interface SandboxContext {
containerName: string; // Running container ID/name
workspaceMounts: WorkspaceMount[]; // Active bind mounts
dockerConfig: DockerConfig; // Resolved Docker settings
browserBridgeUrls?: { // CDP bridge endpoints
http: string; // http://localhost:9222
ws: string; // ws://localhost:9222
};
scope: "session" | "agent" | "shared";
createdAt: Date;
lastActivityAt: Date;
}
interface WorkspaceMount {
hostPath: string; // Path on host machine
containerPath: string; // Path inside container
readOnly: boolean; // Mount as read-only?
}
interface SshConfig {
host: string;
port: number;
username: string;
privateKeyPath?: string;
password?: string;
}
Default Configuration Constants
The sandbox module provides sensible defaults for every configurable value:
// sandbox/defaults.ts — default constants
import path from "node:path";
// Workspace
const STATE_DIR = process.env.OPENCLAW_STATE_DIR ?? path.join(
process.env.HOME ?? "/root", ".openclaw"
);
export const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(STATE_DIR, "sandboxes");
export const DEFAULT_SANDBOX_IMAGE = "openclaw-sandbox:bookworm-slim";
// Browser automation
export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222;
export const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12000;
// Lifecycle
export const DEFAULT_SANDBOX_IDLE_HOURS = 24;
export const DEFAULT_SANDBOX_MAX_AGE_DAYS = 7;
// Tool allow-list
export const DEFAULT_TOOL_ALLOW = [
"exec", "process",
"read", "write", "edit", "apply_patch",
"image",
"sessions_list", "sessions_history",
"sessions_send", "sessions_spawn", "sessions_yield",
"subagents", "session_status",
];
DEFAULT_TOOL_ALLOW list does not include network tools (fetch, curl) or destructive system tools (shutdown, reboot). These must be explicitly added to the allow-list if needed.
Workspace Mounting
The sandbox mounts two directories into the container: the agent workspace at /agent and the user’s current working directory at /workspace. The agent workspace is read-write (for state persistence); the user workspace defaults to read-only but can be made writable:
/agent) is where the sandbox stores intermediate files, tool outputs, and session state. The user workspace (/workspace) is the project directory the user asked the agent to work on. Keeping them separate prevents agent state from polluting the user’s project.
Container State Machine
Containers follow a deterministic lifecycle managed by the sandbox runtime:
stateDiagram-v2 [*] --> Idle Idle --> Starting : tool request Starting --> InUse : container ready Starting --> Failed : startup timeout InUse --> Idle : tool complete InUse --> InUse : another tool request Idle --> Terminating : idle timeout or max age Terminating --> Removed : cleanup complete Failed --> Removed : cleanup Removed --> [*]
Containers transition through five states. The Idle → Starting transition occurs on the first tool request. Once In Use, subsequent tool requests reuse the running container. After the last tool completes, the container returns to Idle and starts an idle timer. If no new requests arrive within DEFAULT_SANDBOX_IDLE_HOURS (24h), or the container exceeds DEFAULT_SANDBOX_MAX_AGE_DAYS (7d), it transitions to Terminating and is removed.
Sandbox Scope
The scope determines container sharing across agent runs:
| Scope | Sharing model | Use case |
|---|---|---|
session | One container per agent run; destroyed when run ends | Maximum isolation; default for untrusted operations |
agent | One container shared across all runs of the same agent | State persistence between runs (e.g., database initialization) |
shared | One container shared across all agents in the session | Resource efficiency when multiple agents work on the same project |
ACP Sandbox Modes
The Agent Context Protocol (ACP) defines two sandbox inheritance modes for child agents spawned via sessions-spawn-tool.ts:
// ACP sandbox inheritance modes
type AcpSandboxMode = "inherit" | "require";
interface AcpSpawnOptions {
sandboxMode: AcpSandboxMode;
// "inherit" — child uses parent's sandbox container
// Pro: no startup overhead, shared state
// Con: child can see parent's files
// "require" — child gets its own sandbox
// Pro: full isolation from parent
// Con: container startup latency
toolPolicies?: ToolPolicy[]; // Child-specific overrides
}
// Example: spawn a sandboxed child for untrusted code review
const child = await spawnAgent({
sandboxMode: "require", // Isolated sandbox
toolPolicies: [
{ tool: "exec", allow: false }, // No code execution
{ tool: "read", allow: true }, // Read-only access
],
});
inherit mode shares the parent’s container filesystem. A child agent with inherit can read and modify files created by the parent. Use require mode when the child handles untrusted input or performs potentially destructive operations.
Sandbox Resolution Flow
sequenceDiagram
participant Agent as Agent Runtime
participant Config as SandboxConfig
participant Resolver as Sandbox Resolver
participant Docker as Docker Backend
participant Container as Container
participant Tool as Tool Executor
Agent->>Config: read sandbox settings
Config-->>Agent: mode, backend, policies
Agent->>Resolver: resolveSandbox(toolName, config)
alt mode = "off"
Resolver-->>Agent: no sandbox (host execution)
else mode = "non-main" and isMainAgent
Resolver-->>Agent: no sandbox (main agent exempt)
else mode = "all" or non-main agent
Resolver->>Docker: findOrCreateContainer(scope)
alt Existing container available
Docker-->>Resolver: reuse container
else No container
Docker->>Container: create + start
Docker->>Container: mount workspaces
Container-->>Docker: ready
Docker-->>Resolver: new SandboxContext
end
Resolver->>Resolver: checkToolPolicy(toolName)
alt Tool allowed
Resolver->>Tool: execute inside container
Tool-->>Resolver: result
Resolver->>Container: update lastActivityAt
else Tool denied
Resolver-->>Agent: ToolAuthorizationError
end
Resolver-->>Agent: tool result
end
The resolution flow first checks if sandboxing is required for this tool call. If yes, it finds or creates a container with the appropriate scope, verifies the tool is permitted by the allow-list, and executes inside the container. The container’s lastActivityAt is updated after every tool execution to reset the idle timer.
Annotated Code Walkthrough
Walk through setting up a sandbox, executing a tool inside it, and handling lifecycle events:
Build a SandboxConfig from user settings and defaults. Missing values fall back to the default constants:
// sandbox/config.ts — building sandbox configuration
import {
DEFAULT_SANDBOX_WORKSPACE_ROOT,
DEFAULT_SANDBOX_IMAGE,
DEFAULT_SANDBOX_IDLE_HOURS,
DEFAULT_SANDBOX_MAX_AGE_DAYS,
DEFAULT_TOOL_ALLOW,
} from "./defaults.js";
export function buildSandboxConfig(
userConfig: Partial<SandboxConfig>
): SandboxConfig {
return {
mode: userConfig.mode ?? "non-main",
backend: userConfig.backend ?? "docker",
workspaceRoot: userConfig.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT,
dockerImage: userConfig.dockerImage ?? DEFAULT_SANDBOX_IMAGE,
sshConfig: userConfig.sshConfig,
browser: userConfig.browser ?? {
cdpPort: DEFAULT_SANDBOX_BROWSER_CDP_PORT,
autostartTimeoutMs: DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS,
},
toolPolicies: userConfig.toolPolicies ?? DEFAULT_TOOL_ALLOW.map(
tool => ({ tool, allow: true })
),
};
}
Create the Docker container with workspace mounts and browser bridge. The container name encodes the scope and agent ID for easy identification:
// sandbox/docker-backend.ts — container creation
async function createContainer(
config: SandboxConfig,
agentId: string,
scope: SandboxContext["scope"]
): Promise<SandboxContext> {
const containerName = `openclaw-${scope}-${agentId}-${Date.now()}`;
const mounts: WorkspaceMount[] = [
{
hostPath: path.join(config.workspaceRoot!, agentId),
containerPath: "/agent",
readOnly: false,
},
{
hostPath: process.cwd(),
containerPath: "/workspace",
readOnly: true, // User workspace is read-only by default
},
];
const dockerArgs = [
"run", "-d",
"--name", containerName,
...mounts.flatMap(m => [
"-v", `${m.hostPath}:${m.containerPath}:${m.readOnly ? "ro" : "rw"}`,
]),
...(config.browser ? ["-p", `${config.browser.cdpPort}:9222`] : []),
config.dockerImage!,
"sleep", "infinity", // Keep container alive
];
await exec("docker", dockerArgs);
return {
containerName,
workspaceMounts: mounts,
dockerConfig: { image: config.dockerImage! },
browserBridgeUrls: config.browser ? {
http: `http://localhost:${config.browser.cdpPort}`,
ws: `ws://localhost:${config.browser.cdpPort}`,
} : undefined,
scope,
createdAt: new Date(),
lastActivityAt: new Date(),
};
}
Execute a tool inside the sandbox container. The executor translates the tool call into a Docker exec command:
// sandbox/executor.ts — tool execution inside container
async function executeInSandbox(
ctx: SandboxContext,
toolName: string,
args: unknown,
toolPolicies: ToolPolicy[]
): Promise<ToolResult> {
// Check tool policy
const policy = toolPolicies.find(p => p.tool === toolName);
if (policy && !policy.allow) {
return {
content: `Tool "${toolName}" is not permitted in this sandbox`,
isError: true,
errorCode: 403,
};
}
// Execute inside container
const result = await exec("docker", [
"exec", "-w", "/workspace",
ctx.containerName,
"openclaw-tool", toolName, JSON.stringify(args),
]);
// Update activity timestamp
ctx.lastActivityAt = new Date();
return { content: result.stdout, isError: result.exitCode !== 0 };
}
Monitor containers for idle timeout and max age. A background loop runs every hour to clean up expired containers:
// sandbox/lifecycle.ts — idle and age monitoring
async function monitorContainers(
containers: Map<string, SandboxContext>
): Promise<void> {
const now = new Date();
for (const [id, ctx] of containers) {
const idleMs = now.getTime() - ctx.lastActivityAt.getTime();
const ageMs = now.getTime() - ctx.createdAt.getTime();
const idleLimitMs = DEFAULT_SANDBOX_IDLE_HOURS * 3600_000;
const ageLimitMs = DEFAULT_SANDBOX_MAX_AGE_DAYS * 86400_000;
if (idleMs > idleLimitMs || ageMs > ageLimitMs) {
console.log(`Removing sandbox ${ctx.containerName}: ` +
(idleMs > idleLimitMs ? "idle timeout" : "max age exceeded"));
await exec("docker", ["rm", "-f", ctx.containerName]);
containers.delete(id);
}
}
}
Edge Cases & Error Handling
| Scenario | Behavior | Code Location |
|---|---|---|
| Docker daemon not running | Fallback to host execution with warning; or error if mode is "all" (strict) | Sandbox resolver |
| Container startup timeout | Container transitions to Failed; cleanup removes partially-created container; tool call retries on host if permitted | docker-backend.ts |
| Orphaned container (agent crash) | Lifecycle monitor detects container exceeding max age and removes it | lifecycle.ts |
| Workspace mount path doesn’t exist | Docker creates the directory automatically (default Docker bind-mount behavior) | Container creation |
| Browser CDP port conflict | Container creation fails; fallback to random port or error if browser is required | docker-backend.ts |
| SSH backend connection failure | Retries with exponential backoff (3 attempts); then falls back to Docker if available | ssh-backend.ts |
| Tool not in allow-list | Returns ToolAuthorizationError (403) without executing | executor.ts |
Child agent with inherit mode + parent sandbox expired | Child detects stale context and creates a new sandbox | sessions-spawn-tool.ts |
Configuration
| Config Key | Default | Description |
|---|---|---|
sandbox.mode | "non-main" | When to sandbox: off, non-main (spawned agents only), all |
sandbox.backend | "docker" | Isolation backend: docker or ssh |
sandbox.workspaceRoot | ~/.openclaw/sandboxes | Base directory for sandbox workspaces on host |
sandbox.dockerImage | openclaw-sandbox:bookworm-slim | Docker image for sandbox containers |
sandbox.browser.cdpPort | 9222 | Chrome DevTools Protocol port for browser tools |
sandbox.browser.autostartTimeoutMs | 12000 | Timeout waiting for browser to start inside container |
sandbox.idleHours | 24 | Hours of inactivity before container is removed |
sandbox.maxAgeDays | 7 | Maximum container age in days |
sandbox.toolAllow | See DEFAULT_TOOL_ALLOW | Array of tool names permitted inside the sandbox |
sandbox.acpMode | "inherit" | ACP sandbox mode for child agents: inherit or require |
Runtime Info
The sandbox-info.ts module provides a read-only view of the current sandbox state for debugging and monitoring:
// sandbox/sandbox-info.ts
export function getSandboxInfo(ctx: SandboxContext | null): SandboxInfo {
if (!ctx) return { active: false };
return {
active: true,
containerName: ctx.containerName,
scope: ctx.scope,
backend: ctx.dockerConfig ? "docker" : "ssh",
mounts: ctx.workspaceMounts.map(m => ({
host: m.hostPath,
container: m.containerPath,
mode: m.readOnly ? "ro" : "rw",
})),
browser: ctx.browserBridgeUrls ?? null,
uptime: Date.now() - ctx.createdAt.getTime(),
idleSince: Date.now() - ctx.lastActivityAt.getTime(),
};
}
Related Topics
See also:
- Tool Use overview — parent module covering all tool-related subsystems
- Function Calling — how tool calls are validated, repaired, and dispatched
- MCP Bridge — how external MCP tool servers connect to the runtime
Directory Map
Source files for sandbox execution
src/agents/tools/
├── sandbox/
│ ├── types.ts ← SandboxConfig, SandboxContext, WorkspaceMount types
│ ├── defaults.ts ← Default constants (image, ports, timeouts, tool allow-list)
│ ├── config.ts ← Config builder with default fallbacks
│ ├── docker-backend.ts ← Docker container creation and management
│ ├── ssh-backend.ts ← SSH session creation and management
│ ├── executor.ts ← Tool execution inside sandbox (policy check + docker exec)
│ ├── lifecycle.ts ← Idle/age monitoring and cleanup
│ └── sandbox-info.ts ← Read-only runtime info for debugging
├── sessions-spawn-tool.ts ← Agent spawning with ACP sandbox inheritance
└── tool-runtime.helpers.ts ← Sandbox integration in tool runtime init