Skip to content

Commit 7679eb3

Browse files
authored
Subagents: restrict follow-up messaging scope (openclaw#46801)
* Subagents: restrict follow-up messaging scope * Subagents: cover foreign-session follow-up sends * Update CHANGELOG.md
1 parent 9e2eed2 commit 7679eb3

File tree

5 files changed

+107
-1
lines changed

5 files changed

+107
-1
lines changed

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
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
3030
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
3131
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
32+
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.
3233
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
3334
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
3435
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { OpenClawConfig } from "../config/config.js";
3+
import { sendControlledSubagentMessage } from "./subagent-control.js";
4+
5+
describe("sendControlledSubagentMessage", () => {
6+
it("rejects runs controlled by another session", async () => {
7+
const result = await sendControlledSubagentMessage({
8+
cfg: {
9+
channels: { whatsapp: { allowFrom: ["*"] } },
10+
} as OpenClawConfig,
11+
controller: {
12+
controllerSessionKey: "agent:main:subagent:leaf",
13+
callerSessionKey: "agent:main:subagent:leaf",
14+
callerIsSubagent: true,
15+
controlScope: "children",
16+
},
17+
entry: {
18+
runId: "run-foreign",
19+
childSessionKey: "agent:main:subagent:other",
20+
requesterSessionKey: "agent:main:main",
21+
requesterDisplayKey: "main",
22+
controllerSessionKey: "agent:main:subagent:other-parent",
23+
task: "foreign run",
24+
cleanup: "keep",
25+
createdAt: Date.now() - 5_000,
26+
startedAt: Date.now() - 4_000,
27+
endedAt: Date.now() - 1_000,
28+
outcome: { status: "ok" },
29+
},
30+
message: "continue",
31+
});
32+
33+
expect(result).toEqual({
34+
status: "forbidden",
35+
error: "Subagents can only control runs spawned from their own session.",
36+
});
37+
});
38+
});

src/agents/subagent-control.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,9 +686,24 @@ export async function steerControlledSubagentRun(params: {
686686

687687
export async function sendControlledSubagentMessage(params: {
688688
cfg: OpenClawConfig;
689+
controller: ResolvedSubagentController;
689690
entry: SubagentRunRecord;
690691
message: string;
691692
}) {
693+
const ownershipError = ensureControllerOwnsRun({
694+
controller: params.controller,
695+
entry: params.entry,
696+
});
697+
if (ownershipError) {
698+
return { status: "forbidden" as const, error: ownershipError };
699+
}
700+
if (params.controller.controlScope !== "children") {
701+
return {
702+
status: "forbidden" as const,
703+
error: "Leaf subagents cannot control other sessions.",
704+
};
705+
}
706+
692707
const targetSessionKey = params.entry.childSessionKey;
693708
const parsed = parseAgentSessionKey(targetSessionKey);
694709
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });

src/auto-reply/reply/commands-subagents/action-send.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ export async function handleSubagentsSendAction(
3737
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
3838
}
3939

40+
const controller = resolveCommandSubagentController(params, ctx.requesterKey);
41+
4042
if (steerRequested) {
41-
const controller = resolveCommandSubagentController(params, ctx.requesterKey);
4243
const result = await steerControlledSubagentRun({
4344
cfg: params.cfg,
4445
controller,
@@ -61,6 +62,7 @@ export async function handleSubagentsSendAction(
6162

6263
const result = await sendControlledSubagentMessage({
6364
cfg: params.cfg,
65+
controller,
6466
entry: targetResolution.entry,
6567
message,
6668
});
@@ -70,6 +72,9 @@ export async function handleSubagentsSendAction(
7072
if (result.status === "error") {
7173
return stopWithText(`⚠️ Subagent error: ${result.error} (run ${result.runId.slice(0, 8)}).`);
7274
}
75+
if (result.status === "forbidden") {
76+
return stopWithText(`⚠️ ${result.error ?? "send failed"}`);
77+
}
7378
return stopWithText(
7479
result.replyText ??
7580
`✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${result.runId.slice(0, 8)}).`,

src/auto-reply/reply/commands.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1887,6 +1887,53 @@ describe("handleCommands subagents", () => {
18871887
expect(waitCall).toBeDefined();
18881888
});
18891889

1890+
it("blocks leaf subagents from sending to explicitly-owned child sessions", async () => {
1891+
const leafKey = "agent:main:subagent:leaf";
1892+
const childKey = `${leafKey}:subagent:child`;
1893+
const storePath = path.join(testWorkspaceDir, "sessions-subagents-send-scope.json");
1894+
await updateSessionStore(storePath, (store) => {
1895+
store[leafKey] = {
1896+
sessionId: "leaf-session",
1897+
updatedAt: Date.now(),
1898+
spawnedBy: "agent:main:main",
1899+
subagentRole: "leaf",
1900+
subagentControlScope: "none",
1901+
};
1902+
store[childKey] = {
1903+
sessionId: "child-session",
1904+
updatedAt: Date.now(),
1905+
spawnedBy: leafKey,
1906+
subagentRole: "leaf",
1907+
subagentControlScope: "none",
1908+
};
1909+
});
1910+
addSubagentRunForTests({
1911+
runId: "run-child-send",
1912+
childSessionKey: childKey,
1913+
requesterSessionKey: leafKey,
1914+
requesterDisplayKey: leafKey,
1915+
task: "child follow-up target",
1916+
cleanup: "keep",
1917+
createdAt: Date.now() - 20_000,
1918+
startedAt: Date.now() - 20_000,
1919+
endedAt: Date.now() - 1_000,
1920+
outcome: { status: "ok" },
1921+
});
1922+
const cfg = {
1923+
commands: { text: true },
1924+
channels: { whatsapp: { allowFrom: ["*"] } },
1925+
session: { store: storePath },
1926+
} as OpenClawConfig;
1927+
const params = buildParams("/subagents send 1 continue with follow-up details", cfg);
1928+
params.sessionKey = leafKey;
1929+
1930+
const result = await handleCommands(params);
1931+
1932+
expect(result.shouldContinue).toBe(false);
1933+
expect(result.reply?.text).toContain("Leaf subagents cannot control other sessions.");
1934+
expect(callGatewayMock).not.toHaveBeenCalled();
1935+
});
1936+
18901937
it("steers subagents via /steer alias", async () => {
18911938
callGatewayMock.mockImplementation(async (opts: unknown) => {
18921939
const request = opts as { method?: string };

0 commit comments

Comments
 (0)