From 4f58e6aa7cb1f70f9cb07cb59e11cb5931b398cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 01:06:09 +0100 Subject: [PATCH] feat(sandbox): per-agent docker overrides --- CHANGELOG.md | 2 +- docs/gateway/configuration.md | 4 +- docs/install/docker.md | 2 +- docs/multi-agent-sandbox-tools.md | 12 +++- src/agents/sandbox-agent-config.test.ts | 92 +++++++++++++++++++++++- src/agents/sandbox.ts | 57 +++++++++------ src/commands/doctor.ts | 57 +++++++++++++++ src/config/types.ts | 96 ++++++++++++------------- src/config/zod-schema.ts | 78 ++++++++++---------- 9 files changed, 280 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8ebebc4..6c30fbe87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ - Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first). - Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`. - Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380. -- Sandbox: allow per-agent `routing.agents..sandbox.docker.setupCommand` overrides for multi-agent gateways (ignored when `scope: "shared"`). +- Sandbox: allow per-agent `routing.agents..sandbox.docker.*` overrides for multi-agent gateways (ignored when `scope: "shared"`). - Tools: make per-agent tool policies override global defaults and run bash synchronously when `process` is disallowed. - Tools: scope `process` sessions per agent to prevent cross-agent visibility. - Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 6b4264823..16f695add 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -339,7 +339,7 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o - `workspaceAccess`: `"none"` | `"ro"` | `"rw"` - `scope`: `"session"` | `"agent"` | `"shared"` - `workspaceRoot`: custom sandbox workspace root - - `docker.setupCommand`: optional one-time setup command (runs once after container creation; ignored when `scope: "shared"`) + - `docker`: per-agent docker overrides (e.g. `image`, `network`, `env`, `setupCommand`, limits; ignored when `scope: "shared"`) - `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`) - `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy). - `allow`: array of allowed tool names @@ -1116,7 +1116,7 @@ Legacy: `perSession` is still supported (`true` → `scope: "session"`, capDrop: ["ALL"], env: { LANG: "C.UTF-8" }, setupCommand: "apt-get update && apt-get install -y git curl jq", - // Per-agent override (multi-agent): routing.agents..sandbox.docker.setupCommand + // Per-agent override (multi-agent): routing.agents..sandbox.docker.* pidsLimit: 256, memory: "1g", memorySwap: "2g", diff --git a/docs/install/docker.md b/docs/install/docker.md index 63c0a6a59..1d7dc5cc6 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -160,7 +160,7 @@ Hardening knobs live under `agent.sandbox.docker`: `network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`. -Multi-agent: override `setupCommand` per agent via `routing.agents..sandbox.docker.setupCommand` +Multi-agent: override `agent.sandbox.docker.*` per agent via `routing.agents..sandbox.docker.*` (ignored when `agent.sandbox.scope` / `routing.agents..sandbox.scope` is `"shared"`). ### Build the default sandbox image diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index 124b69cc8..a60200db3 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -1,3 +1,10 @@ +--- +summary: "Per-agent sandbox + tool restrictions, precedence, and examples" +title: Multi-Agent Sandbox & Tools +read_when: "You want per-agent sandboxing or per-agent tool allow/deny policies in a multi-agent gateway." +status: active +--- + # Multi-Agent Sandbox & Tools Configuration ## Overview @@ -142,9 +149,12 @@ routing.agents[id].sandbox.mode > agent.sandbox.mode routing.agents[id].sandbox.scope > agent.sandbox.scope routing.agents[id].sandbox.workspaceRoot > agent.sandbox.workspaceRoot routing.agents[id].sandbox.workspaceAccess > agent.sandbox.workspaceAccess +routing.agents[id].sandbox.docker.* > agent.sandbox.docker.* ``` -**Note:** `docker`, `browser`, and `prune` settings from `agent.sandbox` are still **global** and apply to all sandboxed agents. +**Notes:** +- `routing.agents[id].sandbox.docker.*` overrides `agent.sandbox.docker.*` for that agent (ignored when sandbox scope resolves to `"shared"`). +- `browser` and `prune` settings under `agent.sandbox` are still **global** and apply to all sandboxed agents. ### Tool Restrictions The filtering order is: diff --git a/src/agents/sandbox-agent-config.test.ts b/src/agents/sandbox-agent-config.test.ts index 6862580a6..aee0dcc01 100644 --- a/src/agents/sandbox-agent-config.test.ts +++ b/src/agents/sandbox-agent-config.test.ts @@ -1,16 +1,24 @@ import { EventEmitter } from "node:events"; import { Readable } from "node:stream"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; // We need to test the internal defaultSandboxConfig function, but it's not exported. // Instead, we test the behavior through resolveSandboxContext which uses it. +type SpawnCall = { + command: string; + args: string[]; +}; + +const spawnCalls: SpawnCall[] = []; + vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - spawn: () => { + spawn: (command: string, args: string[]) => { + spawnCalls.push({ command, args }); const child = new EventEmitter() as { stdout?: Readable; stderr?: Readable; @@ -18,13 +26,31 @@ vi.mock("node:child_process", async (importOriginal) => { }; child.stdout = new Readable({ read() {} }); child.stderr = new Readable({ read() {} }); - queueMicrotask(() => child.emit("close", 0)); + + const dockerArgs = command === "docker" ? args : []; + const shouldFailContainerInspect = + dockerArgs[0] === "inspect" && + dockerArgs[1] === "-f" && + dockerArgs[2] === "{{.State.Running}}"; + const shouldSucceedImageInspect = + dockerArgs[0] === "image" && dockerArgs[1] === "inspect"; + + const code = shouldFailContainerInspect ? 1 : 0; + if (shouldSucceedImageInspect) { + queueMicrotask(() => child.emit("close", 0)); + } else { + queueMicrotask(() => child.emit("close", code)); + } return child; }, }; }); describe("Agent-specific sandbox config", () => { + beforeEach(() => { + spawnCalls.length = 0; + }); + it("should use global sandbox config when no agent-specific config exists", async () => { const { resolveSandboxContext } = await import("./sandbox.js"); @@ -91,6 +117,15 @@ describe("Agent-specific sandbox config", () => { expect(context).toBeDefined(); expect(context?.docker.setupCommand).toBe("echo work"); + expect( + spawnCalls.some( + (call) => + call.command === "docker" && + call.args[0] === "exec" && + call.args.includes("-lc") && + call.args.includes("echo work"), + ), + ).toBe(true); }); it("should ignore agent-specific docker overrides when scope is shared", async () => { @@ -131,6 +166,57 @@ describe("Agent-specific sandbox config", () => { expect(context).toBeDefined(); expect(context?.docker.setupCommand).toBe("echo global"); expect(context?.containerName).toContain("shared"); + expect( + spawnCalls.some( + (call) => + call.command === "docker" && + call.args[0] === "exec" && + call.args.includes("-lc") && + call.args.includes("echo global"), + ), + ).toBe(true); + }); + + it("should allow agent-specific docker settings beyond setupCommand", async () => { + const { resolveSandboxContext } = await import("./sandbox.js"); + + const cfg: ClawdbotConfig = { + agent: { + sandbox: { + mode: "all", + scope: "agent", + docker: { + image: "global-image", + network: "none", + }, + }, + }, + routing: { + agents: { + work: { + workspace: "~/clawd-work", + sandbox: { + mode: "all", + scope: "agent", + docker: { + image: "work-image", + network: "bridge", + }, + }, + }, + }, + }, + }; + + const context = await resolveSandboxContext({ + config: cfg, + sessionKey: "agent:work:main", + workspaceDir: "/tmp/test-work", + }); + + expect(context).toBeDefined(); + expect(context?.docker.image).toBe("work-image"); + expect(context?.docker.network).toBe("bridge"); }); it("should override with agent-specific sandbox mode 'off'", async () => { diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 6f2542963..7b3c6cf7e 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -246,6 +246,9 @@ function defaultSandboxConfig( perSession: agentSandbox?.perSession ?? agent?.perSession, }); + const globalDocker = agent?.docker; + const agentDocker = scope === "shared" ? undefined : agentSandbox?.docker; + return { mode: agentSandbox?.mode ?? agent?.mode ?? "off", scope, @@ -256,29 +259,39 @@ function defaultSandboxConfig( agent?.workspaceRoot ?? DEFAULT_SANDBOX_WORKSPACE_ROOT, docker: { - image: agent?.docker?.image ?? DEFAULT_SANDBOX_IMAGE, + image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE, containerPrefix: - agent?.docker?.containerPrefix ?? DEFAULT_SANDBOX_CONTAINER_PREFIX, - workdir: agent?.docker?.workdir ?? DEFAULT_SANDBOX_WORKDIR, - readOnlyRoot: agent?.docker?.readOnlyRoot ?? true, - tmpfs: agent?.docker?.tmpfs ?? ["/tmp", "/var/tmp", "/run"], - network: agent?.docker?.network ?? "none", - user: agent?.docker?.user, - capDrop: agent?.docker?.capDrop ?? ["ALL"], - env: agent?.docker?.env ?? { LANG: "C.UTF-8" }, - setupCommand: - scope === "shared" - ? agent?.docker?.setupCommand - : (agentSandbox?.docker?.setupCommand ?? agent?.docker?.setupCommand), - pidsLimit: agent?.docker?.pidsLimit, - memory: agent?.docker?.memory, - memorySwap: agent?.docker?.memorySwap, - cpus: agent?.docker?.cpus, - ulimits: agent?.docker?.ulimits, - seccompProfile: agent?.docker?.seccompProfile, - apparmorProfile: agent?.docker?.apparmorProfile, - dns: agent?.docker?.dns, - extraHosts: agent?.docker?.extraHosts, + agentDocker?.containerPrefix ?? + globalDocker?.containerPrefix ?? + DEFAULT_SANDBOX_CONTAINER_PREFIX, + workdir: + agentDocker?.workdir ?? + globalDocker?.workdir ?? + DEFAULT_SANDBOX_WORKDIR, + readOnlyRoot: + agentDocker?.readOnlyRoot ?? globalDocker?.readOnlyRoot ?? true, + tmpfs: agentDocker?.tmpfs ?? + globalDocker?.tmpfs ?? ["/tmp", "/var/tmp", "/run"], + network: agentDocker?.network ?? globalDocker?.network ?? "none", + user: agentDocker?.user ?? globalDocker?.user, + capDrop: agentDocker?.capDrop ?? globalDocker?.capDrop ?? ["ALL"], + env: agentDocker?.env + ? { ...(globalDocker?.env ?? { LANG: "C.UTF-8" }), ...agentDocker.env } + : (globalDocker?.env ?? { LANG: "C.UTF-8" }), + setupCommand: agentDocker?.setupCommand ?? globalDocker?.setupCommand, + pidsLimit: agentDocker?.pidsLimit ?? globalDocker?.pidsLimit, + memory: agentDocker?.memory ?? globalDocker?.memory, + memorySwap: agentDocker?.memorySwap ?? globalDocker?.memorySwap, + cpus: agentDocker?.cpus ?? globalDocker?.cpus, + ulimits: agentDocker?.ulimits + ? { ...globalDocker?.ulimits, ...agentDocker.ulimits } + : globalDocker?.ulimits, + seccompProfile: + agentDocker?.seccompProfile ?? globalDocker?.seccompProfile, + apparmorProfile: + agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile, + dns: agentDocker?.dns ?? globalDocker?.dns, + extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts, }, browser: { enabled: agent?.browser?.enabled ?? false, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index b1027bfc3..5dfb6832a 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -63,6 +63,50 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } +type SandboxScope = "session" | "agent" | "shared"; + +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 hasDockerOverrides(docker?: unknown) { + if (!docker || typeof docker !== "object") return false; + return Object.values(docker).some((value) => value !== undefined); +} + +function collectSandboxSharedDockerOverrideWarnings(cfg: ClawdbotConfig) { + const globalSandbox = cfg.agent?.sandbox; + const agents = cfg.routing?.agents; + if (!agents) return []; + + const warnings: string[] = []; + for (const [agentId, agentCfg] of Object.entries(agents)) { + if (!agentCfg || typeof agentCfg !== "object") continue; + const agentSandbox = agentCfg.sandbox; + if (!agentSandbox || typeof agentSandbox !== "object") continue; + if (!hasDockerOverrides(agentSandbox.docker)) continue; + + const scope = resolveSandboxScope({ + scope: (agentSandbox.scope ?? globalSandbox?.scope) as SandboxScope, + perSession: agentSandbox.perSession ?? globalSandbox?.perSession, + }); + if (scope !== "shared") continue; + + warnings.push( + `- routing.agents.${agentId}.sandbox.docker.* is ignored when sandbox scope resolves to "shared" (single shared container).`, + ); + } + + return warnings; +} + function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string { const override = env.CLAWDIS_CONFIG_PATH?.trim(); if (override) return override; @@ -976,6 +1020,19 @@ export async function doctorCommand( await noteSecurityWarnings(cfg); + const sharedDockerOverrideWarnings = + collectSandboxSharedDockerOverrideWarnings(cfg); + if (sharedDockerOverrideWarnings.length > 0) { + note( + [ + ...sharedDockerOverrideWarnings, + "", + 'Fix: set scope to "agent"/"session", or move the docker config to agent.sandbox.docker (global).', + ].join("\n"), + "Sandbox", + ); + } + if ( options.nonInteractive !== true && process.platform === "linux" && diff --git a/src/config/types.ts b/src/config/types.ts index 3f7851edc..2c014168b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -581,6 +581,50 @@ export type QueueModeByProvider = { webchat?: QueueMode; }; +export type SandboxDockerSettings = { + /** Docker image to use for sandbox containers. */ + image?: string; + /** Prefix for sandbox container names. */ + containerPrefix?: string; + /** Container workdir mount path (default: /workspace). */ + workdir?: string; + /** Run container rootfs read-only. */ + readOnlyRoot?: boolean; + /** Extra tmpfs mounts for read-only containers. */ + tmpfs?: string[]; + /** Container network mode (bridge|none|custom). */ + network?: string; + /** Container user (uid:gid). */ + user?: string; + /** Drop Linux capabilities. */ + capDrop?: string[]; + /** Extra environment variables for sandbox exec. */ + env?: Record; + /** Optional setup command run once after container creation. */ + setupCommand?: string; + /** Limit container PIDs (0 = Docker default). */ + pidsLimit?: number; + /** Limit container memory (e.g. 512m, 2g, or bytes as number). */ + memory?: string | number; + /** Limit container memory swap (same format as memory). */ + memorySwap?: string | number; + /** Limit container CPU shares (e.g. 0.5, 1, 2). */ + cpus?: number; + /** + * Set ulimit values by name (e.g. nofile, nproc). + * Use "soft:hard" string, a number, or { soft, hard }. + */ + ulimits?: Record; + /** Seccomp profile (path or profile name). */ + seccompProfile?: string; + /** AppArmor profile name. */ + apparmorProfile?: string; + /** DNS servers (e.g. ["1.1.1.1", "8.8.8.8"]). */ + dns?: string[]; + /** Extra host mappings (e.g. ["api.local:10.0.0.2"]). */ + extraHosts?: string[]; +}; + export type GroupChatConfig = { mentionPatterns?: string[]; historyLimit?: number; @@ -618,10 +662,7 @@ export type RoutingConfig = { perSession?: boolean; workspaceRoot?: string; /** Docker-specific sandbox overrides for this agent. */ - docker?: { - /** Optional setup command run once after container creation. */ - setupCommand?: string; - }; + docker?: SandboxDockerSettings; /** Tool allow/deny policy for sandboxed sessions (deny wins). */ tools?: { allow?: string[]; @@ -1050,52 +1091,7 @@ export type ClawdbotConfig = { /** Root directory for sandbox workspaces. */ workspaceRoot?: string; /** Docker-specific sandbox settings. */ - docker?: { - /** Docker image to use for sandbox containers. */ - image?: string; - /** Prefix for sandbox container names. */ - containerPrefix?: string; - /** Container workdir mount path (default: /workspace). */ - workdir?: string; - /** Run container rootfs read-only. */ - readOnlyRoot?: boolean; - /** Extra tmpfs mounts for read-only containers. */ - tmpfs?: string[]; - /** Container network mode (bridge|none|custom). */ - network?: string; - /** Container user (uid:gid). */ - user?: string; - /** Drop Linux capabilities. */ - capDrop?: string[]; - /** Extra environment variables for sandbox exec. */ - env?: Record; - /** Optional setup command run once after container creation. */ - setupCommand?: string; - /** Limit container PIDs (0 = Docker default). */ - pidsLimit?: number; - /** Limit container memory (e.g. 512m, 2g, or bytes as number). */ - memory?: string | number; - /** Limit container memory swap (same format as memory). */ - memorySwap?: string | number; - /** Limit container CPU shares (e.g. 0.5, 1, 2). */ - cpus?: number; - /** - * Set ulimit values by name (e.g. nofile, nproc). - * Use "soft:hard" string, a number, or { soft, hard }. - */ - ulimits?: Record< - string, - string | number | { soft?: number; hard?: number } - >; - /** Seccomp profile (path or profile name). */ - seccompProfile?: string; - /** AppArmor profile name. */ - apparmorProfile?: string; - /** DNS servers (e.g. ["1.1.1.1", "8.8.8.8"]). */ - dns?: string[]; - /** Extra host mappings (e.g. ["api.local:10.0.0.2"]). */ - extraHosts?: string[]; - }; + docker?: SandboxDockerSettings; /** Optional sandboxed browser settings. */ browser?: { enabled?: boolean; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8412a8bb4..a58ae9695 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -224,6 +224,42 @@ const HeartbeatSchema = z }) .optional(); +const SandboxDockerSchema = z + .object({ + image: z.string().optional(), + containerPrefix: z.string().optional(), + workdir: z.string().optional(), + readOnlyRoot: z.boolean().optional(), + tmpfs: z.array(z.string()).optional(), + network: z.string().optional(), + user: z.string().optional(), + capDrop: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + setupCommand: z.string().optional(), + pidsLimit: z.number().int().positive().optional(), + memory: z.union([z.string(), z.number()]).optional(), + memorySwap: z.union([z.string(), z.number()]).optional(), + cpus: z.number().positive().optional(), + ulimits: z + .record( + z.string(), + z.union([ + z.string(), + z.number(), + z.object({ + soft: z.number().int().nonnegative().optional(), + hard: z.number().int().nonnegative().optional(), + }), + ]), + ) + .optional(), + seccompProfile: z.string().optional(), + apparmorProfile: z.string().optional(), + dns: z.array(z.string()).optional(), + extraHosts: z.array(z.string()).optional(), + }) + .optional(); + const RoutingSchema = z .object({ groupChat: GroupChatSchema, @@ -265,11 +301,7 @@ const RoutingSchema = z .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), - docker: z - .object({ - setupCommand: z.string().optional(), - }) - .optional(), + docker: SandboxDockerSchema, tools: z .object({ allow: z.array(z.string()).optional(), @@ -673,41 +705,7 @@ export const ClawdbotSchema = z.object({ .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), - docker: z - .object({ - image: z.string().optional(), - containerPrefix: z.string().optional(), - workdir: z.string().optional(), - readOnlyRoot: z.boolean().optional(), - tmpfs: z.array(z.string()).optional(), - network: z.string().optional(), - user: z.string().optional(), - capDrop: z.array(z.string()).optional(), - env: z.record(z.string(), z.string()).optional(), - setupCommand: z.string().optional(), - pidsLimit: z.number().int().positive().optional(), - memory: z.union([z.string(), z.number()]).optional(), - memorySwap: z.union([z.string(), z.number()]).optional(), - cpus: z.number().positive().optional(), - ulimits: z - .record( - z.string(), - z.union([ - z.string(), - z.number(), - z.object({ - soft: z.number().int().nonnegative().optional(), - hard: z.number().int().nonnegative().optional(), - }), - ]), - ) - .optional(), - seccompProfile: z.string().optional(), - apparmorProfile: z.string().optional(), - dns: z.array(z.string()).optional(), - extraHosts: z.array(z.string()).optional(), - }) - .optional(), + docker: SandboxDockerSchema, browser: z .object({ enabled: z.boolean().optional(),