OpenClaw: Docker container escape via unvalidated bind mount config injection
Description
OpenClaw is a personal AI assistant. Prior to version 2026.2.15, a configuration injection issue in the Docker tool sandbox could allow dangerous Docker options (bind mounts, host networking, unconfined profiles) to be applied, enabling container escape or host data access. OpenClaw 2026.2.15 blocks dangerous sandbox Docker settings and includes runtime enforcement when building docker create args; config-schema validation for network=host, seccompProfile=unconfined, apparmorProfile=unconfined; and security audit findings to surface dangerous sandbox docker config. As a workaround, do not configure agents.*.sandbox.docker.binds to mount system directories or Docker socket paths, keep agents.*.sandbox.docker.network at none (default) or bridge, and do not use unconfined for seccomp/AppArmor profiles.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.15 | 2026.2.15 |
Affected products
1Patches
1887b209db47ffix(security): harden sandbox docker config validation
11 files changed · +691 −6
CHANGELOG.md+1 −0 modified@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent. - Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras. - Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. - Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
src/agents/sandbox-create-args.e2e.test.ts+112 −3 modified@@ -94,7 +94,7 @@ describe("buildSandboxCreateArgs", () => { ); }); - it("emits -v flags for custom binds", () => { + it("emits -v flags for safe custom binds", () => { const cfg: SandboxDockerConfig = { image: "openclaw-sandbox:bookworm-slim", containerPrefix: "openclaw-sbx-", @@ -103,7 +103,7 @@ describe("buildSandboxCreateArgs", () => { tmpfs: [], network: "none", capDrop: [], - binds: ["/home/user/source:/source:rw", "/var/run/docker.sock:/var/run/docker.sock"], + binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"], }; const args = buildSandboxCreateArgs({ @@ -124,7 +124,116 @@ describe("buildSandboxCreateArgs", () => { } } expect(vFlags).toContain("/home/user/source:/source:rw"); - expect(vFlags).toContain("/var/run/docker.sock:/var/run/docker.sock"); + expect(vFlags).toContain("/var/data/myapp:/data:ro"); + }); + + it("throws on dangerous bind mounts (Docker socket)", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: ["/var/run/docker.sock:/var/run/docker.sock"], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-dangerous", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/blocked path/); + }); + + it("throws on dangerous bind mounts (parent path)", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + binds: ["/run:/run"], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-dangerous-parent", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/blocked path/); + }); + + it("throws on network host mode", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "host", + capDrop: [], + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-host", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/network mode "host" is blocked/); + }); + + it("throws on seccomp unconfined", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + seccompProfile: "unconfined", + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-seccomp", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/seccomp profile "unconfined" is blocked/); + }); + + it("throws on apparmor unconfined", () => { + const cfg: SandboxDockerConfig = { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: false, + tmpfs: [], + network: "none", + capDrop: [], + apparmorProfile: "unconfined", + }; + + expect(() => + buildSandboxCreateArgs({ + name: "openclaw-sbx-apparmor", + cfg, + scopeKey: "main", + createdAtMs: 1700000000000, + }), + ).toThrow(/apparmor profile "unconfined" is blocked/); }); it("omits -v flags when binds is empty or undefined", () => {
src/agents/sandbox/docker.ts+4 −0 modified@@ -111,6 +111,7 @@ import { computeSandboxConfigHash } from "./config-hash.js"; import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { readRegistry, updateRegistry } from "./registry.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; +import { validateSandboxSecurity } from "./validate-sandbox-security.js"; const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; @@ -240,6 +241,9 @@ export function buildSandboxCreateArgs(params: { labels?: Record<string, string>; configHash?: string; }) { + // Runtime security validation: blocks dangerous bind mounts, network modes, and profiles. + validateSandboxSecurity(params.cfg); + const createdAtMs = params.createdAtMs ?? Date.now(); const args = ["create", "--name", params.name]; args.push("--label", "openclaw.sandbox=1");
src/agents/sandbox/validate-sandbox-security.test.ts+146 −0 added@@ -0,0 +1,146 @@ +import { mkdtempSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + getBlockedBindReasonStringOnly, + validateBindMounts, + validateNetworkMode, + validateSeccompProfile, + validateApparmorProfile, + validateSandboxSecurity, +} from "./validate-sandbox-security.js"; + +describe("getBlockedBindReasonStringOnly", () => { + it("blocks ancestor mounts that would expose the Docker socket", () => { + expect(getBlockedBindReasonStringOnly("/run:/run")).toEqual( + expect.objectContaining({ kind: "covers" }), + ); + expect(getBlockedBindReasonStringOnly("/var/run:/var/run:ro")).toEqual( + expect.objectContaining({ kind: "covers" }), + ); + expect(getBlockedBindReasonStringOnly("/var:/var")).toEqual( + expect.objectContaining({ kind: "covers" }), + ); + }); +}); + +describe("validateBindMounts", () => { + it("allows legitimate project directory mounts", () => { + expect(() => + validateBindMounts([ + "/home/user/source:/source:rw", + "/home/user/projects:/projects:ro", + "/var/data/myapp:/data", + "/opt/myapp/config:/config:ro", + ]), + ).not.toThrow(); + }); + + it("allows undefined or empty binds", () => { + expect(() => validateBindMounts(undefined)).not.toThrow(); + expect(() => validateBindMounts([])).not.toThrow(); + }); + + it("blocks /etc mount", () => { + expect(() => validateBindMounts(["/etc/passwd:/mnt/passwd:ro"])).toThrow( + /blocked path "\/etc"/, + ); + }); + + it("blocks /proc mount", () => { + expect(() => validateBindMounts(["/proc:/proc:ro"])).toThrow(/blocked path "\/proc"/); + }); + + it("blocks Docker socket mounts (/var/run + /run)", () => { + expect(() => validateBindMounts(["/var/run/docker.sock:/var/run/docker.sock"])).toThrow( + /docker\.sock/, + ); + expect(() => validateBindMounts(["/run/docker.sock:/run/docker.sock"])).toThrow(/docker\.sock/); + }); + + it("blocks parent mounts that would expose the Docker socket", () => { + expect(() => validateBindMounts(["/run:/run"])).toThrow(/blocked path/); + expect(() => validateBindMounts(["/var/run:/var/run"])).toThrow(/blocked path/); + expect(() => validateBindMounts(["/var:/var"])).toThrow(/blocked path/); + }); + + it("blocks paths with .. traversal to dangerous directories", () => { + expect(() => validateBindMounts(["/home/user/../../etc/shadow:/mnt/shadow"])).toThrow( + /blocked path "\/etc"/, + ); + }); + + it("blocks paths with double slashes normalizing to dangerous dirs", () => { + expect(() => validateBindMounts(["//etc//passwd:/mnt/passwd"])).toThrow(/blocked path "\/etc"/); + }); + + it("blocks symlink escapes into blocked directories", () => { + const dir = mkdtempSync(join(tmpdir(), "openclaw-sbx-")); + const link = join(dir, "etc-link"); + symlinkSync("/etc", link); + expect(() => validateBindMounts([`${link}/passwd:/mnt/passwd:ro`])).toThrow(/blocked path/); + }); + + it("rejects non-absolute source paths (relative or named volumes)", () => { + expect(() => validateBindMounts(["../etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/); + expect(() => validateBindMounts(["etc/passwd:/mnt/passwd"])).toThrow(/non-absolute/); + expect(() => validateBindMounts(["myvol:/mnt"])).toThrow(/non-absolute/); + }); +}); + +describe("validateNetworkMode", () => { + it("allows bridge/none/custom/undefined", () => { + expect(() => validateNetworkMode("bridge")).not.toThrow(); + expect(() => validateNetworkMode("none")).not.toThrow(); + expect(() => validateNetworkMode("my-custom-network")).not.toThrow(); + expect(() => validateNetworkMode(undefined)).not.toThrow(); + }); + + it("blocks host mode (case-insensitive)", () => { + expect(() => validateNetworkMode("host")).toThrow(/network mode "host" is blocked/); + expect(() => validateNetworkMode("HOST")).toThrow(/network mode "HOST" is blocked/); + }); +}); + +describe("validateSeccompProfile", () => { + it("allows custom profile paths/undefined", () => { + expect(() => validateSeccompProfile("/tmp/seccomp.json")).not.toThrow(); + expect(() => validateSeccompProfile(undefined)).not.toThrow(); + }); + + it("blocks unconfined (case-insensitive)", () => { + expect(() => validateSeccompProfile("unconfined")).toThrow( + /seccomp profile "unconfined" is blocked/, + ); + expect(() => validateSeccompProfile("Unconfined")).toThrow( + /seccomp profile "Unconfined" is blocked/, + ); + }); +}); + +describe("validateApparmorProfile", () => { + it("allows named profile/undefined", () => { + expect(() => validateApparmorProfile("openclaw-sandbox")).not.toThrow(); + expect(() => validateApparmorProfile(undefined)).not.toThrow(); + }); + + it("blocks unconfined (case-insensitive)", () => { + expect(() => validateApparmorProfile("unconfined")).toThrow( + /apparmor profile "unconfined" is blocked/, + ); + }); +}); + +describe("validateSandboxSecurity", () => { + it("passes with safe config", () => { + expect(() => + validateSandboxSecurity({ + binds: ["/home/user/src:/src:rw"], + network: "none", + seccompProfile: "/tmp/seccomp.json", + apparmorProfile: "openclaw-sandbox", + }), + ).not.toThrow(); + }); +});
src/agents/sandbox/validate-sandbox-security.ts+208 −0 added@@ -0,0 +1,208 @@ +/** + * Sandbox security validation — blocks dangerous Docker configurations. + * + * Threat model: local-trusted config, but protect against foot-guns and config injection. + * Enforced at runtime when creating sandbox containers. + */ + +import { existsSync, realpathSync } from "node:fs"; +import { posix } from "node:path"; + +// Targeted denylist: host paths that should never be exposed inside sandbox containers. +// Exported for reuse in security audit collectors. +export const BLOCKED_HOST_PATHS = [ + "/etc", + "/private/etc", + "/proc", + "/sys", + "/dev", + "/root", + "/boot", + "/var/run/docker.sock", + "/private/var/run/docker.sock", + "/run/docker.sock", +]; + +const BLOCKED_NETWORK_MODES = new Set(["host"]); +const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]); +const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]); + +export type BlockedBindReason = + | { kind: "targets"; blockedPath: string } + | { kind: "covers"; blockedPath: string } + | { kind: "non_absolute"; sourcePath: string }; + +/** + * Parse the host/source path from a Docker bind mount string. + * Format: `source:target[:mode]` + */ +export function parseBindSourcePath(bind: string): string { + const trimmed = bind.trim(); + const firstColon = trimmed.indexOf(":"); + if (firstColon <= 0) { + // No colon or starts with colon — treat as source. + return trimmed; + } + return trimmed.slice(0, firstColon); +} + +/** + * Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`. + */ +export function normalizeHostPath(raw: string): string { + const trimmed = raw.trim(); + return posix.normalize(trimmed).replace(/\/+$/, "") || "/"; +} + +/** + * String-only blocked-path check (no filesystem I/O). + * Blocks: + * - binds that target blocked paths (equal or under) + * - binds that cover blocked paths (ancestor mounts like /run or /var) + * - non-absolute source paths (relative / volume names) because they are hard to validate safely + */ +export function getBlockedBindReasonStringOnly(bind: string): BlockedBindReason | null { + const sourceRaw = parseBindSourcePath(bind); + if (!sourceRaw.startsWith("/")) { + return { kind: "non_absolute", sourcePath: sourceRaw }; + } + + const normalized = normalizeHostPath(sourceRaw); + + for (const blocked of BLOCKED_HOST_PATHS) { + if (normalized === blocked || normalized.startsWith(blocked + "/")) { + return { kind: "targets", blockedPath: blocked }; + } + // Ancestor mounts: mounting /run exposes /run/docker.sock. + if (normalized === "/") { + return { kind: "covers", blockedPath: blocked }; + } + if (blocked.startsWith(normalized + "/")) { + return { kind: "covers", blockedPath: blocked }; + } + } + + return null; +} + +function tryRealpathAbsolute(path: string): string { + if (!path.startsWith("/")) { + return path; + } + if (!existsSync(path)) { + return path; + } + try { + // Use native when available (keeps platform semantics); normalize for prefix checks. + return normalizeHostPath(realpathSync.native(path)); + } catch { + return path; + } +} + +function formatBindBlockedError(params: { bind: string; reason: BlockedBindReason }): Error { + if (params.reason.kind === "non_absolute") { + return new Error( + `Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` + + `"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`, + ); + } + const verb = params.reason.kind === "covers" ? "covers" : "targets"; + return new Error( + `Sandbox security: bind mount "${params.bind}" ${verb} blocked path "${params.reason.blockedPath}". ` + + "Mounting system directories (or Docker socket paths) into sandbox containers is not allowed. " + + "Use project-specific paths instead (e.g. /home/user/myproject).", + ); +} + +/** + * Validate bind mounts — throws if any source path is dangerous. + * Includes a symlink/realpath pass when the source path exists. + */ +export function validateBindMounts(binds: string[] | undefined): void { + if (!binds?.length) { + return; + } + + for (const rawBind of binds) { + const bind = rawBind.trim(); + if (!bind) { + continue; + } + + // Fast string-only check (covers .., //, ancestor/descendant logic). + const blocked = getBlockedBindReasonStringOnly(bind); + if (blocked) { + throw formatBindBlockedError({ bind, reason: blocked }); + } + + // Symlink escape hardening: resolve existing absolute paths and re-check. + const sourceRaw = parseBindSourcePath(bind); + const sourceNormalized = normalizeHostPath(sourceRaw); + const sourceReal = tryRealpathAbsolute(sourceNormalized); + if (sourceReal !== sourceNormalized) { + for (const blockedPath of BLOCKED_HOST_PATHS) { + if (sourceReal === blockedPath || sourceReal.startsWith(blockedPath + "/")) { + throw formatBindBlockedError({ + bind, + reason: { kind: "targets", blockedPath }, + }); + } + if (sourceReal === "/") { + throw formatBindBlockedError({ + bind, + reason: { kind: "covers", blockedPath }, + }); + } + if (blockedPath.startsWith(sourceReal + "/")) { + throw formatBindBlockedError({ + bind, + reason: { kind: "covers", blockedPath }, + }); + } + } + } + } +} + +export function validateNetworkMode(network: string | undefined): void { + if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) { + throw new Error( + `Sandbox security: network mode "${network}" is blocked. ` + + 'Network "host" mode bypasses container network isolation. ' + + 'Use "bridge" or "none" instead.', + ); + } +} + +export function validateSeccompProfile(profile: string | undefined): void { + if (profile && BLOCKED_SECCOMP_PROFILES.has(profile.trim().toLowerCase())) { + throw new Error( + `Sandbox security: seccomp profile "${profile}" is blocked. ` + + "Disabling seccomp removes syscall filtering and weakens sandbox isolation. " + + "Use a custom seccomp profile file or omit this setting.", + ); + } +} + +export function validateApparmorProfile(profile: string | undefined): void { + if (profile && BLOCKED_APPARMOR_PROFILES.has(profile.trim().toLowerCase())) { + throw new Error( + `Sandbox security: apparmor profile "${profile}" is blocked. ` + + "Disabling AppArmor removes mandatory access controls and weakens sandbox isolation. " + + "Use a named AppArmor profile or omit this setting.", + ); + } +} + +export function validateSandboxSecurity(cfg: { + binds?: string[]; + network?: string; + seccompProfile?: string; + apparmorProfile?: string; +}): void { + validateBindMounts(cfg.binds); + validateNetworkMode(cfg.network); + validateSeccompProfile(cfg.seccompProfile); + validateApparmorProfile(cfg.apparmorProfile); +}
src/config/config.sandbox-docker.test.ts+48 −3 modified@@ -3,13 +3,13 @@ import { resolveSandboxBrowserConfig } from "../agents/sandbox/config.js"; import { validateConfigObject } from "./config.js"; describe("sandbox docker config", () => { - it("accepts binds array in sandbox.docker config", () => { + it("accepts safe binds array in sandbox.docker config", () => { const res = validateConfigObject({ agents: { defaults: { sandbox: { docker: { - binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"], + binds: ["/home/user/source:/source:rw", "/var/data/myapp:/data:ro"], }, }, }, @@ -29,15 +29,60 @@ describe("sandbox docker config", () => { expect(res.ok).toBe(true); if (res.ok) { expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([ - "/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw", + "/var/data/myapp:/data:ro", ]); expect(res.config.agents?.list?.[0]?.sandbox?.docker?.binds).toEqual([ "/home/user/projects:/projects:ro", ]); } }); + it("rejects network host mode via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + network: "host", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("rejects seccomp unconfined via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + seccompProfile: "unconfined", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + + it("rejects apparmor unconfined via Zod schema validation", () => { + const res = validateConfigObject({ + agents: { + defaults: { + sandbox: { + docker: { + apparmorProfile: "unconfined", + }, + }, + }, + }, + }); + expect(res.ok).toBe(false); + }); + it("rejects non-string values in binds array", () => { const res = validateConfigObject({ agents: {
src/config/zod-schema.agent-runtime.ts+28 −0 modified@@ -125,6 +125,34 @@ export const SandboxDockerSchema = z binds: z.array(z.string()).optional(), }) .strict() + .superRefine((data, ctx) => { + if (data.network?.trim().toLowerCase() === "host") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["network"], + message: + 'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.', + }); + } + if (data.seccompProfile?.trim().toLowerCase() === "unconfined") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["seccompProfile"], + message: + 'Sandbox security: seccomp profile "unconfined" is blocked. ' + + "Use a custom seccomp profile file or omit this setting.", + }); + } + if (data.apparmorProfile?.trim().toLowerCase() === "unconfined") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["apparmorProfile"], + message: + 'Sandbox security: apparmor profile "unconfined" is blocked. ' + + "Use a named AppArmor profile or omit this setting.", + }); + } + }) .optional(); export const SandboxBrowserSchema = z
src/security/audit-extra.sync.ts+99 −0 modified@@ -11,6 +11,7 @@ import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; +import { getBlockedBindReasonStringOnly } from "../agents/sandbox/validate-sandbox-security.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { resolveBrowserConfig } from "../browser/config.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -584,6 +585,104 @@ export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityA return findings; } +export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { + const findings: SecurityAuditFinding[] = []; + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + + const configs: Array<{ source: string; docker: Record<string, unknown> }> = []; + const defaultDocker = cfg.agents?.defaults?.sandbox?.docker; + if (defaultDocker && typeof defaultDocker === "object") { + configs.push({ + source: "agents.defaults.sandbox.docker", + docker: defaultDocker as Record<string, unknown>, + }); + } + for (const entry of agents) { + if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { + continue; + } + const agentDocker = entry.sandbox?.docker; + if (agentDocker && typeof agentDocker === "object") { + configs.push({ + source: `agents.list.${entry.id}.sandbox.docker`, + docker: agentDocker as Record<string, unknown>, + }); + } + } + + for (const { source, docker } of configs) { + const binds = Array.isArray(docker.binds) ? docker.binds : []; + for (const bind of binds) { + if (typeof bind !== "string") { + continue; + } + const blocked = getBlockedBindReasonStringOnly(bind); + if (!blocked) { + continue; + } + if (blocked.kind === "non_absolute") { + findings.push({ + checkId: "sandbox.bind_mount_non_absolute", + severity: "warn", + title: "Sandbox bind mount uses a non-absolute source path", + detail: + `${source}.binds contains "${bind}" which uses source path "${blocked.sourcePath}". ` + + "Non-absolute bind sources are hard to validate safely and may resolve unexpectedly.", + remediation: `Rewrite "${bind}" to use an absolute host path (for example: /home/user/project:/project:ro).`, + }); + continue; + } + const verb = blocked.kind === "covers" ? "covers" : "targets"; + findings.push({ + checkId: "sandbox.dangerous_bind_mount", + severity: "critical", + title: "Dangerous bind mount in sandbox config", + detail: + `${source}.binds contains "${bind}" which ${verb} blocked path "${blocked.blockedPath}". ` + + "This can expose host system directories or the Docker socket to sandbox containers.", + remediation: `Remove "${bind}" from ${source}.binds. Use project-specific paths instead.`, + }); + } + + const network = typeof docker.network === "string" ? docker.network : undefined; + if (network && network.trim().toLowerCase() === "host") { + findings.push({ + checkId: "sandbox.dangerous_network_mode", + severity: "critical", + title: "Network host mode in sandbox config", + detail: `${source}.network is "host" which bypasses container network isolation entirely.`, + remediation: `Set ${source}.network to "bridge" or "none".`, + }); + } + + const seccompProfile = + typeof docker.seccompProfile === "string" ? docker.seccompProfile : undefined; + if (seccompProfile && seccompProfile.trim().toLowerCase() === "unconfined") { + findings.push({ + checkId: "sandbox.dangerous_seccomp_profile", + severity: "critical", + title: "Seccomp unconfined in sandbox config", + detail: `${source}.seccompProfile is "unconfined" which disables syscall filtering.`, + remediation: `Remove ${source}.seccompProfile or use a custom seccomp profile file.`, + }); + } + + const apparmorProfile = + typeof docker.apparmorProfile === "string" ? docker.apparmorProfile : undefined; + if (apparmorProfile && apparmorProfile.trim().toLowerCase() === "unconfined") { + findings.push({ + checkId: "sandbox.dangerous_apparmor_profile", + severity: "critical", + title: "AppArmor unconfined in sandbox config", + detail: `${source}.apparmorProfile is "unconfined" which disables AppArmor enforcement.`, + remediation: `Remove ${source}.apparmorProfile or use a named AppArmor profile.`, + }); + } + } + + return findings; +} + export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const denyListRaw = cfg.gateway?.nodes?.denyCommands;
src/security/audit-extra.ts+1 −0 modified@@ -16,6 +16,7 @@ export { collectMinimalProfileOverrideFindings, collectModelHygieneFindings, collectNodeDenyCommandPatternFindings, + collectSandboxDangerousConfigFindings, collectSandboxDockerNoopFindings, collectSecretsInConfigFindings, collectSmallModelRiskFindings,
src/security/audit.test.ts+42 −0 modified@@ -486,6 +486,48 @@ describe("security audit", () => { expect(res.findings.some((f) => f.checkId === "sandbox.docker_config_mode_off")).toBe(false); }); + it("flags dangerous sandbox docker config (binds/network/seccomp/apparmor)", async () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + sandbox: { + mode: "all", + docker: { + binds: ["/etc/passwd:/mnt/passwd:ro", "/run:/run"], + network: "host", + seccompProfile: "unconfined", + apparmorProfile: "unconfined", + }, + }, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "sandbox.dangerous_bind_mount", severity: "critical" }), + expect.objectContaining({ + checkId: "sandbox.dangerous_network_mode", + severity: "critical", + }), + expect.objectContaining({ + checkId: "sandbox.dangerous_seccomp_profile", + severity: "critical", + }), + expect.objectContaining({ + checkId: "sandbox.dangerous_apparmor_profile", + severity: "critical", + }), + ]), + ); + }); + it("flags ineffective gateway.nodes.denyCommands entries", async () => { const cfg: OpenClawConfig = { gateway: {
src/security/audit.ts+2 −0 modified@@ -21,6 +21,7 @@ import { collectModelHygieneFindings, collectNodeDenyCommandPatternFindings, collectSmallModelRiskFindings, + collectSandboxDangerousConfigFindings, collectSandboxDockerNoopFindings, collectPluginsTrustFindings, collectSecretsInConfigFindings, @@ -621,6 +622,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu findings.push(...collectHooksHardeningFindings(cfg, env)); findings.push(...collectGatewayHttpSessionKeyOverrideFindings(cfg)); findings.push(...collectSandboxDockerNoopFindings(cfg)); + findings.push(...collectSandboxDangerousConfigFindings(cfg)); findings.push(...collectNodeDenyCommandPatternFindings(cfg)); findings.push(...collectMinimalProfileOverrideFindings(cfg)); findings.push(...collectSecretsInConfigFindings(cfg));
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-w235-x559-36mgghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27002ghsaADVISORY
- github.com/openclaw/openclaw/commit/887b209db47f1f9322fead241a1c0b043fd38339ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.15ghsax_refsource_MISCWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-w235-x559-36mgghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.