diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c1dd4f9f16..e2b50510d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - 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) - 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. - 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`. +- 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. - 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. - 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. - 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. diff --git a/src/agents/subagent-control.test.ts b/src/agents/subagent-control.test.ts new file mode 100644 index 00000000000..fec77ad025b --- /dev/null +++ b/src/agents/subagent-control.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { sendControlledSubagentMessage } from "./subagent-control.js"; + +describe("sendControlledSubagentMessage", () => { + it("rejects runs controlled by another session", async () => { + const result = await sendControlledSubagentMessage({ + cfg: { + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig, + controller: { + controllerSessionKey: "agent:main:subagent:leaf", + callerSessionKey: "agent:main:subagent:leaf", + callerIsSubagent: true, + controlScope: "children", + }, + entry: { + runId: "run-foreign", + childSessionKey: "agent:main:subagent:other", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + controllerSessionKey: "agent:main:subagent:other-parent", + task: "foreign run", + cleanup: "keep", + createdAt: Date.now() - 5_000, + startedAt: Date.now() - 4_000, + endedAt: Date.now() - 1_000, + outcome: { status: "ok" }, + }, + message: "continue", + }); + + expect(result).toEqual({ + status: "forbidden", + error: "Subagents can only control runs spawned from their own session.", + }); + }); +}); diff --git a/src/agents/subagent-control.ts b/src/agents/subagent-control.ts index 528a84eebd3..6594e5c7877 100644 --- a/src/agents/subagent-control.ts +++ b/src/agents/subagent-control.ts @@ -686,9 +686,24 @@ export async function steerControlledSubagentRun(params: { export async function sendControlledSubagentMessage(params: { cfg: OpenClawConfig; + controller: ResolvedSubagentController; entry: SubagentRunRecord; message: string; }) { + const ownershipError = ensureControllerOwnsRun({ + controller: params.controller, + entry: params.entry, + }); + if (ownershipError) { + return { status: "forbidden" as const, error: ownershipError }; + } + if (params.controller.controlScope !== "children") { + return { + status: "forbidden" as const, + error: "Leaf subagents cannot control other sessions.", + }; + } + const targetSessionKey = params.entry.childSessionKey; const parsed = parseAgentSessionKey(targetSessionKey); const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId }); diff --git a/src/auto-reply/reply/commands-subagents/action-send.ts b/src/auto-reply/reply/commands-subagents/action-send.ts index 3e764e2a6bb..9414313b381 100644 --- a/src/auto-reply/reply/commands-subagents/action-send.ts +++ b/src/auto-reply/reply/commands-subagents/action-send.ts @@ -37,8 +37,9 @@ export async function handleSubagentsSendAction( return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`); } + const controller = resolveCommandSubagentController(params, ctx.requesterKey); + if (steerRequested) { - const controller = resolveCommandSubagentController(params, ctx.requesterKey); const result = await steerControlledSubagentRun({ cfg: params.cfg, controller, @@ -61,6 +62,7 @@ export async function handleSubagentsSendAction( const result = await sendControlledSubagentMessage({ cfg: params.cfg, + controller, entry: targetResolution.entry, message, }); @@ -70,6 +72,9 @@ export async function handleSubagentsSendAction( if (result.status === "error") { return stopWithText(`⚠️ Subagent error: ${result.error} (run ${result.runId.slice(0, 8)}).`); } + if (result.status === "forbidden") { + return stopWithText(`⚠️ ${result.error ?? "send failed"}`); + } return stopWithText( result.replyText ?? `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${result.runId.slice(0, 8)}).`, diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index f6d2d88f5ba..2d8e6458933 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1887,6 +1887,53 @@ describe("handleCommands subagents", () => { expect(waitCall).toBeDefined(); }); + it("blocks leaf subagents from sending to explicitly-owned child sessions", async () => { + const leafKey = "agent:main:subagent:leaf"; + const childKey = `${leafKey}:subagent:child`; + const storePath = path.join(testWorkspaceDir, "sessions-subagents-send-scope.json"); + await updateSessionStore(storePath, (store) => { + store[leafKey] = { + sessionId: "leaf-session", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + subagentRole: "leaf", + subagentControlScope: "none", + }; + store[childKey] = { + sessionId: "child-session", + updatedAt: Date.now(), + spawnedBy: leafKey, + subagentRole: "leaf", + subagentControlScope: "none", + }; + }); + addSubagentRunForTests({ + runId: "run-child-send", + childSessionKey: childKey, + requesterSessionKey: leafKey, + requesterDisplayKey: leafKey, + task: "child follow-up target", + cleanup: "keep", + createdAt: Date.now() - 20_000, + startedAt: Date.now() - 20_000, + endedAt: Date.now() - 1_000, + outcome: { status: "ok" }, + }); + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + } as OpenClawConfig; + const params = buildParams("/subagents send 1 continue with follow-up details", cfg); + params.sessionKey = leafKey; + + const result = await handleCommands(params); + + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Leaf subagents cannot control other sessions."); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + it("steers subagents via /steer alias", async () => { callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string };