refactor(sandbox): centralize network mode policy helpers

This commit is contained in:
Peter Steinberger
2026-02-24 23:26:46 +00:00
parent 14b6eea6e3
commit 5552f9073f
7 changed files with 78 additions and 19 deletions

View File

@@ -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;
}

View File

@@ -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. ' +

View File

@@ -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: {

View File

@@ -299,6 +299,10 @@ export const FIELD_HELP: Record<string, string> = {
"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:<id>. 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":

View File

@@ -405,6 +405,8 @@ export const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
"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",

View File

@@ -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"],

View File

@@ -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"