Subagents: restrict follow-up messaging scope (#46801)

* Subagents: restrict follow-up messaging scope

* Subagents: cover foreign-session follow-up sends

* Update CHANGELOG.md
This commit is contained in:
Vincent Koc
2026-03-15 09:44:51 -07:00
committed by GitHub
parent 9e2eed211c
commit 7679eb3752
5 changed files with 107 additions and 1 deletions

View File

@@ -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.

View File

@@ -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.",
});
});
});

View File

@@ -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 });

View File

@@ -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)}).`,

View File

@@ -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 };