From b9aa2d436b757d2bd96c8bbaec35dbc1edf4dfb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 01:10:39 +0000 Subject: [PATCH] fix(security): enforce sandbox inheritance for sessions_spawn --- CHANGELOG.md | 1 + docs/concepts/session-tool.md | 1 + docs/gateway/configuration-reference.md | 1 + docs/tools/subagents.md | 1 + ...subagents.sessions-spawn.allowlist.test.ts | 37 +++++++++++++++++++ src/agents/subagent-spawn.ts | 16 ++++++++ 6 files changed, 57 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f62929c1..05e9a49bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting. - Security/Node metadata policy: harden node platform classification against Unicode confusables and switch unknown platform defaults to a conservative allowlist that excludes `system.run`/`system.which` unless explicitly allowlisted, preventing metadata canonicalization drift from broadening node command permissions. Thanks @tdjackey for reporting. - Plugins/Discovery precedence: load bundled plugins before auto-discovered global extensions so bundled channel plugins win duplicate-ID resolution by default (explicit `plugins.load.paths` overrides remain highest precedence), with loader regression coverage. Landed from contributor PR #29710 by @Sid-Qin. Thanks @Sid-Qin. - Discord/Reconnect integrity: release Discord message listener lane immediately while preserving serialized handler execution, add HELLO-stall resume-first recovery with bounded fresh-identify fallback after repeated stalls, and extend lifecycle/listener regression coverage for forced reconnect scenarios. Landed from contributor PR #29508 by @cgdusek. Thanks @cgdusek. diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index bbd58d599..cc6da5c78 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -160,6 +160,7 @@ Parameters: Allowlist: - `agents.list[].subagents.allowAgents`: list of agent ids allowed via `agentId` (`["*"]` to allow any). Default: only the requester agent. +- Sandbox inheritance guard: if the requester session is sandboxed, `sessions_spawn` rejects targets that would run unsandboxed. Discovery: diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 7da57445b..00ef71c2d 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1207,6 +1207,7 @@ scripts/sandbox-browser-setup.sh # optional browser image - `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI. - `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`. - `subagents.allowAgents`: allowlist of agent ids for `sessions_spawn` (`["*"]` = any; default: same agent only). +- Sandbox inheritance guard: if the requester session is sandboxed, `sessions_spawn` rejects targets that would run unsandboxed. --- diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 67310b6fb..a91ca2e32 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -124,6 +124,7 @@ See [Configuration Reference](/gateway/configuration-reference) and [Slash comma Allowlist: - `agents.list[].subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent. +- Sandbox inheritance guard: if the requester session is sandboxed, `sessions_spawn` rejects targets that would run unsandboxed. Discovery: diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts index 2a64a0406..970764e14 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts @@ -154,4 +154,41 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { acceptedAt: 5200, }); }); + + it("forbids sandboxed cross-agent spawns that would unsandbox the child", async () => { + setSessionsSpawnConfigOverride({ + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + defaults: { + sandbox: { + mode: "all", + }, + }, + list: [ + { + id: "main", + subagents: { + allowAgents: ["research"], + }, + }, + { + id: "research", + sandbox: { + mode: "off", + }, + }, + ], + }, + }); + + const result = await executeSpawn("call11", "research"); + const details = result.details as { status?: string; error?: string }; + + expect(details.status).toBe("forbidden"); + expect(details.error).toContain("Sandboxed sessions cannot spawn unsandboxed subagents."); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 9624d09ae..92e3e3785 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -13,6 +13,7 @@ import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { AGENT_LANE_SUBAGENT } from "./lanes.js"; import { resolveSubagentSpawnModelSelection } from "./model-selection.js"; +import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; import { buildSubagentSystemPrompt } from "./subagent-announce.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js"; @@ -269,6 +270,21 @@ export async function spawnSubagentDirect( } } const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; + const requesterRuntime = resolveSandboxRuntimeStatus({ + cfg, + sessionKey: requesterInternalKey, + }); + const childRuntime = resolveSandboxRuntimeStatus({ + cfg, + sessionKey: childSessionKey, + }); + if (requesterRuntime.sandboxed && !childRuntime.sandboxed) { + return { + status: "forbidden", + error: + "Sandboxed sessions cannot spawn unsandboxed subagents. Set a sandboxed target agent or use the same agent runtime.", + }; + } const childDepth = callerDepth + 1; const spawnedByKey = requesterInternalKey; const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);