feat: add sandbox scope default

This commit is contained in:
Peter Steinberger
2026-01-07 02:31:51 +01:00
parent 4d4e4de915
commit 467d4e17fe
8 changed files with 102 additions and 49 deletions

View File

@@ -10,6 +10,7 @@
- New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`). - New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
- To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`). - To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
- Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>` (Telegram also supports `clawdbot telegram pairing ...`). - Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>` (Telegram also supports `clawdbot telegram pairing ...`).
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the users local time (system prompt only). - Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the users local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. - Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. - Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.

View File

@@ -629,7 +629,7 @@ Default: `~/clawd`.
``` ```
If `agent.sandbox` is enabled, non-main sessions can override this with their If `agent.sandbox` is enabled, non-main sessions can override this with their
own per-session workspaces under `agent.sandbox.workspaceRoot`. own per-scope workspaces under `agent.sandbox.workspaceRoot`.
### `agent.skipBootstrap` ### `agent.skipBootstrap`
@@ -847,27 +847,30 @@ per session key at a time). Default: 1.
### `agent.sandbox` ### `agent.sandbox`
Optional per-session **Docker sandboxing** for the embedded agent. Intended for Optional **Docker sandboxing** for the embedded agent. Intended for non-main
non-main sessions so they cannot access your host system. sessions so they cannot access your host system.
Defaults (if enabled): Defaults (if enabled):
- one container per session - scope: `"agent"` (one container + workspace per agent)
- Debian bookworm-slim based image - Debian bookworm-slim based image
- workspace per session under `~/.clawdbot/sandboxes` - workspace per agent under `~/.clawdbot/sandboxes`
- auto-prune: idle > 24h OR age > 7d - auto-prune: idle > 24h OR age > 7d
- tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins) - tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins)
- optional sandboxed browser (Chromium + CDP, noVNC observer) - optional sandboxed browser (Chromium + CDP, noVNC observer)
- hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile` - hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`
Warning: `perSession: false` means a shared container and shared workspace. No Warning: `scope: "shared"` means a shared container and shared workspace. No
cross-session isolation. cross-session isolation. Use `scope: "session"` for per-session isolation.
Legacy: `perSession` is still supported (`true` → `scope: "session"`,
`false` → `scope: "shared"`).
```json5 ```json5
{ {
agent: { agent: {
sandbox: { sandbox: {
mode: "non-main", // off | non-main | all mode: "non-main", // off | non-main | all
perSession: true, // recommended for isolation (false = shared container/workspace) scope: "agent", // session | agent | shared (agent is default)
workspaceRoot: "~/.clawdbot/sandboxes", workspaceRoot: "~/.clawdbot/sandboxes",
docker: { docker: {
image: "clawdbot-sandbox:bookworm-slim", image: "clawdbot-sandbox:bookworm-slim",

View File

@@ -140,10 +140,11 @@ We're considering a `readOnlyMode` flag that prevents the AI from:
Two complementary approaches: Two complementary approaches:
- **Run the full Gateway in Docker** (container boundary): [Docker](/install/docker) - **Run the full Gateway in Docker** (container boundary): [Docker](/install/docker)
- **Per-session tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): [Configuration](/gateway/configuration) - **Tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): [Configuration](/gateway/configuration)
Note: to prevent cross-agent access, keep `perSession: true` so each session gets Note: to prevent cross-agent access, keep `sandbox.scope` at `"agent"` (default)
its own container + workspace. `perSession: false` shares a single container. or `"session"` for stricter per-session isolation. `scope: "shared"` uses a
single container/workspace.
Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and dont enable it for strangers. Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and dont enable it for strangers.

View File

@@ -70,25 +70,26 @@ pnpm test:docker:qr
- Gateway bind defaults to `lan` for container use. - Gateway bind defaults to `lan` for container use.
- The gateway container is the source of truth for sessions (`~/.clawdbot/agents/<agentId>/sessions/`). - The gateway container is the source of truth for sessions (`~/.clawdbot/agents/<agentId>/sessions/`).
## Per-session Agent Sandbox (host gateway + Docker tools) ## Agent Sandbox (host gateway + Docker tools)
### What it does ### What it does
When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker
container. The gateway stays on your host, but the tool execution is isolated: container. The gateway stays on your host, but the tool execution is isolated:
- one container per session (hard wall) - scope: `"agent"` by default (one container + workspace per agent)
- per-session workspace folder mounted at `/workspace` - scope: `"session"` for per-session isolation
- per-scope workspace folder mounted at `/workspace`
- allow/deny tool policy (deny wins) - allow/deny tool policy (deny wins)
- inbound media is copied into the sandbox workspace (`media/inbound/*`) so tools can read it - inbound media is copied into the sandbox workspace (`media/inbound/*`) so tools can read it
Warning: setting `perSession: false` disables per-session isolation. All sessions Warning: `scope: "shared"` disables cross-session isolation. All sessions share
share one container and one workspace, so there is no cross-session isolation. one container and one workspace.
### Default behavior ### Default behavior
- Image: `clawdbot-sandbox:bookworm-slim` - Image: `clawdbot-sandbox:bookworm-slim`
- One container per session - One container per agent
- Workspace per session under `~/.clawdbot/sandboxes` - Workspace per agent under `~/.clawdbot/sandboxes`
- Auto-prune: idle > 24h OR age > 7d - Auto-prune: idle > 24h OR age > 7d
- Network: `none` by default (explicitly opt-in if you need egress) - Network: `none` by default (explicitly opt-in if you need egress)
- Default allow: `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` - Default allow: `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`
@@ -101,7 +102,7 @@ share one container and one workspace, so there is no cross-session isolation.
agent: { agent: {
sandbox: { sandbox: {
mode: "non-main", // off | non-main | all mode: "non-main", // off | non-main | all
perSession: true, scope: "agent", // session | agent | shared (agent is default)
workspaceRoot: "~/.clawdbot/sandboxes", workspaceRoot: "~/.clawdbot/sandboxes",
docker: { docker: {
image: "clawdbot-sandbox:bookworm-slim", image: "clawdbot-sandbox:bookworm-slim",

View File

@@ -32,7 +32,7 @@ describe("buildSandboxCreateArgs", () => {
const args = buildSandboxCreateArgs({ const args = buildSandboxCreateArgs({
name: "clawdbot-sbx-test", name: "clawdbot-sbx-test",
cfg, cfg,
sessionKey: "main", scopeKey: "main",
createdAtMs: 1700000000000, createdAtMs: 1700000000000,
labels: { "clawdbot.sandboxBrowser": "1" }, labels: { "clawdbot.sandboxBrowser": "1" },
}); });

View File

@@ -18,6 +18,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { STATE_DIR_CLAWDBOT } from "../config/config.js"; import { STATE_DIR_CLAWDBOT } from "../config/config.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { resolveAgentIdFromSessionKey } from "./agent-scope.js";
import { import {
DEFAULT_AGENT_WORKSPACE_DIR, DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME, DEFAULT_AGENTS_FILENAME,
@@ -72,9 +73,11 @@ export type SandboxPruneConfig = {
maxAgeDays: number; maxAgeDays: number;
}; };
export type SandboxScope = "session" | "agent" | "shared";
export type SandboxConfig = { export type SandboxConfig = {
mode: "off" | "non-main" | "all"; mode: "off" | "non-main" | "all";
perSession: boolean; scope: SandboxScope;
workspaceRoot: string; workspaceRoot: string;
docker: SandboxDockerConfig; docker: SandboxDockerConfig;
browser: SandboxBrowserConfig; browser: SandboxBrowserConfig;
@@ -197,11 +200,33 @@ function isToolAllowed(policy: SandboxToolPolicy, name: string) {
return allow.includes(name.toLowerCase()); return allow.includes(name.toLowerCase());
} }
function resolveSandboxScope(params: {
scope?: SandboxScope;
perSession?: boolean;
}): SandboxScope {
if (params.scope) return params.scope;
if (typeof params.perSession === "boolean") {
return params.perSession ? "session" : "shared";
}
return "agent";
}
function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) {
const trimmed = sessionKey.trim() || "main";
if (scope === "shared") return "shared";
if (scope === "session") return trimmed;
const agentId = resolveAgentIdFromSessionKey(trimmed);
return `agent:${agentId}`;
}
function defaultSandboxConfig(cfg?: ClawdbotConfig): SandboxConfig { function defaultSandboxConfig(cfg?: ClawdbotConfig): SandboxConfig {
const agent = cfg?.agent?.sandbox; const agent = cfg?.agent?.sandbox;
return { return {
mode: agent?.mode ?? "off", mode: agent?.mode ?? "off",
perSession: agent?.perSession ?? true, scope: resolveSandboxScope({
scope: agent?.scope,
perSession: agent?.perSession,
}),
workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT, workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT,
docker: { docker: {
image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE, image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE,
@@ -502,14 +527,14 @@ function formatUlimitValue(
export function buildSandboxCreateArgs(params: { export function buildSandboxCreateArgs(params: {
name: string; name: string;
cfg: SandboxDockerConfig; cfg: SandboxDockerConfig;
sessionKey: string; scopeKey: string;
createdAtMs?: number; createdAtMs?: number;
labels?: Record<string, string>; labels?: Record<string, string>;
}) { }) {
const createdAtMs = params.createdAtMs ?? Date.now(); const createdAtMs = params.createdAtMs ?? Date.now();
const args = ["create", "--name", params.name]; const args = ["create", "--name", params.name];
args.push("--label", "clawdbot.sandbox=1"); args.push("--label", "clawdbot.sandbox=1");
args.push("--label", `clawdbot.sessionKey=${params.sessionKey}`); args.push("--label", `clawdbot.sessionKey=${params.scopeKey}`);
args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`); args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`);
for (const [key, value] of Object.entries(params.labels ?? {})) { for (const [key, value] of Object.entries(params.labels ?? {})) {
if (key && value) args.push("--label", `${key}=${value}`); if (key && value) args.push("--label", `${key}=${value}`);
@@ -557,15 +582,15 @@ async function createSandboxContainer(params: {
name: string; name: string;
cfg: SandboxDockerConfig; cfg: SandboxDockerConfig;
workspaceDir: string; workspaceDir: string;
sessionKey: string; scopeKey: string;
}) { }) {
const { name, cfg, workspaceDir, sessionKey } = params; const { name, cfg, workspaceDir, scopeKey } = params;
await ensureDockerImage(cfg.image); await ensureDockerImage(cfg.image);
const args = buildSandboxCreateArgs({ const args = buildSandboxCreateArgs({
name, name,
cfg, cfg,
sessionKey, scopeKey,
}); });
args.push("--workdir", cfg.workdir); args.push("--workdir", cfg.workdir);
args.push("-v", `${workspaceDir}:${cfg.workdir}`); args.push("-v", `${workspaceDir}:${cfg.workdir}`);
@@ -584,9 +609,9 @@ async function ensureSandboxContainer(params: {
workspaceDir: string; workspaceDir: string;
cfg: SandboxConfig; cfg: SandboxConfig;
}) { }) {
const slug = params.cfg.perSession const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey);
? slugifySessionKey(params.sessionKey) const slug =
: "shared"; params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
const name = `${params.cfg.docker.containerPrefix}${slug}`; const name = `${params.cfg.docker.containerPrefix}${slug}`;
const containerName = name.slice(0, 63); const containerName = name.slice(0, 63);
const state = await dockerContainerState(containerName); const state = await dockerContainerState(containerName);
@@ -595,7 +620,7 @@ async function ensureSandboxContainer(params: {
name: containerName, name: containerName,
cfg: params.cfg.docker, cfg: params.cfg.docker,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
sessionKey: params.sessionKey, scopeKey,
}); });
} else if (!state.running) { } else if (!state.running) {
await execDocker(["start", containerName]); await execDocker(["start", containerName]);
@@ -603,7 +628,7 @@ async function ensureSandboxContainer(params: {
const now = Date.now(); const now = Date.now();
await updateRegistry({ await updateRegistry({
containerName, containerName,
sessionKey: params.sessionKey, sessionKey: scopeKey,
createdAtMs: now, createdAtMs: now,
lastUsedAtMs: now, lastUsedAtMs: now,
image: params.cfg.docker.image, image: params.cfg.docker.image,
@@ -648,16 +673,15 @@ function buildSandboxBrowserResolvedConfig(params: {
} }
async function ensureSandboxBrowser(params: { async function ensureSandboxBrowser(params: {
sessionKey: string; scopeKey: string;
workspaceDir: string; workspaceDir: string;
cfg: SandboxConfig; cfg: SandboxConfig;
}): Promise<SandboxBrowserContext | null> { }): Promise<SandboxBrowserContext | null> {
if (!params.cfg.browser.enabled) return null; if (!params.cfg.browser.enabled) return null;
if (!isToolAllowed(params.cfg.tools, "browser")) return null; if (!isToolAllowed(params.cfg.tools, "browser")) return null;
const slug = params.cfg.perSession const slug =
? slugifySessionKey(params.sessionKey) params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey);
: "shared";
const name = `${params.cfg.browser.containerPrefix}${slug}`; const name = `${params.cfg.browser.containerPrefix}${slug}`;
const containerName = name.slice(0, 63); const containerName = name.slice(0, 63);
const state = await dockerContainerState(containerName); const state = await dockerContainerState(containerName);
@@ -666,7 +690,7 @@ async function ensureSandboxBrowser(params: {
const args = buildSandboxCreateArgs({ const args = buildSandboxCreateArgs({
name: containerName, name: containerName,
cfg: params.cfg.docker, cfg: params.cfg.docker,
sessionKey: params.sessionKey, scopeKey: params.scopeKey,
labels: { "clawdbot.sandboxBrowser": "1" }, labels: { "clawdbot.sandboxBrowser": "1" },
}); });
args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}`); args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}`);
@@ -710,7 +734,7 @@ async function ensureSandboxBrowser(params: {
? await readDockerPort(containerName, params.cfg.browser.noVncPort) ? await readDockerPort(containerName, params.cfg.browser.noVncPort)
: null; : null;
const existing = BROWSER_BRIDGES.get(params.sessionKey); const existing = BROWSER_BRIDGES.get(params.scopeKey);
const existingProfile = existing const existingProfile = existing
? resolveProfile(existing.bridge.state.resolved, "clawd") ? resolveProfile(existing.bridge.state.resolved, "clawd")
: null; : null;
@@ -722,7 +746,7 @@ async function ensureSandboxBrowser(params: {
await stopBrowserBridgeServer(existing.bridge.server).catch( await stopBrowserBridgeServer(existing.bridge.server).catch(
() => undefined, () => undefined,
); );
BROWSER_BRIDGES.delete(params.sessionKey); BROWSER_BRIDGES.delete(params.scopeKey);
} }
let bridge: BrowserBridge; let bridge: BrowserBridge;
if (shouldReuse && existing) { if (shouldReuse && existing) {
@@ -737,13 +761,13 @@ async function ensureSandboxBrowser(params: {
}); });
} }
if (!shouldReuse) { if (!shouldReuse) {
BROWSER_BRIDGES.set(params.sessionKey, { bridge, containerName }); BROWSER_BRIDGES.set(params.scopeKey, { bridge, containerName });
} }
const now = Date.now(); const now = Date.now();
await updateBrowserRegistry({ await updateBrowserRegistry({
containerName, containerName,
sessionKey: params.sessionKey, sessionKey: params.scopeKey,
createdAtMs: now, createdAtMs: now,
lastUsedAtMs: now, lastUsedAtMs: now,
image: params.cfg.browser.image, image: params.cfg.browser.image,
@@ -858,9 +882,11 @@ export async function resolveSandboxContext(params: {
await maybePruneSandboxes(cfg); await maybePruneSandboxes(cfg);
const workspaceRoot = resolveUserPath(cfg.workspaceRoot); const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const workspaceDir = cfg.perSession const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey);
? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey) const workspaceDir =
: workspaceRoot; cfg.scope === "shared"
? workspaceRoot
: resolveSandboxWorkspaceDir(workspaceRoot, scopeKey);
const seedWorkspace = const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
await ensureSandboxWorkspace( await ensureSandboxWorkspace(
@@ -876,7 +902,7 @@ export async function resolveSandboxContext(params: {
}); });
const browser = await ensureSandboxBrowser({ const browser = await ensureSandboxBrowser({
sessionKey: rawSessionKey, scopeKey,
workspaceDir, workspaceDir,
cfg, cfg,
}); });
@@ -905,9 +931,11 @@ export async function ensureSandboxWorkspaceForSession(params: {
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
const workspaceRoot = resolveUserPath(cfg.workspaceRoot); const workspaceRoot = resolveUserPath(cfg.workspaceRoot);
const workspaceDir = cfg.perSession const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey);
? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey) const workspaceDir =
: workspaceRoot; cfg.scope === "shared"
? workspaceRoot
: resolveSandboxWorkspaceDir(workspaceRoot, scopeKey);
const seedWorkspace = const seedWorkspace =
params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR;
await ensureSandboxWorkspace( await ensureSandboxWorkspace(

View File

@@ -532,6 +532,9 @@ export type RoutingConfig = {
model?: string; model?: string;
sandbox?: { sandbox?: {
mode?: "off" | "non-main" | "all"; mode?: "off" | "non-main" | "all";
/** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean; perSession?: boolean;
workspaceRoot?: string; workspaceRoot?: string;
}; };
@@ -912,7 +915,9 @@ export type ClawdbotConfig = {
* - "all": allow session tools to target any session * - "all": allow session tools to target any session
*/ */
sessionToolsVisibility?: "spawned" | "all"; sessionToolsVisibility?: "spawned" | "all";
/** Use one container per session (recommended for hard isolation). */ /** Container/workspace scope for sandbox isolation. */
scope?: "session" | "agent" | "shared";
/** Legacy alias for scope ("session" when true, "shared" when false). */
perSession?: boolean; perSession?: boolean;
/** Root directory for sandbox workspaces. */ /** Root directory for sandbox workspaces. */
workspaceRoot?: string; workspaceRoot?: string;

View File

@@ -235,6 +235,13 @@ const RoutingSchema = z
z.literal("all"), z.literal("all"),
]) ])
.optional(), .optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(), perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(), workspaceRoot: z.string().optional(),
}) })
@@ -573,6 +580,13 @@ export const ClawdbotSchema = z.object({
sessionToolsVisibility: z sessionToolsVisibility: z
.union([z.literal("spawned"), z.literal("all")]) .union([z.literal("spawned"), z.literal("all")])
.optional(), .optional(),
scope: z
.union([
z.literal("session"),
z.literal("agent"),
z.literal("shared"),
])
.optional(),
perSession: z.boolean().optional(), perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(), workspaceRoot: z.string().optional(),
docker: z docker: z