Skip to content

Commit 00da318

Browse files
authored
fix: constrain wildcard subagent targets (#84357)
* fix subagent wildcard targets * add changelog for subagent wildcard fix
1 parent eea7170 commit 00da318

12 files changed

Lines changed: 212 additions & 10 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
2929

3030
- Agents: include bounded trajectory queued-writer diagnostics in `pi-trajectory-flush` timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev.
3131
- Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev.
32+
- Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant.
3233
- Agents: honor explicit `models.providers.<id>.timeoutSeconds` values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei.
3334
- Agents/subagents: skip stale embedded-run wake probes for dormant completion requesters, so late subagent completions go straight to requester-agent/direct handoff instead of producing `reason=no_active_run` queue noise. (#82964) Thanks @galiniliev.
3435
- CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030.

docs/gateway/config-agents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1092,7 +1092,7 @@ for provider examples and precedence.
10921092
- `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.
10931093
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
10941094
- `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.
1095-
- `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.
1095+
- `subagents.allowAgents`: allowlist of agent ids for explicit `sessions_spawn.agentId` targets (`["*"]` = any configured target; default: same agent only). Include the requester id when self-targeted `agentId` calls should be allowed.
10961096
- Sandbox inheritance guard: if the requester session is sandboxed, `sessions_spawn` rejects targets that would run unsandboxed.
10971097
- `subagents.requireAgentId`: when true, block `sessions_spawn` calls that omit `agentId` (forces explicit profile selection; default: false).
10981098

docs/gateway/config-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto
401401
```
402402

403403
- `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model.
404-
- `allowAgents`: default allowlist of target agent ids for `sessions_spawn` when the requester agent does not set its own `subagents.allowAgents` (`["*"]` = any; default: same agent only).
404+
- `allowAgents`: default allowlist of target agent ids for `sessions_spawn` when the requester agent does not set its own `subagents.allowAgents` (`["*"]` = any configured target; default: same agent only).
405405
- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout.
406406
- `announceTimeoutMs`: per-call timeout (milliseconds) for gateway `agent` announce delivery attempts. Default: `120000`. Transient retries can make the total announce wait longer than one configured timeout.
407407
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`.

docs/tools/subagents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ See [Configuration reference](/gateway/configuration-reference) and
340340
### Allowlist
341341

342342
<ParamField path="agents.list[].subagents.allowAgents" type="string[]">
343-
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.
343+
List of agent ids that can be targeted via explicit `agentId` (`["*"]` allows any configured target). 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.
344344
</ParamField>
345345
<ParamField path="agents.defaults.subagents.allowAgents" type="string[]">
346346
Default target-agent allowlist used when the requester agent does not set its own `subagents.allowAgents`.

src/agents/acp-spawn.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,6 +1230,78 @@ describe("spawnAcpDirect", () => {
12301230
expectAcceptedSpawn(result);
12311231
});
12321232

1233+
it("allows configured ACP harness ids when subagent allowlist contains wildcard", async () => {
1234+
replaceSpawnConfig({
1235+
...hoisted.state.cfg,
1236+
acp: {
1237+
...hoisted.state.cfg.acp,
1238+
allowedAgents: ["codex", "writer"],
1239+
},
1240+
agents: {
1241+
...hoisted.state.cfg.agents,
1242+
list: [
1243+
{
1244+
id: "main",
1245+
default: true,
1246+
subagents: {
1247+
allowAgents: ["*"],
1248+
},
1249+
},
1250+
],
1251+
},
1252+
});
1253+
1254+
const result = await spawnAcpDirect(
1255+
createSpawnRequest({
1256+
agentId: "writer",
1257+
}),
1258+
{
1259+
...createRequesterContext(),
1260+
agentSessionKey: "agent:main:subagent:parent",
1261+
},
1262+
);
1263+
1264+
expectAcceptedSpawn(result);
1265+
});
1266+
1267+
it("rejects unconfigured ACP harness ids when subagent allowlist contains wildcard", async () => {
1268+
replaceSpawnConfig({
1269+
...hoisted.state.cfg,
1270+
acp: {
1271+
...hoisted.state.cfg.acp,
1272+
allowedAgents: [],
1273+
},
1274+
agents: {
1275+
...hoisted.state.cfg.agents,
1276+
list: [
1277+
{
1278+
id: "main",
1279+
default: true,
1280+
subagents: {
1281+
allowAgents: ["*"],
1282+
},
1283+
},
1284+
],
1285+
},
1286+
});
1287+
1288+
const result = await spawnAcpDirect(
1289+
createSpawnRequest({
1290+
agentId: "writer",
1291+
}),
1292+
{
1293+
...createRequesterContext(),
1294+
agentSessionKey: "agent:main:subagent:parent",
1295+
},
1296+
);
1297+
1298+
const failed = expectFailedSpawn(result, "forbidden");
1299+
expect(failed.errorCode).toBe("subagent_policy");
1300+
expect(failed.error).toBe(
1301+
'agentId "writer" is not in the configured agent registry (allowed: main)',
1302+
);
1303+
});
1304+
12331305
it("rejects ACP spawns to agents outside the subagent allowlist", async () => {
12341306
replaceSpawnConfig({
12351307
...hoisted.state.cfg,

src/agents/acp-spawn.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ import {
7070
resolveAcpSpawnStreamLogPath,
7171
startAcpSpawnParentStreamRelay,
7272
} from "./acp-spawn-parent-stream.js";
73-
import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
73+
import { listAgentIds, resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
7474
import {
7575
findAcpUnsupportedInheritedToolAllow,
7676
findAcpUnsupportedInheritedToolDeny,
@@ -447,11 +447,41 @@ function resolveTargetAcpAgentId(params: {
447447

448448
function isExplicitlyAllowedAcpAgent(cfg: OpenClawConfig, agentId: string): boolean {
449449
return (cfg.acp?.allowedAgents ?? []).some((entry) => {
450+
if (entry.trim() === "*") {
451+
return true;
452+
}
450453
const normalized = normalizeOptionalAgentId(entry);
451-
return normalized === "*" || normalized === agentId;
454+
return normalized === agentId;
452455
});
453456
}
454457

458+
function resolveConfiguredAcpSubagentTargetIds(cfg: OpenClawConfig): string[] {
459+
const ids = new Set<string>(listAgentIds(cfg));
460+
for (const agent of cfg.agents?.list ?? []) {
461+
if (agent.runtime?.type !== "acp") {
462+
continue;
463+
}
464+
const acpAgent = normalizeOptionalAgentId(agent.runtime.acp?.agent);
465+
if (acpAgent) {
466+
ids.add(acpAgent);
467+
}
468+
}
469+
const defaultAgent = normalizeOptionalAgentId(cfg.acp?.defaultAgent);
470+
if (defaultAgent) {
471+
ids.add(defaultAgent);
472+
}
473+
for (const entry of cfg.acp?.allowedAgents ?? []) {
474+
if (entry.trim() === "*") {
475+
continue;
476+
}
477+
const id = normalizeOptionalAgentId(entry);
478+
if (id) {
479+
ids.add(id);
480+
}
481+
}
482+
return Array.from(ids);
483+
}
484+
455485
function normalizeOptionalAgentId(value: string | undefined | null): string | undefined {
456486
const trimmed = normalizeOptionalString(value) ?? "";
457487
if (!trimmed) {
@@ -801,6 +831,7 @@ function resolveAcpSubagentEnvelopeState(params: {
801831
allowAgents:
802832
resolveAgentConfig(params.cfg, requesterAgentId)?.subagents?.allowAgents ??
803833
params.cfg.agents?.defaults?.subagents?.allowAgents,
834+
configuredAgentIds: resolveConfiguredAcpSubagentTargetIds(params.cfg),
804835
});
805836
if (!targetPolicy.ok) {
806837
return {

src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,39 @@ describe("subagent spawn allowlist + sandbox guards", () => {
137137
expectChildSessionKey(result, /^agent:beta:subagent:/);
138138
});
139139

140-
it("allows any agent when allowlist contains *", async () => {
140+
it("allows configured agents when allowlist contains *", async () => {
141+
setConfig({
142+
agents: {
143+
list: [{ id: "main", subagents: { allowAgents: ["*"] } }, { id: "beta" }],
144+
},
145+
});
146+
const result = await spawn({ agentId: "beta" });
147+
expectStatus(result, "accepted");
148+
});
149+
150+
it("rejects unconfigured agent ids when allowlist contains *", async () => {
141151
setConfig({
142152
agents: {
143153
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
144154
},
145155
});
146156
const result = await spawn({ agentId: "beta" });
157+
expectStatus(result, "forbidden");
158+
expect(result.error ?? "").toBe(
159+
'agentId "beta" is not in the configured agent registry (allowed: main)',
160+
);
161+
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
162+
});
163+
164+
it("allows explicit unconfigured agent ids when allowlist also contains *", async () => {
165+
setConfig({
166+
agents: {
167+
list: [{ id: "main", subagents: { allowAgents: ["*", "beta"] } }],
168+
},
169+
});
170+
const result = await spawn({ agentId: "beta" });
147171
expectStatus(result, "accepted");
172+
expectChildSessionKey(result, /^agent:beta:subagent:/);
148173
});
149174

150175
it("normalizes allowlisted agent ids", async () => {

src/agents/subagent-spawn.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
1212
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
1313
import { normalizeOptionalString } from "../shared/string-coerce.js";
1414
import type { DeliveryContext } from "../utils/delivery-context.types.js";
15-
import { resolveAgentDir } from "./agent-scope-config.js";
15+
import { listAgentIds, resolveAgentDir } from "./agent-scope-config.js";
1616
import type { BootstrapContextMode } from "./bootstrap-files.js";
1717
import {
1818
inheritedToolAllowPatch,
@@ -93,6 +93,10 @@ export type {
9393

9494
export { decodeStrictBase64 };
9595

96+
function resolveConfiguredAgentIds(cfg: OpenClawConfig): string[] {
97+
return listAgentIds(cfg);
98+
}
99+
96100
type SubagentSpawnDeps = {
97101
callGateway: typeof callGateway;
98102
forkSessionFromParent: typeof forkSessionFromParent;
@@ -839,6 +843,7 @@ export async function spawnSubagentDirect(
839843
allowAgents:
840844
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
841845
cfg?.agents?.defaults?.subagents?.allowAgents,
846+
configuredAgentIds: resolveConfiguredAgentIds(cfg),
842847
});
843848
if (!targetPolicy.ok) {
844849
return {

src/agents/subagent-target-policy.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,59 @@ describe("subagent target policy", () => {
6464
allowedIds: ["planner"],
6565
});
6666
});
67+
68+
it("limits wildcard allowlists to configured agents plus the requester", () => {
69+
expect(
70+
resolveSubagentAllowedTargetIds({
71+
requesterAgentId: "main",
72+
allowAgents: ["*"],
73+
configuredAgentIds: ["planner", "checker"],
74+
}),
75+
).toEqual({
76+
allowAny: true,
77+
allowedIds: ["checker", "main", "planner"],
78+
});
79+
});
80+
81+
it("rejects wildcard targets outside the configured registry", () => {
82+
const result = resolveSubagentTargetPolicy({
83+
requesterAgentId: "main",
84+
targetAgentId: "bogus",
85+
requestedAgentId: "bogus",
86+
allowAgents: ["*"],
87+
configuredAgentIds: ["main", "planner"],
88+
});
89+
90+
expect(result.ok).toBe(false);
91+
if (result.ok) {
92+
throw new Error("Expected target policy to reject unconfigured wildcard target");
93+
}
94+
expect(result.allowedText).toBe("main, planner");
95+
expect(result.error).toBe(
96+
'agentId "bogus" is not in the configured agent registry (allowed: main, planner)',
97+
);
98+
});
99+
100+
it("preserves explicit targets when wildcard allowlists are mixed", () => {
101+
expect(
102+
resolveSubagentAllowedTargetIds({
103+
requesterAgentId: "main",
104+
allowAgents: ["*", "beta"],
105+
configuredAgentIds: ["main", "planner"],
106+
}),
107+
).toEqual({
108+
allowAny: true,
109+
allowedIds: ["beta", "main", "planner"],
110+
});
111+
112+
expect(
113+
resolveSubagentTargetPolicy({
114+
requesterAgentId: "main",
115+
targetAgentId: "beta",
116+
requestedAgentId: "beta",
117+
allowAgents: ["*", "beta"],
118+
configuredAgentIds: ["main", "planner"],
119+
}),
120+
).toEqual({ ok: true });
121+
});
67122
});

src/agents/subagent-target-policy.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export function resolveSubagentAllowedTargetIds(params: {
4343
const configuredIds = (params.configuredAgentIds ?? [])
4444
.map((id) => normalizeAgentId(id))
4545
.filter(Boolean);
46+
configuredIds.push(...policy.allowedIds);
47+
if (requesterAgentId) {
48+
configuredIds.push(requesterAgentId);
49+
}
4650
return {
4751
allowAny: true,
4852
allowedIds: Array.from(new Set(configuredIds)).toSorted((a, b) => a.localeCompare(b)),
@@ -59,6 +63,7 @@ export function resolveSubagentTargetPolicy(params: {
5963
targetAgentId: string;
6064
requestedAgentId?: string;
6165
allowAgents?: readonly string[];
66+
configuredAgentIds?: readonly string[];
6267
}): SubagentTargetPolicyResult {
6368
const requesterAgentId = normalizeAgentId(params.requesterAgentId);
6469
const targetAgentId = normalizeAgentId(params.targetAgentId);
@@ -69,11 +74,19 @@ export function resolveSubagentTargetPolicy(params: {
6974
const allowed = resolveSubagentAllowedTargetIds({
7075
requesterAgentId,
7176
allowAgents: params.allowAgents,
77+
configuredAgentIds: params.configuredAgentIds,
7278
});
73-
if (allowed.allowAny || allowed.allowedIds.includes(targetAgentId)) {
79+
if (allowed.allowedIds.includes(targetAgentId)) {
7480
return { ok: true };
7581
}
7682
const allowedText = allowed.allowedIds.length > 0 ? allowed.allowedIds.join(", ") : "none";
83+
if (allowed.allowAny) {
84+
return {
85+
ok: false,
86+
allowedText,
87+
error: `agentId "${targetAgentId}" is not in the configured agent registry (allowed: ${allowedText})`,
88+
};
89+
}
7790
return {
7891
ok: false,
7992
allowedText,

0 commit comments

Comments
 (0)