fix(subagents): enforce explicit spawn allowlists

This commit is contained in:
Peter Steinberger
2026-04-27 14:53:10 +01:00
parent 58a4ca4423
commit 2e99c1d227
11 changed files with 327 additions and 71 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/subagents: enforce `subagents.allowAgents` for explicit same-agent `sessions_spawn(agentId=...)` calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio.
- ACP/sessions_spawn: let explicit `sessions_spawn(runtime="acp")` bootstrap turns run while `acp.dispatch.enabled=false` still blocks automatic ACP thread dispatch. Fixes #63591. Thanks @moeedahmed.
- Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet.
- Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20.

View File

@@ -979,7 +979,7 @@ for provider examples and precedence.
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
- `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).
- `subagents.allowAgents`: allowlist of agent ids for explicit `sessions_spawn.agentId` targets (`["*"]` = any; default: same agent only). Include the requester id when self-targeted `agentId` calls should be allowed.
- Sandbox inheritance guard: if the requester session is sandboxed, `sessions_spawn` rejects targets that would run unsandboxed.
- `subagents.requireAgentId`: when true, block `sessions_spawn` calls that omit `agentId` (forces explicit profile selection; default: false).

View File

@@ -244,7 +244,7 @@ See [Configuration reference](/gateway/configuration-reference) and
### Allowlist
<ParamField path="agents.list[].subagents.allowAgents" type="string[]">
List of agent ids that can be targeted via `agentId` (`["*"]` allows any). Default: only the requester agent.
List of agent ids that can be targeted via explicit `agentId` (`["*"]` allows any). Default: only the requester agent. If you set a list and still want the requester to spawn itself with `agentId`, include the requester id in the list.
</ParamField>
<ParamField path="agents.defaults.subagents.allowAgents" type="string[]">
Default target-agent allowlist used when the requester agent does not set its own `subagents.allowAgents`.

View File

@@ -1100,6 +1100,38 @@ describe("spawnAcpDirect", () => {
expect(failed.error).toContain("agentId is not allowed");
});
it("rejects explicit ACP self-targets when the subagent allowlist excludes the requester", async () => {
replaceSpawnConfig({
...hoisted.state.cfg,
acp: {
...hoisted.state.cfg.acp,
allowedAgents: ["codex", "writer"],
},
agents: {
defaults: {
subagents: {
allowAgents: ["writer"],
maxSpawnDepth: 2,
},
},
},
});
const result = await spawnAcpDirect(
createSpawnRequest({
agentId: "codex",
}),
{
...createRequesterContext(),
agentSessionKey: "agent:codex:subagent:parent",
},
);
const failed = expectFailedSpawn(result, "forbidden");
expect(failed.errorCode).toBe("subagent_policy");
expect(failed.error).toContain("agentId is not allowed");
});
it("spawns Matrix thread-bound ACP sessions from top-level room targets", async () => {
enableMatrixAcpThreadBindings();
hoisted.sessionBindingBindMock.mockImplementationOnce(

View File

@@ -82,6 +82,7 @@ import {
} from "./subagent-capabilities.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { countActiveRunsForSession, getSubagentRunByChildSessionKey } from "./subagent-registry.js";
import { resolveSubagentTargetPolicy } from "./subagent-target-policy.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
const log = createSubsystemLogger("agents/acp-spawn");
@@ -786,24 +787,18 @@ function resolveAcpSubagentEnvelopeState(params: {
};
}
if (params.targetAgentId !== requesterAgentId) {
const allowAgents =
const targetPolicy = resolveSubagentTargetPolicy({
requesterAgentId,
targetAgentId: params.targetAgentId,
requestedAgentId: params.requestedAgentId,
allowAgents:
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.allowAgents ??
params.cfg.agents?.defaults?.subagents?.allowAgents ??
[];
const allowAny = allowAgents.some((value) => value.trim() === "*");
const normalizedTargetId = normalizeOptionalLowercaseString(params.targetAgentId) ?? "";
const allowSet = new Set(
allowAgents
.filter((value) => value.trim() && value.trim() !== "*")
.map((value) => normalizeOptionalLowercaseString(normalizeAgentId(value)) ?? ""),
);
if (!allowAny && !allowSet.has(normalizedTargetId)) {
const allowedText = allowSet.size > 0 ? Array.from(allowSet).join(", ") : "none";
return {
error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`,
};
}
params.cfg.agents?.defaults?.subagents?.allowAgents,
});
if (!targetPolicy.ok) {
return {
error: targetPolicy.error,
};
}
const childCapabilities = resolveSubagentCapabilities({

View File

@@ -14,6 +14,7 @@ const hoisted = vi.hoisted(() => ({
pruneLegacyStoreKeysMock: vi.fn(),
registerSubagentRunMock: vi.fn(),
emitSessionLifecycleEventMock: vi.fn(),
resolveAgentConfigMock: vi.fn(),
configOverride: {} as Record<string, unknown>,
}));
@@ -46,7 +47,7 @@ describe("spawnSubagentDirect seam flow", () => {
pruneLegacyStoreKeysMock: hoisted.pruneLegacyStoreKeysMock,
registerSubagentRunMock: hoisted.registerSubagentRunMock,
emitSessionLifecycleEventMock: hoisted.emitSessionLifecycleEventMock,
resolveAgentConfig: () => undefined,
resolveAgentConfig: hoisted.resolveAgentConfigMock,
resolveSubagentSpawnModelSelection: () => "openai-codex/gpt-5.4",
resolveSandboxRuntimeStatus: () => ({ sandboxed: false }),
sessionStorePath: "/tmp/subagent-spawn-session-store.json",
@@ -61,6 +62,11 @@ describe("spawnSubagentDirect seam flow", () => {
hoisted.pruneLegacyStoreKeysMock.mockReset();
hoisted.registerSubagentRunMock.mockReset();
hoisted.emitSessionLifecycleEventMock.mockReset();
hoisted.resolveAgentConfigMock.mockReset();
hoisted.resolveAgentConfigMock.mockImplementation(
(cfg: { agents?: { list?: Array<{ id?: string }> } }, agentId: string) =>
cfg.agents?.list?.find((agent) => agent.id === agentId),
);
hoisted.configOverride = createConfigOverride();
installAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
@@ -76,6 +82,84 @@ describe("spawnSubagentDirect seam flow", () => {
);
});
it("rejects explicit same-agent targets when allowAgents excludes the requester", async () => {
hoisted.configOverride = createConfigOverride({
agents: {
defaults: {
workspace: os.tmpdir(),
},
list: [
{
id: "task-manager",
workspace: "/tmp/workspace-task-manager",
subagents: {
allowAgents: ["planner"],
},
},
{
id: "planner",
workspace: "/tmp/workspace-planner",
},
],
},
});
const result = await spawnSubagentDirect(
{
task: "spawn myself explicitly",
agentId: "task-manager",
},
{
agentSessionKey: "agent:task-manager:main",
},
);
expect(result).toMatchObject({
status: "forbidden",
error: "agentId is not allowed for sessions_spawn (allowed: planner)",
});
expect(hoisted.callGatewayMock).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "agent" }),
);
});
it("allows omitted agentId to default to requester even when allowAgents excludes requester", async () => {
hoisted.configOverride = createConfigOverride({
agents: {
defaults: {
workspace: os.tmpdir(),
},
list: [
{
id: "task-manager",
workspace: "/tmp/workspace-task-manager",
subagents: {
allowAgents: ["planner"],
},
},
{
id: "planner",
workspace: "/tmp/workspace-planner",
},
],
},
});
const result = await spawnSubagentDirect(
{
task: "spawn default target",
},
{
agentSessionKey: "agent:task-manager:main",
},
);
expect(result).toMatchObject({
status: "accepted",
childSessionKey: expect.stringMatching(/^agent:task-manager:subagent:/),
});
});
it("accepts a spawned run across session patching, runtime-model persistence, registry registration, and lifecycle emission", async () => {
const operations: string[] = [];
let persistedStore: Record<string, Record<string, unknown>> | undefined;

View File

@@ -8,10 +8,7 @@ import type { SubagentSpawnPreparation } from "../context-engine/types.js";
import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-registry-state.js";
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import type { BootstrapContextMode } from "./bootstrap-files.js";
import {
@@ -29,6 +26,7 @@ import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { buildSubagentInitialUserMessage } from "./subagent-initial-user-message.js";
import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js";
import { resolveSubagentSpawnAcceptedNote } from "./subagent-spawn-accepted-note.js";
import { resolveSubagentTargetPolicy } from "./subagent-target-policy.js";
export {
SUBAGENT_SPAWN_ACCEPTED_NOTE,
SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE,
@@ -744,25 +742,19 @@ export async function spawnSubagentDirect(
requesterGroupSpace: ctx.agentGroupSpace,
requesterMemberRoleIds: ctx.agentMemberRoleIds,
});
if (targetAgentId !== requesterAgentId) {
const allowAgents =
const targetPolicy = resolveSubagentTargetPolicy({
requesterAgentId,
targetAgentId,
requestedAgentId,
allowAgents:
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
cfg?.agents?.defaults?.subagents?.allowAgents ??
[];
const allowAny = allowAgents.some((value) => value.trim() === "*");
const normalizedTargetId = normalizeLowercaseStringOrEmpty(targetAgentId);
const allowSet = new Set(
allowAgents
.filter((value) => value.trim() && value.trim() !== "*")
.map((value) => normalizeLowercaseStringOrEmpty(normalizeAgentId(value))),
);
if (!allowAny && !allowSet.has(normalizedTargetId)) {
const allowedText = allowSet.size > 0 ? Array.from(allowSet).join(", ") : "none";
return {
status: "forbidden",
error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`,
};
}
cfg?.agents?.defaults?.subagents?.allowAgents,
});
if (!targetPolicy.ok) {
return {
status: "forbidden",
error: targetPolicy.error,
};
}
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
const requesterRuntime = resolveSandboxRuntimeStatus({

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
resolveSubagentAllowedTargetIds,
resolveSubagentTargetPolicy,
} from "./subagent-target-policy.js";
describe("subagent target policy", () => {
it("defaults to requester-only when no allowlist is configured", () => {
expect(
resolveSubagentTargetPolicy({
requesterAgentId: "main",
targetAgentId: "main",
requestedAgentId: "main",
}),
).toEqual({ ok: true });
expect(
resolveSubagentTargetPolicy({
requesterAgentId: "main",
targetAgentId: "other",
requestedAgentId: "other",
}),
).toMatchObject({ ok: false, allowedText: "main" });
});
it("keeps omitted agentId self-spawns allowed even when an allowlist is configured", () => {
expect(
resolveSubagentTargetPolicy({
requesterAgentId: "task-manager",
targetAgentId: "task-manager",
allowAgents: ["planner"],
}),
).toEqual({ ok: true });
});
it("rejects explicit self-targets when the configured allowlist excludes the requester", () => {
expect(
resolveSubagentTargetPolicy({
requesterAgentId: "task-manager",
targetAgentId: "task-manager",
requestedAgentId: "task-manager",
allowAgents: ["planner", "checker"],
}),
).toMatchObject({
ok: false,
allowedText: "checker, planner",
error: "agentId is not allowed for sessions_spawn (allowed: checker, planner)",
});
});
it("resolves allowed target ids without auto-adding requester for explicit allowlists", () => {
expect(
resolveSubagentAllowedTargetIds({
requesterAgentId: "main",
allowAgents: ["planner"],
configuredAgentIds: ["main", "planner"],
}),
).toEqual({
allowAny: false,
allowedIds: ["planner"],
});
});
});

View File

@@ -0,0 +1,84 @@
import { normalizeAgentId } from "../routing/session-key.js";
export type SubagentTargetPolicyResult =
| { ok: true }
| { ok: false; allowedText: string; error: string };
function normalizeAllowAgents(allowAgents: readonly string[] | undefined): {
configured: boolean;
allowAny: boolean;
allowedIds: string[];
} {
if (!Array.isArray(allowAgents)) {
return {
configured: false,
allowAny: false,
allowedIds: [],
};
}
const allowedIds = allowAgents
.map((value) => value.trim())
.filter((value) => value && value !== "*")
.map((value) => normalizeAgentId(value))
.filter(Boolean);
return {
configured: true,
allowAny: allowAgents.some((value) => value.trim() === "*"),
allowedIds: Array.from(new Set(allowedIds)).toSorted((a, b) => a.localeCompare(b)),
};
}
export function resolveSubagentAllowedTargetIds(params: {
requesterAgentId: string;
allowAgents?: readonly string[];
configuredAgentIds?: readonly string[];
}): { allowAny: boolean; allowedIds: string[] } {
const requesterAgentId = normalizeAgentId(params.requesterAgentId);
const policy = normalizeAllowAgents(params.allowAgents);
if (!policy.configured) {
return {
allowAny: false,
allowedIds: requesterAgentId ? [requesterAgentId] : [],
};
}
if (policy.allowAny) {
const configuredIds = (params.configuredAgentIds ?? [])
.map((id) => normalizeAgentId(id))
.filter(Boolean);
return {
allowAny: true,
allowedIds: Array.from(new Set(configuredIds)).toSorted((a, b) => a.localeCompare(b)),
};
}
return {
allowAny: false,
allowedIds: policy.allowedIds,
};
}
export function resolveSubagentTargetPolicy(params: {
requesterAgentId: string;
targetAgentId: string;
requestedAgentId?: string;
allowAgents?: readonly string[];
}): SubagentTargetPolicyResult {
const requesterAgentId = normalizeAgentId(params.requesterAgentId);
const targetAgentId = normalizeAgentId(params.targetAgentId);
if (!params.requestedAgentId?.trim() && targetAgentId === requesterAgentId) {
return { ok: true };
}
const allowed = resolveSubagentAllowedTargetIds({
requesterAgentId,
allowAgents: params.allowAgents,
});
if (allowed.allowAny || allowed.allowedIds.includes(targetAgentId)) {
return { ok: true };
}
const allowedText = allowed.allowedIds.length > 0 ? allowed.allowedIds.join(", ") : "none";
return {
ok: false,
allowedText,
error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`,
};
}

View File

@@ -47,12 +47,6 @@ describe("agents_list tool", () => {
expect(result.details).toMatchObject({
requester: "main",
agents: [
{
id: "main",
configured: true,
model: "anthropic/claude-opus-4.5",
agentRuntime: { id: "pi", source: "defaults" },
},
{
id: "codex",
name: "Codex",
@@ -64,6 +58,31 @@ describe("agents_list tool", () => {
});
});
it("returns requester as the only target when no subagent allowlist is configured", async () => {
loadConfigMock.mockReturnValue({
agents: {
list: [{ id: "main", default: true }, { id: "codex" }],
},
} satisfies OpenClawConfig);
const { createAgentsListTool } = await import("./agents-list-tool.js");
const result = await createAgentsListTool({ agentSessionKey: "agent:main:main" }).execute(
"call",
{},
);
expect(result.details).toMatchObject({
requester: "main",
allowAny: false,
agents: [
{
id: "main",
configured: true,
},
],
});
});
it("marks OPENCLAW_AGENT_RUNTIME as the effective runtime source", async () => {
vi.stubEnv("OPENCLAW_AGENT_RUNTIME", "codex");
loadConfigMock.mockReturnValue({

View File

@@ -11,6 +11,7 @@ import {
resolveAgentConfig,
resolveAgentEffectiveModelPrimary,
} from "../agent-scope.js";
import { resolveSubagentAllowedTargetIds } from "../subagent-target-policy.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult } from "./common.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
@@ -102,14 +103,7 @@ export function createAgentsListTool(opts?: {
const allowAgents =
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
cfg?.agents?.defaults?.subagents?.allowAgents ??
[];
const allowAny = allowAgents.some((value) => value.trim() === "*");
const allowSet = new Set(
allowAgents
.filter((value) => value.trim() && value.trim() !== "*")
.map((value) => normalizeAgentId(value)),
);
cfg?.agents?.defaults?.subagents?.allowAgents;
const configuredAgents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
const configuredIds = configuredAgents.map((entry) => normalizeAgentId(entry.id));
@@ -122,23 +116,16 @@ export function createAgentsListTool(opts?: {
configuredNameMap.set(normalizeAgentId(entry.id), name);
}
const allowed = new Set<string>();
allowed.add(requesterAgentId);
if (allowAny) {
for (const id of configuredIds) {
allowed.add(id);
}
} else {
for (const id of allowSet) {
allowed.add(id);
}
}
const all = Array.from(allowed);
const allowed = resolveSubagentAllowedTargetIds({
requesterAgentId,
allowAgents,
configuredAgentIds: configuredIds,
});
const all = allowed.allowedIds;
const rest = all
.filter((id) => id !== requesterAgentId)
.toSorted((a, b) => a.localeCompare(b));
const ordered = [requesterAgentId, ...rest];
const ordered = all.includes(requesterAgentId) ? [requesterAgentId, ...rest] : rest;
const agents: AgentListEntry[] = ordered.map((id) => {
const agentRuntime = resolveAgentRuntimeMetadata(cfg, id);
return {
@@ -152,7 +139,7 @@ export function createAgentsListTool(opts?: {
return jsonResult({
requester: requesterAgentId,
allowAny,
allowAny: allowed.allowAny,
agents,
});
},