refactor(sandbox): unify scope + per-agent overrides

This commit is contained in:
Peter Steinberger
2026-01-08 01:17:49 +01:00
parent ad8b7c739b
commit 145fe1cec7
10 changed files with 343 additions and 140 deletions

View File

@@ -31,7 +31,7 @@
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first). - 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`. - 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. - 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.<agentId>.sandbox.docker.*` overrides for multi-agent gateways (ignored when `scope: "shared"`). - Sandbox: allow per-agent `routing.agents.<agentId>.sandbox.{docker,browser,prune}.*` 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: 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. - Tools: scope `process` sessions per agent to prevent cross-agent visibility.
- Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412. - Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412.

View File

@@ -340,6 +340,8 @@ Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside o
- `scope`: `"session"` | `"agent"` | `"shared"` - `scope`: `"session"` | `"agent"` | `"shared"`
- `workspaceRoot`: custom sandbox workspace root - `workspaceRoot`: custom sandbox workspace root
- `docker`: per-agent docker overrides (e.g. `image`, `network`, `env`, `setupCommand`, limits; ignored when `scope: "shared"`) - `docker`: per-agent docker overrides (e.g. `image`, `network`, `env`, `setupCommand`, limits; ignored when `scope: "shared"`)
- `browser`: per-agent sandboxed browser overrides (ignored when `scope: "shared"`)
- `prune`: per-agent sandbox pruning overrides (ignored when `scope: "shared"`)
- `tools`: per-agent sandbox tool policy (deny wins; overrides `agent.sandbox.tools`) - `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). - `tools`: per-agent tool restrictions (overrides `agent.tools`; applied before sandbox tool policy).
- `allow`: array of allowed tool names - `allow`: array of allowed tool names

View File

@@ -160,7 +160,7 @@ Hardening knobs live under `agent.sandbox.docker`:
`network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`, `network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`,
`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`. `seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`.
Multi-agent: override `agent.sandbox.docker.*` per agent via `routing.agents.<agentId>.sandbox.docker.*` Multi-agent: override `agent.sandbox.{docker,browser,prune}.*` per agent via `routing.agents.<agentId>.sandbox.{docker,browser,prune}.*`
(ignored when `agent.sandbox.scope` / `routing.agents.<agentId>.sandbox.scope` is `"shared"`). (ignored when `agent.sandbox.scope` / `routing.agents.<agentId>.sandbox.scope` is `"shared"`).
### Build the default sandbox image ### Build the default sandbox image

View File

@@ -150,11 +150,12 @@ routing.agents[id].sandbox.scope > agent.sandbox.scope
routing.agents[id].sandbox.workspaceRoot > agent.sandbox.workspaceRoot routing.agents[id].sandbox.workspaceRoot > agent.sandbox.workspaceRoot
routing.agents[id].sandbox.workspaceAccess > agent.sandbox.workspaceAccess routing.agents[id].sandbox.workspaceAccess > agent.sandbox.workspaceAccess
routing.agents[id].sandbox.docker.* > agent.sandbox.docker.* routing.agents[id].sandbox.docker.* > agent.sandbox.docker.*
routing.agents[id].sandbox.browser.* > agent.sandbox.browser.*
routing.agents[id].sandbox.prune.* > agent.sandbox.prune.*
``` ```
**Notes:** **Notes:**
- `routing.agents[id].sandbox.docker.*` overrides `agent.sandbox.docker.*` for that agent (ignored when sandbox scope resolves to `"shared"`). - `routing.agents[id].sandbox.{docker,browser,prune}.*` overrides `agent.sandbox.{docker,browser,prune}.*` 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 ### Tool Restrictions
The filtering order is: The filtering order is:

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
describe("sandbox config merges", () => {
it("resolves sandbox scope deterministically", async () => {
const { resolveSandboxScope } = await import("./sandbox.js");
expect(resolveSandboxScope({})).toBe("agent");
expect(resolveSandboxScope({ perSession: true })).toBe("session");
expect(resolveSandboxScope({ perSession: false })).toBe("shared");
expect(resolveSandboxScope({ perSession: true, scope: "agent" })).toBe(
"agent",
);
});
it("merges sandbox docker env and ulimits (agent wins)", async () => {
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
const resolved = resolveSandboxDockerConfig({
scope: "agent",
globalDocker: {
env: { LANG: "C.UTF-8", FOO: "1" },
ulimits: { nofile: { soft: 10, hard: 20 } },
},
agentDocker: {
env: { FOO: "2", BAR: "3" },
ulimits: { nproc: 256 },
},
});
expect(resolved.env).toEqual({ LANG: "C.UTF-8", FOO: "2", BAR: "3" });
expect(resolved.ulimits).toEqual({
nofile: { soft: 10, hard: 20 },
nproc: 256,
});
});
it("ignores agent docker overrides under shared scope", async () => {
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
const resolved = resolveSandboxDockerConfig({
scope: "shared",
globalDocker: { image: "global" },
agentDocker: { image: "agent" },
});
expect(resolved.image).toBe("global");
});
it("applies per-agent browser and prune overrides (ignored under shared scope)", async () => {
const { resolveSandboxBrowserConfig, resolveSandboxPruneConfig } =
await import("./sandbox.js");
const browser = resolveSandboxBrowserConfig({
scope: "agent",
globalBrowser: { enabled: false, headless: false, enableNoVnc: true },
agentBrowser: { enabled: true, headless: true, enableNoVnc: false },
});
expect(browser.enabled).toBe(true);
expect(browser.headless).toBe(true);
expect(browser.enableNoVnc).toBe(false);
const prune = resolveSandboxPruneConfig({
scope: "agent",
globalPrune: { idleHours: 24, maxAgeDays: 7 },
agentPrune: { idleHours: 0, maxAgeDays: 1 },
});
expect(prune).toEqual({ idleHours: 0, maxAgeDays: 1 });
const browserShared = resolveSandboxBrowserConfig({
scope: "shared",
globalBrowser: { enabled: false },
agentBrowser: { enabled: true },
});
expect(browserShared.enabled).toBe(false);
const pruneShared = resolveSandboxPruneConfig({
scope: "shared",
globalPrune: { idleHours: 24, maxAgeDays: 7 },
agentPrune: { idleHours: 0, maxAgeDays: 1 },
});
expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 });
});
});

View File

@@ -207,7 +207,7 @@ function isToolAllowed(policy: SandboxToolPolicy, name: string) {
return allow.includes(name.toLowerCase()); return allow.includes(name.toLowerCase());
} }
function resolveSandboxScope(params: { export function resolveSandboxScope(params: {
scope?: SandboxScope; scope?: SandboxScope;
perSession?: boolean; perSession?: boolean;
}): SandboxScope { }): SandboxScope {
@@ -218,6 +218,108 @@ function resolveSandboxScope(params: {
return "agent"; return "agent";
} }
export function resolveSandboxDockerConfig(params: {
scope: SandboxScope;
globalDocker?: Partial<SandboxDockerConfig>;
agentDocker?: Partial<SandboxDockerConfig>;
}): SandboxDockerConfig {
const agentDocker =
params.scope === "shared" ? undefined : params.agentDocker;
const globalDocker = params.globalDocker;
const env = agentDocker?.env
? { ...(globalDocker?.env ?? { LANG: "C.UTF-8" }), ...agentDocker.env }
: (globalDocker?.env ?? { LANG: "C.UTF-8" });
const ulimits = agentDocker?.ulimits
? { ...globalDocker?.ulimits, ...agentDocker.ulimits }
: globalDocker?.ulimits;
return {
image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE,
containerPrefix:
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,
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,
seccompProfile: agentDocker?.seccompProfile ?? globalDocker?.seccompProfile,
apparmorProfile:
agentDocker?.apparmorProfile ?? globalDocker?.apparmorProfile,
dns: agentDocker?.dns ?? globalDocker?.dns,
extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts,
};
}
export function resolveSandboxBrowserConfig(params: {
scope: SandboxScope;
globalBrowser?: Partial<SandboxBrowserConfig>;
agentBrowser?: Partial<SandboxBrowserConfig>;
}): SandboxBrowserConfig {
const agentBrowser =
params.scope === "shared" ? undefined : params.agentBrowser;
const globalBrowser = params.globalBrowser;
return {
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
image:
agentBrowser?.image ??
globalBrowser?.image ??
DEFAULT_SANDBOX_BROWSER_IMAGE,
containerPrefix:
agentBrowser?.containerPrefix ??
globalBrowser?.containerPrefix ??
DEFAULT_SANDBOX_BROWSER_PREFIX,
cdpPort:
agentBrowser?.cdpPort ??
globalBrowser?.cdpPort ??
DEFAULT_SANDBOX_BROWSER_CDP_PORT,
vncPort:
agentBrowser?.vncPort ??
globalBrowser?.vncPort ??
DEFAULT_SANDBOX_BROWSER_VNC_PORT,
noVncPort:
agentBrowser?.noVncPort ??
globalBrowser?.noVncPort ??
DEFAULT_SANDBOX_BROWSER_NOVNC_PORT,
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
enableNoVnc:
agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
};
}
export function resolveSandboxPruneConfig(params: {
scope: SandboxScope;
globalPrune?: Partial<SandboxPruneConfig>;
agentPrune?: Partial<SandboxPruneConfig>;
}): SandboxPruneConfig {
const agentPrune = params.scope === "shared" ? undefined : params.agentPrune;
const globalPrune = params.globalPrune;
return {
idleHours:
agentPrune?.idleHours ??
globalPrune?.idleHours ??
DEFAULT_SANDBOX_IDLE_HOURS,
maxAgeDays:
agentPrune?.maxAgeDays ??
globalPrune?.maxAgeDays ??
DEFAULT_SANDBOX_MAX_AGE_DAYS,
};
}
function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) { function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) {
const trimmed = sessionKey.trim() || "main"; const trimmed = sessionKey.trim() || "main";
if (scope === "shared") return "shared"; if (scope === "shared") return "shared";
@@ -226,7 +328,7 @@ function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) {
return `agent:${agentId}`; return `agent:${agentId}`;
} }
function defaultSandboxConfig( export function resolveSandboxConfigForAgent(
cfg?: ClawdbotConfig, cfg?: ClawdbotConfig,
agentId?: string, agentId?: string,
): SandboxConfig { ): SandboxConfig {
@@ -246,9 +348,6 @@ function defaultSandboxConfig(
perSession: agentSandbox?.perSession ?? agent?.perSession, perSession: agentSandbox?.perSession ?? agent?.perSession,
}); });
const globalDocker = agent?.docker;
const agentDocker = scope === "shared" ? undefined : agentSandbox?.docker;
return { return {
mode: agentSandbox?.mode ?? agent?.mode ?? "off", mode: agentSandbox?.mode ?? agent?.mode ?? "off",
scope, scope,
@@ -258,63 +357,27 @@ function defaultSandboxConfig(
agentSandbox?.workspaceRoot ?? agentSandbox?.workspaceRoot ??
agent?.workspaceRoot ?? agent?.workspaceRoot ??
DEFAULT_SANDBOX_WORKSPACE_ROOT, DEFAULT_SANDBOX_WORKSPACE_ROOT,
docker: { docker: resolveSandboxDockerConfig({
image: agentDocker?.image ?? globalDocker?.image ?? DEFAULT_SANDBOX_IMAGE, scope,
containerPrefix: globalDocker: agent?.docker,
agentDocker?.containerPrefix ?? agentDocker: agentSandbox?.docker,
globalDocker?.containerPrefix ?? }),
DEFAULT_SANDBOX_CONTAINER_PREFIX, browser: resolveSandboxBrowserConfig({
workdir: scope,
agentDocker?.workdir ?? globalBrowser: agent?.browser,
globalDocker?.workdir ?? agentBrowser: agentSandbox?.browser,
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,
image: agent?.browser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
containerPrefix:
agent?.browser?.containerPrefix ?? DEFAULT_SANDBOX_BROWSER_PREFIX,
cdpPort: agent?.browser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT,
vncPort: agent?.browser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT,
noVncPort:
agent?.browser?.noVncPort ?? DEFAULT_SANDBOX_BROWSER_NOVNC_PORT,
headless: agent?.browser?.headless ?? false,
enableNoVnc: agent?.browser?.enableNoVnc ?? true,
},
tools: { tools: {
allow: allow:
agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW, agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
deny: deny:
agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY, agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY,
}, },
prune: { prune: resolveSandboxPruneConfig({
idleHours: agent?.prune?.idleHours ?? DEFAULT_SANDBOX_IDLE_HOURS, scope,
maxAgeDays: agent?.prune?.maxAgeDays ?? DEFAULT_SANDBOX_MAX_AGE_DAYS, globalPrune: agent?.prune,
}, agentPrune: agentSandbox?.prune,
}),
}; };
} }
@@ -962,7 +1025,7 @@ export async function resolveSandboxContext(params: {
const rawSessionKey = params.sessionKey?.trim(); const rawSessionKey = params.sessionKey?.trim();
if (!rawSessionKey) return null; if (!rawSessionKey) return null;
const agentId = resolveAgentIdFromSessionKey(rawSessionKey); const agentId = resolveAgentIdFromSessionKey(rawSessionKey);
const cfg = defaultSandboxConfig(params.config, agentId); const cfg = resolveSandboxConfigForAgent(params.config, agentId);
const mainKey = params.config?.session?.mainKey?.trim() || "main"; const mainKey = params.config?.session?.mainKey?.trim() || "main";
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;
@@ -1025,7 +1088,7 @@ export async function ensureSandboxWorkspaceForSession(params: {
const rawSessionKey = params.sessionKey?.trim(); const rawSessionKey = params.sessionKey?.trim();
if (!rawSessionKey) return null; if (!rawSessionKey) return null;
const agentId = resolveAgentIdFromSessionKey(rawSessionKey); const agentId = resolveAgentIdFromSessionKey(rawSessionKey);
const cfg = defaultSandboxConfig(params.config, agentId); const cfg = resolveSandboxConfigForAgent(params.config, agentId);
const mainKey = params.config?.session?.mainKey?.trim() || "main"; const mainKey = params.config?.session?.mainKey?.trim() || "main";
if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null; if (!shouldSandboxSession(cfg, rawSessionKey, mainKey)) return null;

View File

@@ -25,6 +25,7 @@ afterEach(() => {
const readConfigFileSnapshot = vi.fn(); const readConfigFileSnapshot = vi.fn();
const confirm = vi.fn().mockResolvedValue(true); const confirm = vi.fn().mockResolvedValue(true);
const select = vi.fn().mockResolvedValue("node"); const select = vi.fn().mockResolvedValue("node");
const note = vi.fn();
const writeConfigFile = vi.fn().mockResolvedValue(undefined); const writeConfigFile = vi.fn().mockResolvedValue(undefined);
const migrateLegacyConfig = vi.fn((raw: unknown) => ({ const migrateLegacyConfig = vi.fn((raw: unknown) => ({
config: raw as Record<string, unknown>, config: raw as Record<string, unknown>,
@@ -74,7 +75,7 @@ const serviceUninstall = vi.fn().mockResolvedValue(undefined);
vi.mock("@clack/prompts", () => ({ vi.mock("@clack/prompts", () => ({
confirm, confirm,
intro: vi.fn(), intro: vi.fn(),
note: vi.fn(), note,
outro: vi.fn(), outro: vi.fn(),
select, select,
})); }));
@@ -413,6 +414,61 @@ describe("doctor", () => {
expect(docker.image).toBe("clawdbot-sandbox"); expect(docker.image).toBe("clawdbot-sandbox");
expect(docker.containerPrefix).toBe("clawdbot-sbx"); expect(docker.containerPrefix).toBe("clawdbot-sbx");
}); });
it("warns when per-agent sandbox docker/browser/prune overrides are ignored under shared scope", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {
agent: {
sandbox: {
mode: "all",
scope: "shared",
},
},
routing: {
agents: {
work: {
workspace: "~/clawd-work",
sandbox: {
mode: "all",
scope: "shared",
docker: {
setupCommand: "echo work",
},
},
},
},
},
},
issues: [],
legacyIssues: [],
});
note.mockClear();
const { doctorCommand } = await import("./doctor.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await doctorCommand(runtime, { nonInteractive: true });
expect(
note.mock.calls.some(
([message, title]) =>
title === "Sandbox" &&
typeof message === "string" &&
message.includes("routing.agents.work.sandbox") &&
message.includes('scope resolves to "shared"'),
),
).toBe(true);
});
it("falls back to legacy sandbox image when missing", async () => { it("falls back to legacy sandbox image when missing", async () => {
readConfigFileSnapshot.mockResolvedValue({ readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json", path: "/tmp/clawdbot.json",

View File

@@ -11,6 +11,7 @@ import {
DEFAULT_SANDBOX_BROWSER_IMAGE, DEFAULT_SANDBOX_BROWSER_IMAGE,
DEFAULT_SANDBOX_COMMON_IMAGE, DEFAULT_SANDBOX_COMMON_IMAGE,
DEFAULT_SANDBOX_IMAGE, DEFAULT_SANDBOX_IMAGE,
resolveSandboxScope,
} from "../agents/sandbox.js"; } from "../agents/sandbox.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js"; import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js";
@@ -63,25 +64,12 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local"; return cfg.gateway?.mode === "remote" ? "remote" : "local";
} }
type SandboxScope = "session" | "agent" | "shared"; function hasObjectOverrides(value?: unknown) {
if (!value || typeof value !== "object") return false;
function resolveSandboxScope(params: { return Object.values(value).some((entry) => entry !== undefined);
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) { function collectSandboxSharedOverrideWarnings(cfg: ClawdbotConfig) {
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 globalSandbox = cfg.agent?.sandbox;
const agents = cfg.routing?.agents; const agents = cfg.routing?.agents;
if (!agents) return []; if (!agents) return [];
@@ -91,16 +79,21 @@ function collectSandboxSharedDockerOverrideWarnings(cfg: ClawdbotConfig) {
if (!agentCfg || typeof agentCfg !== "object") continue; if (!agentCfg || typeof agentCfg !== "object") continue;
const agentSandbox = agentCfg.sandbox; const agentSandbox = agentCfg.sandbox;
if (!agentSandbox || typeof agentSandbox !== "object") continue; if (!agentSandbox || typeof agentSandbox !== "object") continue;
if (!hasDockerOverrides(agentSandbox.docker)) continue;
const hasOverrides =
hasObjectOverrides(agentSandbox.docker) ||
hasObjectOverrides(agentSandbox.browser) ||
hasObjectOverrides(agentSandbox.prune);
if (!hasOverrides) continue;
const scope = resolveSandboxScope({ const scope = resolveSandboxScope({
scope: (agentSandbox.scope ?? globalSandbox?.scope) as SandboxScope, scope: agentSandbox.scope ?? globalSandbox?.scope,
perSession: agentSandbox.perSession ?? globalSandbox?.perSession, perSession: agentSandbox.perSession ?? globalSandbox?.perSession,
}); });
if (scope !== "shared") continue; if (scope !== "shared") continue;
warnings.push( warnings.push(
`- routing.agents.${agentId}.sandbox.docker.* is ignored when sandbox scope resolves to "shared" (single shared container).`, `- routing.agents.${agentId}.sandbox.{docker,browser,prune}.* is ignored when sandbox scope resolves to "shared" (single shared container).`,
); );
} }
@@ -1020,14 +1013,13 @@ export async function doctorCommand(
await noteSecurityWarnings(cfg); await noteSecurityWarnings(cfg);
const sharedDockerOverrideWarnings = const sharedOverrideWarnings = collectSandboxSharedOverrideWarnings(cfg);
collectSandboxSharedDockerOverrideWarnings(cfg); if (sharedOverrideWarnings.length > 0) {
if (sharedDockerOverrideWarnings.length > 0) {
note( note(
[ [
...sharedDockerOverrideWarnings, ...sharedOverrideWarnings,
"", "",
'Fix: set scope to "agent"/"session", or move the docker config to agent.sandbox.docker (global).', 'Fix: set scope to "agent"/"session", or move the config to agent.sandbox.{docker,browser,prune} (global).',
].join("\n"), ].join("\n"),
"Sandbox", "Sandbox",
); );

View File

@@ -625,6 +625,24 @@ export type SandboxDockerSettings = {
extraHosts?: string[]; extraHosts?: string[];
}; };
export type SandboxBrowserSettings = {
enabled?: boolean;
image?: string;
containerPrefix?: string;
cdpPort?: number;
vncPort?: number;
noVncPort?: number;
headless?: boolean;
enableNoVnc?: boolean;
};
export type SandboxPruneSettings = {
/** Prune if idle for more than N hours (0 disables). */
idleHours?: number;
/** Prune if older than N days (0 disables). */
maxAgeDays?: number;
};
export type GroupChatConfig = { export type GroupChatConfig = {
mentionPatterns?: string[]; mentionPatterns?: string[];
historyLimit?: number; historyLimit?: number;
@@ -663,11 +681,15 @@ export type RoutingConfig = {
workspaceRoot?: string; workspaceRoot?: string;
/** Docker-specific sandbox overrides for this agent. */ /** Docker-specific sandbox overrides for this agent. */
docker?: SandboxDockerSettings; docker?: SandboxDockerSettings;
/** Optional sandboxed browser overrides for this agent. */
browser?: SandboxBrowserSettings;
/** Tool allow/deny policy for sandboxed sessions (deny wins). */ /** Tool allow/deny policy for sandboxed sessions (deny wins). */
tools?: { tools?: {
allow?: string[]; allow?: string[];
deny?: string[]; deny?: string[];
}; };
/** Auto-prune overrides for this agent. */
prune?: SandboxPruneSettings;
}; };
tools?: { tools?: {
allow?: string[]; allow?: string[];
@@ -1093,28 +1115,14 @@ export type ClawdbotConfig = {
/** Docker-specific sandbox settings. */ /** Docker-specific sandbox settings. */
docker?: SandboxDockerSettings; docker?: SandboxDockerSettings;
/** Optional sandboxed browser settings. */ /** Optional sandboxed browser settings. */
browser?: { browser?: SandboxBrowserSettings;
enabled?: boolean;
image?: string;
containerPrefix?: string;
cdpPort?: number;
vncPort?: number;
noVncPort?: number;
headless?: boolean;
enableNoVnc?: boolean;
};
/** Tool allow/deny policy (deny wins). */ /** Tool allow/deny policy (deny wins). */
tools?: { tools?: {
allow?: string[]; allow?: string[];
deny?: string[]; deny?: string[];
}; };
/** Auto-prune sandbox containers. */ /** Auto-prune sandbox containers. */
prune?: { prune?: SandboxPruneSettings;
/** Prune if idle for more than N hours (0 disables). */
idleHours?: number;
/** Prune if older than N days (0 disables). */
maxAgeDays?: number;
};
}; };
/** Global tool allow/deny policy for all providers (deny wins). */ /** Global tool allow/deny policy for all providers (deny wins). */
tools?: { tools?: {

View File

@@ -260,6 +260,33 @@ const SandboxDockerSchema = z
}) })
.optional(); .optional();
const SandboxBrowserSchema = z
.object({
enabled: z.boolean().optional(),
image: z.string().optional(),
containerPrefix: z.string().optional(),
cdpPort: z.number().int().positive().optional(),
vncPort: z.number().int().positive().optional(),
noVncPort: z.number().int().positive().optional(),
headless: z.boolean().optional(),
enableNoVnc: z.boolean().optional(),
})
.optional();
const SandboxPruneSchema = z
.object({
idleHours: z.number().int().nonnegative().optional(),
maxAgeDays: z.number().int().nonnegative().optional(),
})
.optional();
const ToolPolicySchema = z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional();
const RoutingSchema = z const RoutingSchema = z
.object({ .object({
groupChat: GroupChatSchema, groupChat: GroupChatSchema,
@@ -302,20 +329,12 @@ const RoutingSchema = z
perSession: z.boolean().optional(), perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(), workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema, docker: SandboxDockerSchema,
tools: z browser: SandboxBrowserSchema,
.object({ tools: ToolPolicySchema,
allow: z.array(z.string()).optional(), prune: SandboxPruneSchema,
deny: z.array(z.string()).optional(),
})
.optional(),
})
.optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
}) })
.optional(), .optional(),
tools: ToolPolicySchema,
}) })
.optional(), .optional(),
) )
@@ -706,30 +725,9 @@ export const ClawdbotSchema = z.object({
perSession: z.boolean().optional(), perSession: z.boolean().optional(),
workspaceRoot: z.string().optional(), workspaceRoot: z.string().optional(),
docker: SandboxDockerSchema, docker: SandboxDockerSchema,
browser: z browser: SandboxBrowserSchema,
.object({ tools: ToolPolicySchema,
enabled: z.boolean().optional(), prune: SandboxPruneSchema,
image: z.string().optional(),
containerPrefix: z.string().optional(),
cdpPort: z.number().int().positive().optional(),
vncPort: z.number().int().positive().optional(),
noVncPort: z.number().int().positive().optional(),
headless: z.boolean().optional(),
enableNoVnc: z.boolean().optional(),
})
.optional(),
tools: z
.object({
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.optional(),
prune: z
.object({
idleHours: z.number().int().nonnegative().optional(),
maxAgeDays: z.number().int().nonnegative().optional(),
})
.optional(),
}) })
.optional(), .optional(),
}) })