From 467d4e17fe9b4f22c96de58ff2db2bdad6533315 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 02:31:51 +0100 Subject: [PATCH] feat: add sandbox scope default --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 19 +++--- docs/gateway/security.md | 7 ++- docs/install/docker.md | 17 +++--- src/agents/sandbox-create-args.test.ts | 2 +- src/agents/sandbox.ts | 84 +++++++++++++++++--------- src/config/types.ts | 7 ++- src/config/zod-schema.ts | 14 +++++ 8 files changed, 102 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb21b10bb..9dbc068de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 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`). - Approve requests via `clawdbot pairing list --provider ` + `clawdbot pairing approve --provider ` (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 user’s local time (system prompt only). - 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. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index dcaa36df8..074accc65 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -629,7 +629,7 @@ Default: `~/clawd`. ``` 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` @@ -847,27 +847,30 @@ per session key at a time). Default: 1. ### `agent.sandbox` -Optional per-session **Docker sandboxing** for the embedded agent. Intended for -non-main sessions so they cannot access your host system. +Optional **Docker sandboxing** for the embedded agent. Intended for non-main +sessions so they cannot access your host system. Defaults (if enabled): -- one container per session +- scope: `"agent"` (one container + workspace per agent) - Debian bookworm-slim based image -- workspace per session under `~/.clawdbot/sandboxes` +- workspace per agent under `~/.clawdbot/sandboxes` - 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) - optional sandboxed browser (Chromium + CDP, noVNC observer) - hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile` -Warning: `perSession: false` means a shared container and shared workspace. No -cross-session isolation. +Warning: `scope: "shared"` means a shared container and shared workspace. No +cross-session isolation. Use `scope: "session"` for per-session isolation. + +Legacy: `perSession` is still supported (`true` → `scope: "session"`, +`false` → `scope: "shared"`). ```json5 { agent: { sandbox: { 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", docker: { image: "clawdbot-sandbox:bookworm-slim", diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 6a9bd55c9..2dbf7e47e 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -140,10 +140,11 @@ We're considering a `readOnlyMode` flag that prevents the AI from: Two complementary approaches: - **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 -its own container + workspace. `perSession: false` shares a single container. +Note: to prevent cross-agent access, keep `sandbox.scope` at `"agent"` (default) +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 don’t enable it for strangers. diff --git a/docs/install/docker.md b/docs/install/docker.md index 118ab96c2..3a98b264d 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -70,25 +70,26 @@ pnpm test:docker:qr - Gateway bind defaults to `lan` for container use. - The gateway container is the source of truth for sessions (`~/.clawdbot/agents//sessions/`). -## Per-session Agent Sandbox (host gateway + Docker tools) +## Agent Sandbox (host gateway + Docker tools) ### What it does 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: -- one container per session (hard wall) -- per-session workspace folder mounted at `/workspace` +- scope: `"agent"` by default (one container + workspace per agent) +- scope: `"session"` for per-session isolation +- per-scope workspace folder mounted at `/workspace` - allow/deny tool policy (deny wins) - 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 -share one container and one workspace, so there is no cross-session isolation. +Warning: `scope: "shared"` disables cross-session isolation. All sessions share +one container and one workspace. ### Default behavior - Image: `clawdbot-sandbox:bookworm-slim` -- One container per session -- Workspace per session under `~/.clawdbot/sandboxes` +- One container per agent +- Workspace per agent under `~/.clawdbot/sandboxes` - Auto-prune: idle > 24h OR age > 7d - 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` @@ -101,7 +102,7 @@ share one container and one workspace, so there is no cross-session isolation. agent: { sandbox: { mode: "non-main", // off | non-main | all - perSession: true, + scope: "agent", // session | agent | shared (agent is default) workspaceRoot: "~/.clawdbot/sandboxes", docker: { image: "clawdbot-sandbox:bookworm-slim", diff --git a/src/agents/sandbox-create-args.test.ts b/src/agents/sandbox-create-args.test.ts index ee8bbcdbc..ebe7385c4 100644 --- a/src/agents/sandbox-create-args.test.ts +++ b/src/agents/sandbox-create-args.test.ts @@ -32,7 +32,7 @@ describe("buildSandboxCreateArgs", () => { const args = buildSandboxCreateArgs({ name: "clawdbot-sbx-test", cfg, - sessionKey: "main", + scopeKey: "main", createdAtMs: 1700000000000, labels: { "clawdbot.sandboxBrowser": "1" }, }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 15ef13840..e45882da5 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -18,6 +18,7 @@ import type { ClawdbotConfig } from "../config/config.js"; import { STATE_DIR_CLAWDBOT } from "../config/config.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; +import { resolveAgentIdFromSessionKey } from "./agent-scope.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, DEFAULT_AGENTS_FILENAME, @@ -72,9 +73,11 @@ export type SandboxPruneConfig = { maxAgeDays: number; }; +export type SandboxScope = "session" | "agent" | "shared"; + export type SandboxConfig = { mode: "off" | "non-main" | "all"; - perSession: boolean; + scope: SandboxScope; workspaceRoot: string; docker: SandboxDockerConfig; browser: SandboxBrowserConfig; @@ -197,11 +200,33 @@ function isToolAllowed(policy: SandboxToolPolicy, name: string) { 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 { const agent = cfg?.agent?.sandbox; return { mode: agent?.mode ?? "off", - perSession: agent?.perSession ?? true, + scope: resolveSandboxScope({ + scope: agent?.scope, + perSession: agent?.perSession, + }), workspaceRoot: agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT, docker: { image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE, @@ -502,14 +527,14 @@ function formatUlimitValue( export function buildSandboxCreateArgs(params: { name: string; cfg: SandboxDockerConfig; - sessionKey: string; + scopeKey: string; createdAtMs?: number; labels?: Record; }) { const createdAtMs = params.createdAtMs ?? Date.now(); const args = ["create", "--name", params.name]; 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}`); for (const [key, value] of Object.entries(params.labels ?? {})) { if (key && value) args.push("--label", `${key}=${value}`); @@ -557,15 +582,15 @@ async function createSandboxContainer(params: { name: string; cfg: SandboxDockerConfig; workspaceDir: string; - sessionKey: string; + scopeKey: string; }) { - const { name, cfg, workspaceDir, sessionKey } = params; + const { name, cfg, workspaceDir, scopeKey } = params; await ensureDockerImage(cfg.image); const args = buildSandboxCreateArgs({ name, cfg, - sessionKey, + scopeKey, }); args.push("--workdir", cfg.workdir); args.push("-v", `${workspaceDir}:${cfg.workdir}`); @@ -584,9 +609,9 @@ async function ensureSandboxContainer(params: { workspaceDir: string; cfg: SandboxConfig; }) { - const slug = params.cfg.perSession - ? slugifySessionKey(params.sessionKey) - : "shared"; + const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); + const slug = + params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); const name = `${params.cfg.docker.containerPrefix}${slug}`; const containerName = name.slice(0, 63); const state = await dockerContainerState(containerName); @@ -595,7 +620,7 @@ async function ensureSandboxContainer(params: { name: containerName, cfg: params.cfg.docker, workspaceDir: params.workspaceDir, - sessionKey: params.sessionKey, + scopeKey, }); } else if (!state.running) { await execDocker(["start", containerName]); @@ -603,7 +628,7 @@ async function ensureSandboxContainer(params: { const now = Date.now(); await updateRegistry({ containerName, - sessionKey: params.sessionKey, + sessionKey: scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, @@ -648,16 +673,15 @@ function buildSandboxBrowserResolvedConfig(params: { } async function ensureSandboxBrowser(params: { - sessionKey: string; + scopeKey: string; workspaceDir: string; cfg: SandboxConfig; }): Promise { if (!params.cfg.browser.enabled) return null; if (!isToolAllowed(params.cfg.tools, "browser")) return null; - const slug = params.cfg.perSession - ? slugifySessionKey(params.sessionKey) - : "shared"; + const slug = + params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); const name = `${params.cfg.browser.containerPrefix}${slug}`; const containerName = name.slice(0, 63); const state = await dockerContainerState(containerName); @@ -666,7 +690,7 @@ async function ensureSandboxBrowser(params: { const args = buildSandboxCreateArgs({ name: containerName, cfg: params.cfg.docker, - sessionKey: params.sessionKey, + scopeKey: params.scopeKey, labels: { "clawdbot.sandboxBrowser": "1" }, }); args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}`); @@ -710,7 +734,7 @@ async function ensureSandboxBrowser(params: { ? await readDockerPort(containerName, params.cfg.browser.noVncPort) : null; - const existing = BROWSER_BRIDGES.get(params.sessionKey); + const existing = BROWSER_BRIDGES.get(params.scopeKey); const existingProfile = existing ? resolveProfile(existing.bridge.state.resolved, "clawd") : null; @@ -722,7 +746,7 @@ async function ensureSandboxBrowser(params: { await stopBrowserBridgeServer(existing.bridge.server).catch( () => undefined, ); - BROWSER_BRIDGES.delete(params.sessionKey); + BROWSER_BRIDGES.delete(params.scopeKey); } let bridge: BrowserBridge; if (shouldReuse && existing) { @@ -737,13 +761,13 @@ async function ensureSandboxBrowser(params: { }); } if (!shouldReuse) { - BROWSER_BRIDGES.set(params.sessionKey, { bridge, containerName }); + BROWSER_BRIDGES.set(params.scopeKey, { bridge, containerName }); } const now = Date.now(); await updateBrowserRegistry({ containerName, - sessionKey: params.sessionKey, + sessionKey: params.scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.browser.image, @@ -858,9 +882,11 @@ export async function resolveSandboxContext(params: { await maybePruneSandboxes(cfg); const workspaceRoot = resolveUserPath(cfg.workspaceRoot); - const workspaceDir = cfg.perSession - ? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey) - : workspaceRoot; + const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey); + const workspaceDir = + cfg.scope === "shared" + ? workspaceRoot + : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); const seedWorkspace = params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; await ensureSandboxWorkspace( @@ -876,7 +902,7 @@ export async function resolveSandboxContext(params: { }); const browser = await ensureSandboxBrowser({ - sessionKey: rawSessionKey, + scopeKey, workspaceDir, cfg, }); @@ -905,9 +931,11 @@ export async function ensureSandboxWorkspaceForSession(params: { if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; const workspaceRoot = resolveUserPath(cfg.workspaceRoot); - const workspaceDir = cfg.perSession - ? resolveSandboxWorkspaceDir(workspaceRoot, rawSessionKey) - : workspaceRoot; + const scopeKey = resolveSandboxScopeKey(cfg.scope, rawSessionKey); + const workspaceDir = + cfg.scope === "shared" + ? workspaceRoot + : resolveSandboxWorkspaceDir(workspaceRoot, scopeKey); const seedWorkspace = params.workspaceDir?.trim() || DEFAULT_AGENT_WORKSPACE_DIR; await ensureSandboxWorkspace( diff --git a/src/config/types.ts b/src/config/types.ts index a103ab1a7..8b02d50ed 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -532,6 +532,9 @@ export type RoutingConfig = { model?: string; sandbox?: { 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; workspaceRoot?: string; }; @@ -912,7 +915,9 @@ export type ClawdbotConfig = { * - "all": allow session tools to target any session */ 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; /** Root directory for sandbox workspaces. */ workspaceRoot?: string; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index acf134e1c..069a41aad 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -235,6 +235,13 @@ const RoutingSchema = z z.literal("all"), ]) .optional(), + scope: z + .union([ + z.literal("session"), + z.literal("agent"), + z.literal("shared"), + ]) + .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), }) @@ -573,6 +580,13 @@ export const ClawdbotSchema = z.object({ sessionToolsVisibility: z .union([z.literal("spawned"), z.literal("all")]) .optional(), + scope: z + .union([ + z.literal("session"), + z.literal("agent"), + z.literal("shared"), + ]) + .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: z