mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
feat(sandbox): block container namespace joins by default
This commit is contained in:
@@ -9,6 +9,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. (#25103) Thanks @steipete and @vincentkoc.
|
||||
- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
|
||||
|
||||
@@ -32,6 +32,7 @@ For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when requ
|
||||
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy.
|
||||
It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records).
|
||||
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
|
||||
It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins).
|
||||
It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`.
|
||||
It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions.
|
||||
It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable).
|
||||
|
||||
@@ -1017,7 +1017,9 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
|
||||
|
||||
**`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user.
|
||||
|
||||
**Containers default to `network: "none"`** — set to `"bridge"` if the agent needs outbound access.
|
||||
**Containers default to `network: "none"`** — set to `"bridge"` (or a custom bridge network) if the agent needs outbound access.
|
||||
`"host"` is blocked. `"container:<id>"` is blocked by default unless you explicitly set
|
||||
`sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass).
|
||||
|
||||
**Inbound attachments** are staged into `media/inbound/*` in the active workspace.
|
||||
|
||||
|
||||
@@ -138,6 +138,12 @@ scripts/sandbox-browser-setup.sh
|
||||
By default, sandbox containers run with **no network**.
|
||||
Override with `agents.defaults.sandbox.docker.network`.
|
||||
|
||||
Security defaults:
|
||||
|
||||
- `network: "host"` is blocked.
|
||||
- `network: "container:<id>"` is blocked by default (namespace join bypass risk).
|
||||
- Break-glass override: `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true`.
|
||||
|
||||
Docker installs and the containerized gateway live here:
|
||||
[Docker](/install/docker)
|
||||
|
||||
@@ -154,6 +160,7 @@ Paths:
|
||||
Common pitfalls:
|
||||
|
||||
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
|
||||
- `docker.network: "container:<id>"` requires `dangerouslyAllowContainerNamespaceJoin: true` and is break-glass only.
|
||||
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
|
||||
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
|
||||
- Sandbox exec does **not** inherit host `process.env`. Use
|
||||
|
||||
@@ -244,6 +244,7 @@ High-signal `checkId` values you will most likely see in real deployments (not e
|
||||
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
|
||||
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
|
||||
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
|
||||
@@ -299,8 +300,10 @@ schema:
|
||||
- `channels.mattermost.accounts.<accountId>.dangerouslyAllowNameMatching` (extension channel)
|
||||
- `agents.defaults.sandbox.docker.dangerouslyAllowReservedContainerTargets`
|
||||
- `agents.defaults.sandbox.docker.dangerouslyAllowExternalBindSources`
|
||||
- `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin`
|
||||
- `agents.list[<index>].sandbox.docker.dangerouslyAllowReservedContainerTargets`
|
||||
- `agents.list[<index>].sandbox.docker.dangerouslyAllowExternalBindSources`
|
||||
- `agents.list[<index>].sandbox.docker.dangerouslyAllowContainerNamespaceJoin`
|
||||
|
||||
## Reverse Proxy Configuration
|
||||
|
||||
|
||||
@@ -368,6 +368,8 @@ precedence, and troubleshooting.
|
||||
- `"rw"` mounts the agent workspace read/write at `/workspace`
|
||||
- Auto-prune: idle > 24h OR age > 7d
|
||||
- Network: `none` by default (explicitly opt-in if you need egress)
|
||||
- `host` is blocked.
|
||||
- `container:<id>` is blocked by default (namespace-join risk).
|
||||
- Default allow: `exec`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`
|
||||
|
||||
@@ -376,6 +378,9 @@ precedence, and troubleshooting.
|
||||
If you plan to install packages in `setupCommand`, note:
|
||||
|
||||
- Default `docker.network` is `"none"` (no egress).
|
||||
- `docker.network: "host"` is blocked.
|
||||
- `docker.network: "container:<id>"` is blocked by default.
|
||||
- Break-glass override: `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true`.
|
||||
- `readOnlyRoot: true` blocks package installs.
|
||||
- `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`).
|
||||
OpenClaw auto-recreates containers when `setupCommand` (or docker config) changes
|
||||
@@ -445,7 +450,8 @@ If you plan to install packages in `setupCommand`, note:
|
||||
|
||||
Hardening knobs live under `agents.defaults.sandbox.docker`:
|
||||
`network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`,
|
||||
`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`.
|
||||
`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`,
|
||||
`dangerouslyAllowContainerNamespaceJoin` (break-glass only).
|
||||
|
||||
Multi-agent: override `agents.defaults.sandbox.{docker,browser,prune}.*` per agent via `agents.list[].sandbox.{docker,browser,prune}.*`
|
||||
(ignored when `agents.defaults.sandbox.scope` / `agents.list[].sandbox.scope` is `"shared"`).
|
||||
|
||||
@@ -181,6 +181,12 @@ describe("buildSandboxCreateArgs", () => {
|
||||
cfg: createSandboxConfig({ network: "host" }),
|
||||
expected: /network mode "host" is blocked/,
|
||||
},
|
||||
{
|
||||
name: "network container namespace join",
|
||||
containerName: "openclaw-sbx-container-network",
|
||||
cfg: createSandboxConfig({ network: "container:peer" }),
|
||||
expected: /network mode "container:peer" is blocked by default/,
|
||||
},
|
||||
{
|
||||
name: "seccomp unconfined",
|
||||
containerName: "openclaw-sbx-seccomp",
|
||||
@@ -271,4 +277,18 @@ describe("buildSandboxCreateArgs", () => {
|
||||
});
|
||||
expect(args).toEqual(expect.arrayContaining(["-v", "/tmp/override:/workspace:rw"]));
|
||||
});
|
||||
|
||||
it("allows container namespace join with explicit dangerous override", () => {
|
||||
const cfg = createSandboxConfig({
|
||||
network: "container:peer",
|
||||
dangerouslyAllowContainerNamespaceJoin: true,
|
||||
});
|
||||
const args = buildSandboxCreateArgs({
|
||||
name: "openclaw-sbx-container-network-override",
|
||||
cfg,
|
||||
scopeKey: "main",
|
||||
createdAtMs: 1700000000000,
|
||||
});
|
||||
expect(args).toEqual(expect.arrayContaining(["--network", "container:peer"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import { readBrowserRegistry, updateBrowserRegistry } from "./registry.js";
|
||||
import { resolveSandboxAgentId, slugifySessionKey } from "./shared.js";
|
||||
import { isToolAllowed } from "./tool-policy.js";
|
||||
import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
|
||||
import { validateNetworkMode } from "./validate-sandbox-security.js";
|
||||
|
||||
const HOT_BROWSER_WINDOW_MS = 5 * 60 * 1000;
|
||||
const CDP_SOURCE_RANGE_ENV_KEY = "OPENCLAW_BROWSER_CDP_SOURCE_RANGE";
|
||||
@@ -107,14 +108,15 @@ async function ensureSandboxBrowserImage(image: string) {
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureDockerNetwork(network: string) {
|
||||
async function ensureDockerNetwork(
|
||||
network: string,
|
||||
opts?: { allowContainerNamespaceJoin?: boolean },
|
||||
) {
|
||||
validateNetworkMode(network, {
|
||||
allowContainerNamespaceJoin: opts?.allowContainerNamespaceJoin === true,
|
||||
});
|
||||
const normalized = network.trim().toLowerCase();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === "bridge" ||
|
||||
normalized === "none" ||
|
||||
normalized.startsWith("container:")
|
||||
) {
|
||||
if (!normalized || normalized === "bridge" || normalized === "none") {
|
||||
return;
|
||||
}
|
||||
const inspect = await execDocker(["network", "inspect", network], { allowFailure: true });
|
||||
@@ -216,7 +218,9 @@ export async function ensureSandboxBrowser(params: {
|
||||
if (noVncEnabled) {
|
||||
noVncPassword = generateNoVncPassword();
|
||||
}
|
||||
await ensureDockerNetwork(browserDockerCfg.network);
|
||||
await ensureDockerNetwork(browserDockerCfg.network, {
|
||||
allowContainerNamespaceJoin: browserDockerCfg.dangerouslyAllowContainerNamespaceJoin === true,
|
||||
});
|
||||
await ensureSandboxBrowserImage(browserImage);
|
||||
const args = buildSandboxCreateArgs({
|
||||
name: containerName,
|
||||
|
||||
@@ -95,6 +95,15 @@ export function resolveSandboxDockerConfig(params: {
|
||||
dns: agentDocker?.dns ?? globalDocker?.dns,
|
||||
extraHosts: agentDocker?.extraHosts ?? globalDocker?.extraHosts,
|
||||
binds: binds.length ? binds : undefined,
|
||||
dangerouslyAllowReservedContainerTargets:
|
||||
agentDocker?.dangerouslyAllowReservedContainerTargets ??
|
||||
globalDocker?.dangerouslyAllowReservedContainerTargets,
|
||||
dangerouslyAllowExternalBindSources:
|
||||
agentDocker?.dangerouslyAllowExternalBindSources ??
|
||||
globalDocker?.dangerouslyAllowExternalBindSources,
|
||||
dangerouslyAllowContainerNamespaceJoin:
|
||||
agentDocker?.dangerouslyAllowContainerNamespaceJoin ??
|
||||
globalDocker?.dangerouslyAllowContainerNamespaceJoin,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ export function buildSandboxCreateArgs(params: {
|
||||
bindSourceRoots?: string[];
|
||||
allowSourcesOutsideAllowedRoots?: boolean;
|
||||
allowReservedContainerTargets?: boolean;
|
||||
allowContainerNamespaceJoin?: boolean;
|
||||
}) {
|
||||
// Runtime security validation: blocks dangerous bind mounts, network modes, and profiles.
|
||||
validateSandboxSecurity({
|
||||
@@ -278,6 +279,9 @@ export function buildSandboxCreateArgs(params: {
|
||||
allowReservedContainerTargets:
|
||||
params.allowReservedContainerTargets ??
|
||||
params.cfg.dangerouslyAllowReservedContainerTargets === true,
|
||||
dangerouslyAllowContainerNamespaceJoin:
|
||||
params.allowContainerNamespaceJoin ??
|
||||
params.cfg.dangerouslyAllowContainerNamespaceJoin === true,
|
||||
});
|
||||
|
||||
const createdAtMs = params.createdAtMs ?? Date.now();
|
||||
|
||||
@@ -222,6 +222,30 @@ describe("validateNetworkMode", () => {
|
||||
expect(() => validateNetworkMode(testCase.mode), testCase.mode).toThrow(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks container namespace joins by default", () => {
|
||||
const cases = [
|
||||
{
|
||||
mode: "container:abc123",
|
||||
expected: /network mode "container:abc123" is blocked by default/,
|
||||
},
|
||||
{
|
||||
mode: "CONTAINER:ABC123",
|
||||
expected: /network mode "CONTAINER:ABC123" is blocked by default/,
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(() => validateNetworkMode(testCase.mode), testCase.mode).toThrow(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows container namespace joins with explicit dangerous override", () => {
|
||||
expect(() =>
|
||||
validateNetworkMode("container:abc123", {
|
||||
allowContainerNamespaceJoin: true,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSeccompProfile", () => {
|
||||
|
||||
@@ -42,6 +42,10 @@ export type ValidateBindMountsOptions = {
|
||||
allowReservedContainerTargets?: boolean;
|
||||
};
|
||||
|
||||
export type ValidateNetworkModeOptions = {
|
||||
allowContainerNamespaceJoin?: boolean;
|
||||
};
|
||||
|
||||
export type BlockedBindReason =
|
||||
| { kind: "targets"; blockedPath: string }
|
||||
| { kind: "covers"; blockedPath: string }
|
||||
@@ -276,14 +280,30 @@ export function validateBindMounts(
|
||||
}
|
||||
}
|
||||
|
||||
export function validateNetworkMode(network: string | undefined): void {
|
||||
if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) {
|
||||
export function validateNetworkMode(
|
||||
network: string | undefined,
|
||||
options?: ValidateNetworkModeOptions,
|
||||
): void {
|
||||
const normalized = network?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (BLOCKED_NETWORK_MODES.has(normalized)) {
|
||||
throw new Error(
|
||||
`Sandbox security: network mode "${network}" is blocked. ` +
|
||||
'Network "host" mode bypasses container network isolation. ' +
|
||||
'Use "bridge" or "none" instead.',
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.startsWith("container:") && options?.allowContainerNamespaceJoin !== true) {
|
||||
throw new Error(
|
||||
`Sandbox security: network mode "${network}" is blocked by default. ` +
|
||||
'Network "container:*" joins another container namespace and bypasses sandbox network isolation. ' +
|
||||
"Use a custom bridge network, or set dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSeccompProfile(profile: string | undefined): void {
|
||||
@@ -312,10 +332,13 @@ export function validateSandboxSecurity(
|
||||
network?: string;
|
||||
seccompProfile?: string;
|
||||
apparmorProfile?: string;
|
||||
dangerouslyAllowContainerNamespaceJoin?: boolean;
|
||||
} & ValidateBindMountsOptions,
|
||||
): void {
|
||||
validateBindMounts(cfg.binds, cfg);
|
||||
validateNetworkMode(cfg.network);
|
||||
validateNetworkMode(cfg.network, {
|
||||
allowContainerNamespaceJoin: cfg.dangerouslyAllowContainerNamespaceJoin === true,
|
||||
});
|
||||
validateSeccompProfile(cfg.seccompProfile);
|
||||
validateApparmorProfile(cfg.apparmorProfile);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,37 @@ describe("sandbox docker config", () => {
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects container namespace join by default", () => {
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
docker: {
|
||||
network: "container:peer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("allows container namespace join with explicit dangerous override", () => {
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
docker: {
|
||||
network: "container:peer",
|
||||
dangerouslyAllowContainerNamespaceJoin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects seccomp unconfined via Zod schema validation", () => {
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
@@ -219,4 +250,37 @@ describe("sandbox browser binds config", () => {
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects container namespace join in sandbox.browser config by default", () => {
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
browser: {
|
||||
network: "container:peer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("allows container namespace join in sandbox.browser config with explicit dangerous override", () => {
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
docker: {
|
||||
dangerouslyAllowContainerNamespaceJoin: true,
|
||||
},
|
||||
browser: {
|
||||
network: "container:peer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,11 @@ export type SandboxDockerSettings = {
|
||||
* (workspace + agent workspace roots).
|
||||
*/
|
||||
dangerouslyAllowExternalBindSources?: boolean;
|
||||
/**
|
||||
* Dangerous override: allow Docker `network: "container:<id>"` namespace joins.
|
||||
* Default behavior blocks container namespace joins to preserve sandbox isolation.
|
||||
*/
|
||||
dangerouslyAllowContainerNamespaceJoin?: boolean;
|
||||
};
|
||||
|
||||
export type SandboxBrowserSettings = {
|
||||
|
||||
@@ -126,6 +126,7 @@ export const SandboxDockerSchema = z
|
||||
binds: z.array(z.string()).optional(),
|
||||
dangerouslyAllowReservedContainerTargets: z.boolean().optional(),
|
||||
dangerouslyAllowExternalBindSources: z.boolean().optional(),
|
||||
dangerouslyAllowContainerNamespaceJoin: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
@@ -153,7 +154,8 @@ export const SandboxDockerSchema = z
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.network?.trim().toLowerCase() === "host") {
|
||||
const network = data.network?.trim().toLowerCase();
|
||||
if (network === "host") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["network"],
|
||||
@@ -161,6 +163,15 @@ export const SandboxDockerSchema = z
|
||||
'Sandbox security: network mode "host" is blocked. Use "bridge" or "none" instead.',
|
||||
});
|
||||
}
|
||||
if (network?.startsWith("container:") && data.dangerouslyAllowContainerNamespaceJoin !== true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["network"],
|
||||
message:
|
||||
'Sandbox security: network mode "container:*" is blocked by default. ' +
|
||||
"Use a custom bridge network, or set dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.",
|
||||
});
|
||||
}
|
||||
if (data.seccompProfile?.trim().toLowerCase() === "unconfined") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -464,6 +475,21 @@ export const AgentSandboxSchema = z
|
||||
prune: SandboxPruneSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
const browserNetwork = data.browser?.network?.trim().toLowerCase();
|
||||
if (
|
||||
browserNetwork?.startsWith("container:") &&
|
||||
data.docker?.dangerouslyAllowContainerNamespaceJoin !== true
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["browser", "network"],
|
||||
message:
|
||||
'Sandbox security: browser network mode "container:*" is blocked by default. ' +
|
||||
"Set sandbox.docker.dangerouslyAllowContainerNamespaceJoin=true only when you fully trust this runtime.",
|
||||
});
|
||||
}
|
||||
})
|
||||
.optional();
|
||||
|
||||
const CommonToolPolicyFields = {
|
||||
|
||||
@@ -830,13 +830,21 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu
|
||||
}
|
||||
|
||||
const network = typeof docker.network === "string" ? docker.network : undefined;
|
||||
if (network && network.trim().toLowerCase() === "host") {
|
||||
const normalizedNetwork = network?.trim().toLowerCase();
|
||||
if (normalizedNetwork === "host" || normalizedNetwork?.startsWith("container:")) {
|
||||
const modeLabel = normalizedNetwork === "host" ? '"host"' : `"${network}"`;
|
||||
const detail =
|
||||
normalizedNetwork === "host"
|
||||
? `${source}.network is "host" which bypasses container network isolation entirely.`
|
||||
: `${source}.network is ${modeLabel} which joins another container namespace and can bypass sandbox network isolation.`;
|
||||
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".`,
|
||||
title: "Dangerous network mode in sandbox config",
|
||||
detail,
|
||||
remediation:
|
||||
`Set ${source}.network to "bridge", "none", or a custom bridge network name.` +
|
||||
` Use ${source}.dangerouslyAllowContainerNamespaceJoin=true only as a break-glass override when you fully trust this runtime.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -855,6 +855,31 @@ describe("security audit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("flags container namespace join network mode in sandbox config", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
docker: {
|
||||
network: "container:peer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = await audit(cfg);
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "sandbox.dangerous_network_mode",
|
||||
severity: "critical",
|
||||
title: "Dangerous network mode in sandbox config",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("checks sandbox browser bridge-network restrictions", async () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user