diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 7e2a26adb..d98ba7f67 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -89,6 +89,15 @@ clawdbot sandbox recreate --all clawdbot sandbox recreate --all ``` +### After changing setupCommand + +```bash +clawdbot sandbox recreate --all +# or just one agent: +clawdbot sandbox recreate --agent family +``` + + ### For a specific agent only ```bash diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index a8a4b6a55..e08552386 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -293,6 +293,9 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio } ``` +Note: `setupCommand` lives under `sandbox.docker` and runs once on container creation. +Per-agent `sandbox.docker.*` overrides are ignored when the resolved scope is `"shared"`. + **Benefits:** - **Security isolation**: Restrict tools for untrusted agents - **Resource control**: Sandbox specific agents while keeping others on host diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 0b3d9b830..2179db263 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1990,6 +1990,9 @@ cross-session isolation. Use `scope: "session"` for per-session isolation. Legacy: `perSession` is still supported (`true` → `scope: "session"`, `false` → `scope: "shared"`). +`setupCommand` runs **once** after the container is created (inside the container via `sh -lc`). +For package installs, ensure network egress, a writable root FS, and a root user. + ```json5 { agents: { diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 266210fda..a3d81dc0d 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -116,6 +116,20 @@ Override with `agents.defaults.sandbox.docker.network`. Docker installs and the containerized gateway live here: [Docker](/install/docker) +## setupCommand (one-time container setup) +`setupCommand` runs **once** after the sandbox container is created (not on every run). +It executes inside the container via `sh -lc`. + +Paths: +- Global: `agents.defaults.sandbox.docker.setupCommand` +- Per-agent: `agents.list[].sandbox.docker.setupCommand` + + +Common pitfalls: +- Default `docker.network` is `"none"` (no egress), so package installs will fail. +- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image. +- `user` must be root for package installs (omit `user` or set `user: "0:0"`). + ## Tool policy + escape hatches Tool allow/deny policies still apply before sandbox rules. If a tool is denied globally or per-agent, sandboxing doesn’t bring it back. diff --git a/docs/install/docker.md b/docs/install/docker.md index 9e08d5386..5c4941248 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -254,6 +254,14 @@ precedence, and troubleshooting. ### Enable sandboxing +If you plan to install packages in `setupCommand`, note: +- Default `docker.network` is `"none"` (no egress). +- `readOnlyRoot: true` blocks package installs. +- `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`). +Clawdbot auto-recreates containers when `setupCommand` (or docker config) changes +unless the container was **recently used** (within ~5 minutes). Hot containers +log a warning with the exact `clawdbot sandbox recreate ...` command. + ```json5 { agents: { diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index 96e15f13c..892c5d92d 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -18,6 +18,9 @@ This allows you to run multiple agents with different security profiles: - Family/work agents with restricted tools - Public-facing agents in sandboxes +`setupCommand` belongs under `sandbox.docker` (global or per-agent) and runs once +when the container is created. + Auth is per-agent: each agent reads from its own `agentDir` auth store at: ``` diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 36abdc66a..511702ffb 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -111,6 +111,8 @@ Note on sandboxing: - `requires.bins` is checked on the **host** at skill load time. - If an agent is sandboxed, the binary must also exist **inside the container**. Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image). + `setupCommand` runs once after the container is created. + Package installs also require network egress, a writable root FS, and a root user in the sandbox. Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI in the sandbox container to run there. diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts new file mode 100644 index 000000000..3dadde0aa --- /dev/null +++ b/src/agents/sandbox/config-hash.ts @@ -0,0 +1,38 @@ +import crypto from "node:crypto"; + +import type { SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; + +type SandboxHashInput = { + docker: SandboxDockerConfig; + workspaceAccess: SandboxWorkspaceAccess; + workspaceDir: string; + agentWorkspaceDir: string; +}; + +function normalizeForHash(value: unknown): unknown { + if (value === undefined) return undefined; + if (Array.isArray(value)) { + const normalized = value.map(normalizeForHash).filter((item) => item !== undefined); + const allPrimitive = normalized.every((item) => item === null || typeof item !== "object"); + if (allPrimitive) { + return [...normalized].sort((a, b) => String(a).localeCompare(String(b))); + } + return normalized; + } + if (value && typeof value === "object") { + const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b)); + const normalized: Record = {}; + for (const [key, entryValue] of entries) { + const next = normalizeForHash(entryValue); + if (next !== undefined) normalized[key] = next; + } + return normalized; + } + return value; +} + +export function computeSandboxConfigHash(input: SandboxHashInput): string { + const payload = normalizeForHash(input); + const raw = JSON.stringify(payload); + return crypto.createHash("sha1").update(raw).digest("hex"); +} diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 59ccb127e..5fc1e6a8b 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -1,10 +1,14 @@ import { spawn } from "node:child_process"; +import { defaultRuntime } from "../../runtime.js"; import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; -import { updateRegistry } from "./registry.js"; -import { resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; +import { readRegistry, updateRegistry } from "./registry.js"; +import { computeSandboxConfigHash } from "./config-hash.js"; +import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; +const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; + export function execDocker(args: string[], opts?: { allowFailure?: boolean }) { return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { const child = spawn("docker", args, { @@ -99,12 +103,16 @@ export function buildSandboxCreateArgs(params: { scopeKey: string; createdAtMs?: number; labels?: Record; + configHash?: string; }) { const createdAtMs = params.createdAtMs ?? Date.now(); const args = ["create", "--name", params.name]; args.push("--label", "clawdbot.sandbox=1"); args.push("--label", `clawdbot.sessionKey=${params.scopeKey}`); args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`); + if (params.configHash) { + args.push("--label", `clawdbot.configHash=${params.configHash}`); + } for (const [key, value] of Object.entries(params.labels ?? {})) { if (key && value) args.push("--label", `${key}=${value}`); } @@ -161,6 +169,7 @@ async function createSandboxContainer(params: { workspaceAccess: SandboxWorkspaceAccess; agentWorkspaceDir: string; scopeKey: string; + configHash?: string; }) { const { name, cfg, workspaceDir, scopeKey } = params; await ensureDockerImage(cfg.image); @@ -169,6 +178,7 @@ async function createSandboxContainer(params: { name, cfg, scopeKey, + configHash: params.configHash, }); args.push("--workdir", cfg.workdir); const mainMountSuffix = @@ -191,6 +201,28 @@ async function createSandboxContainer(params: { } } +async function readContainerConfigHash(containerName: string): Promise { + const result = await execDocker( + ["inspect", "-f", '{{ index .Config.Labels "clawdbot.configHash" }}', containerName], + { allowFailure: true }, + ); + if (result.code !== 0) return null; + const raw = result.stdout.trim(); + if (!raw || raw === "") return null; + return raw; +} + +function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) { + if (params.scope === "session") { + return `clawdbot sandbox recreate --session ${params.sessionKey}`; + } + if (params.scope === "agent") { + const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main"; + return `clawdbot sandbox recreate --agent ${agentId}`; + } + return "clawdbot sandbox recreate --all"; +} + export async function ensureSandboxContainer(params: { sessionKey: string; workspaceDir: string; @@ -201,8 +233,51 @@ export async function ensureSandboxContainer(params: { const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); const name = `${params.cfg.docker.containerPrefix}${slug}`; const containerName = name.slice(0, 63); + const expectedHash = computeSandboxConfigHash({ + docker: params.cfg.docker, + workspaceAccess: params.cfg.workspaceAccess, + workspaceDir: params.workspaceDir, + agentWorkspaceDir: params.agentWorkspaceDir, + }); + const now = Date.now(); const state = await dockerContainerState(containerName); - if (!state.exists) { + let hasContainer = state.exists; + let running = state.running; + let currentHash: string | null = null; + let hashMismatch = false; + let registryEntry: + | { + lastUsedAtMs: number; + configHash?: string; + } + | undefined; + if (hasContainer) { + const registry = await readRegistry(); + registryEntry = registry.entries.find((entry) => entry.containerName === containerName); + currentHash = await readContainerConfigHash(containerName); + if (!currentHash) { + currentHash = + registryEntry?.configHash ?? null; + } + hashMismatch = !currentHash || currentHash !== expectedHash; + if (hashMismatch) { + const lastUsedAtMs = registryEntry?.lastUsedAtMs; + const isHot = + running && + (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS); + if (isHot) { + const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey }); + defaultRuntime.log( + `Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`, + ); + } else { + await execDocker(["rm", "-f", containerName], { allowFailure: true }); + hasContainer = false; + running = false; + } + } + } + if (!hasContainer) { await createSandboxContainer({ name: containerName, cfg: params.cfg.docker, @@ -210,17 +285,18 @@ export async function ensureSandboxContainer(params: { workspaceAccess: params.cfg.workspaceAccess, agentWorkspaceDir: params.agentWorkspaceDir, scopeKey, + configHash: expectedHash, }); - } else if (!state.running) { + } else if (!running) { await execDocker(["start", containerName]); } - const now = Date.now(); await updateRegistry({ containerName, sessionKey: scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, + configHash: hashMismatch && running ? currentHash ?? undefined : expectedHash, }); return containerName; } diff --git a/src/agents/sandbox/registry.ts b/src/agents/sandbox/registry.ts index 698a39a94..c8b7c46ec 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -12,6 +12,7 @@ export type SandboxRegistryEntry = { createdAtMs: number; lastUsedAtMs: number; image: string; + configHash?: string; }; type SandboxRegistry = { @@ -56,6 +57,7 @@ export async function updateRegistry(entry: SandboxRegistryEntry) { ...entry, createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, image: existing?.image ?? entry.image, + configHash: entry.configHash ?? existing?.configHash, }); await writeRegistry({ entries: next }); }