diff --git a/src/agents/sandbox/network-mode.ts b/src/agents/sandbox/network-mode.ts new file mode 100644 index 00000000000..6fe5ee6ac82 --- /dev/null +++ b/src/agents/sandbox/network-mode.ts @@ -0,0 +1,28 @@ +export type NetworkModeBlockReason = "host" | "container_namespace_join"; + +export function normalizeNetworkMode(network: string | undefined): string | undefined { + const normalized = network?.trim().toLowerCase(); + return normalized || undefined; +} + +export function getBlockedNetworkModeReason(params: { + network: string | undefined; + allowContainerNamespaceJoin?: boolean; +}): NetworkModeBlockReason | null { + const normalized = normalizeNetworkMode(params.network); + if (!normalized) { + return null; + } + if (normalized === "host") { + return "host"; + } + if (normalized.startsWith("container:") && params.allowContainerNamespaceJoin !== true) { + return "container_namespace_join"; + } + return null; +} + +export function isDangerousNetworkMode(network: string | undefined): boolean { + const normalized = normalizeNetworkMode(network); + return normalized === "host" || normalized?.startsWith("container:") === true; +} diff --git a/src/agents/sandbox/validate-sandbox-security.ts b/src/agents/sandbox/validate-sandbox-security.ts index 928459836c4..097f883f988 100644 --- a/src/agents/sandbox/validate-sandbox-security.ts +++ b/src/agents/sandbox/validate-sandbox-security.ts @@ -11,6 +11,7 @@ import { normalizeSandboxHostPath, resolveSandboxHostPathViaExistingAncestor, } from "./host-paths.js"; +import { getBlockedNetworkModeReason } from "./network-mode.js"; // Targeted denylist: host paths that should never be exposed inside sandbox containers. // Exported for reuse in security audit collectors. @@ -31,7 +32,6 @@ export const BLOCKED_HOST_PATHS = [ "/run/docker.sock", ]; -const BLOCKED_NETWORK_MODES = new Set(["host"]); const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]); const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]); const RESERVED_CONTAINER_TARGET_PATHS = ["/workspace", SANDBOX_AGENT_WORKSPACE_MOUNT]; @@ -284,12 +284,11 @@ export function validateNetworkMode( network: string | undefined, options?: ValidateNetworkModeOptions, ): void { - const normalized = network?.trim().toLowerCase(); - if (!normalized) { - return; - } - - if (BLOCKED_NETWORK_MODES.has(normalized)) { + const blockedReason = getBlockedNetworkModeReason({ + network, + allowContainerNamespaceJoin: options?.allowContainerNamespaceJoin, + }); + if (blockedReason === "host") { throw new Error( `Sandbox security: network mode "${network}" is blocked. ` + 'Network "host" mode bypasses container network isolation. ' + @@ -297,7 +296,7 @@ export function validateNetworkMode( ); } - if (normalized.startsWith("container:") && options?.allowContainerNamespaceJoin !== true) { + if (blockedReason === "container_namespace_join") { throw new Error( `Sandbox security: network mode "${network}" is blocked by default. ` + 'Network "container:*" joins another container namespace and bypasses sandbox network isolation. ' + diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 032a68e857b..71b24af01ef 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveSandboxBrowserConfig } from "../agents/sandbox/config.js"; +import { + resolveSandboxBrowserConfig, + resolveSandboxDockerConfig, +} from "../agents/sandbox/config.js"; import { validateConfigObject } from "./config.js"; describe("sandbox docker config", () => { @@ -84,6 +87,22 @@ describe("sandbox docker config", () => { expect(res.ok).toBe(true); }); + it("uses agent override precedence for dangerouslyAllowContainerNamespaceJoin", () => { + const inherited = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { dangerouslyAllowContainerNamespaceJoin: true }, + agentDocker: {}, + }); + expect(inherited.dangerouslyAllowContainerNamespaceJoin).toBe(true); + + const overridden = resolveSandboxDockerConfig({ + scope: "agent", + globalDocker: { dangerouslyAllowContainerNamespaceJoin: true }, + agentDocker: { dangerouslyAllowContainerNamespaceJoin: false }, + }); + expect(overridden.dangerouslyAllowContainerNamespaceJoin).toBe(false); + }); + it("rejects seccomp unconfined via Zod schema validation", () => { const res = validateConfigObject({ agents: { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index a0d464274fd..bac2c2dcae1 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -299,6 +299,10 @@ export const FIELD_HELP: Record = { "agents.defaults.sandbox.browser.network": "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.", "agents.list[].sandbox.browser.network": "Per-agent override for sandbox browser Docker network.", + "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "DANGEROUS break-glass override that allows sandbox Docker network mode container:. This joins another container namespace and weakens sandbox isolation.", + "agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Per-agent DANGEROUS override for container namespace joins in sandbox Docker network mode.", "agents.defaults.sandbox.browser.cdpSourceRange": "Optional CIDR allowlist for container-edge CDP ingress (for example 172.21.0.1/32).", "agents.list[].sandbox.browser.cdpSourceRange": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 397376f6e11..f1706d1af7d 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -405,6 +405,8 @@ export const FIELD_LABELS: Record = { "agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings", "agents.defaults.sandbox.browser.network": "Sandbox Browser Network", "agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range", + "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Sandbox Docker Allow Container Namespace Join", commands: "Commands", "commands.native": "Native Commands", "commands.nativeSkills": "Native Skill Commands", @@ -713,6 +715,8 @@ export const FIELD_LABELS: Record = { "Agent Heartbeat Suppress Tool Error Warnings", "agents.list[].sandbox.browser.network": "Agent Sandbox Browser Network", "agents.list[].sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Port Range", + "agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Agent Sandbox Docker Allow Container Namespace Join", "discovery.mdns.mode": "mDNS Discovery Mode", plugins: "Plugins", "plugins.enabled": "Enable Plugins", diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index ca559ce5e94..c477cc1743b 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import { AgentModelSchema } from "./zod-schema.agent-model.js"; import { @@ -154,8 +155,11 @@ export const SandboxDockerSchema = z } } } - const network = data.network?.trim().toLowerCase(); - if (network === "host") { + const blockedNetworkReason = getBlockedNetworkModeReason({ + network: data.network, + allowContainerNamespaceJoin: data.dangerouslyAllowContainerNamespaceJoin === true, + }); + if (blockedNetworkReason === "host") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["network"], @@ -163,7 +167,7 @@ export const SandboxDockerSchema = z 'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.', }); } - if (network?.startsWith("container:") && data.dangerouslyAllowContainerNamespaceJoin !== true) { + if (blockedNetworkReason === "container_namespace_join") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["network"], @@ -476,11 +480,11 @@ export const AgentSandboxSchema = z }) .strict() .superRefine((data, ctx) => { - const browserNetwork = data.browser?.network?.trim().toLowerCase(); - if ( - browserNetwork?.startsWith("container:") && - data.docker?.dangerouslyAllowContainerNamespaceJoin !== true - ) { + const blockedBrowserNetworkReason = getBlockedNetworkModeReason({ + network: data.browser?.network, + allowContainerNamespaceJoin: data.docker?.dangerouslyAllowContainerNamespaceJoin === true, + }); + if (blockedBrowserNetworkReason === "container_namespace_join") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["browser", "network"], diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 893d1afb8a0..daa60aed73f 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -3,6 +3,7 @@ import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; +import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/network-mode.js"; /** * Synchronous security audit collector functions. * @@ -830,8 +831,8 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu } const network = typeof docker.network === "string" ? docker.network : undefined; - const normalizedNetwork = network?.trim().toLowerCase(); - if (normalizedNetwork === "host" || normalizedNetwork?.startsWith("container:")) { + const normalizedNetwork = normalizeNetworkMode(network); + if (isDangerousNetworkMode(network)) { const modeLabel = normalizedNetwork === "host" ? '"host"' : `"${network}"`; const detail = normalizedNetwork === "host"