Skip to content

Commit c1a42dc

Browse files
authored
fix: enforce focus subagent scope (#73613)
* fix: enforce focus subagent scope * docs: add changelog for focus scope fix
1 parent b48f6ca commit c1a42dc

5 files changed

Lines changed: 97 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ Docs: https://docs.openclaw.ai
189189
- Telegram/gateway: bound outbound Bot API calls and cache bundled plugin alias lookup so slow Telegram sends or WSL2 filesystem scans no longer wedge gateway replies. (#74210) Thanks @obviyus.
190190
- Configure/GitHub Copilot: reuse existing Copilot auth during configure and show the provider's manifest model catalog in the model picker. (#74276) Thanks @obviyus.
191191
- Configure/models: keep the model picker scoped to the selected manifest provider and enable its bundled plugin before catalog lookup, so choosing GitHub Copilot no longer falls back to Ollama or skips the catalog. (#74322) Thanks @obviyus.
192+
- Auto-reply/subagents: reject `/focus` from leaf subagents and scope fallback target resolution to the requesting subagent's children, so subagents cannot bind conversations outside their control boundary. (#73613) Thanks @drobison00.
192193

193194
## 2026.4.27
194195

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const hoisted = vi.hoisted(() => ({
1515
readAcpSessionEntryMock: vi.fn(),
1616
resolveConversationBindingContextMock: vi.fn(),
1717
resolveFocusTargetSessionMock: vi.fn(),
18+
resolveStoredSubagentCapabilitiesMock: vi.fn(),
1819
sessionBindingCapabilitiesMock: vi.fn(),
1920
sessionBindingBindMock: vi.fn(),
2021
sessionBindingResolveByConversationMock: vi.fn(),
@@ -102,6 +103,11 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => ({
102103
getSessionBindingService: () => buildFocusSessionBindingService(),
103104
}));
104105

106+
vi.mock("../../agents/subagent-capabilities.js", () => ({
107+
resolveStoredSubagentCapabilities: (sessionKey: string, options: unknown) =>
108+
hoisted.resolveStoredSubagentCapabilitiesMock(sessionKey, options),
109+
}));
110+
105111
vi.mock("./conversation-binding-input.js", () => ({
106112
resolveConversationBindingContextFromAcpCommand: (params: unknown) =>
107113
hoisted.resolveConversationBindingContextMock(params),
@@ -195,6 +201,7 @@ function buildFocusContext(params?: {
195201
chatType?: string;
196202
senderId?: string;
197203
token?: string;
204+
requesterKey?: string;
198205
}) {
199206
return {
200207
params: buildCommandParams({
@@ -203,7 +210,7 @@ function buildFocusContext(params?: {
203210
senderId: params?.senderId,
204211
}),
205212
handledPrefix: "/focus",
206-
requesterKey: "agent:main:main",
213+
requesterKey: params?.requesterKey ?? "agent:main:main",
207214
runs: [],
208215
restTokens: [params?.token ?? "codex-acp"],
209216
} satisfies Parameters<typeof handleSubagentsFocusAction>[0];
@@ -224,6 +231,9 @@ function buildUnfocusContext(params?: { senderId?: string }) {
224231
describe("focus actions", () => {
225232
beforeEach(() => {
226233
vi.clearAllMocks();
234+
hoisted.resolveStoredSubagentCapabilitiesMock.mockReturnValue({
235+
controlScope: "children",
236+
});
227237
hoisted.sessionBindingCapabilitiesMock.mockReturnValue(createSessionBindingCapabilities());
228238
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(null);
229239
hoisted.resolveFocusTargetSessionMock.mockResolvedValue({
@@ -278,6 +288,11 @@ describe("focus actions", () => {
278288

279289
expect(result.reply?.text).toContain("bound this conversation");
280290
expect(result.reply?.text).toContain("(acp)");
291+
expect(hoisted.resolveFocusTargetSessionMock).toHaveBeenCalledWith(
292+
expect.objectContaining({
293+
requesterKey: "agent:main:main",
294+
}),
295+
);
281296
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
282297
expect.objectContaining({
283298
placement: "current",
@@ -291,6 +306,29 @@ describe("focus actions", () => {
291306
);
292307
});
293308

309+
it("rejects /focus from a leaf subagent", async () => {
310+
hoisted.resolveStoredSubagentCapabilitiesMock.mockReturnValue({
311+
controlScope: "none",
312+
});
313+
hoisted.resolveConversationBindingContextMock.mockReturnValue({
314+
channel: THREAD_CHANNEL,
315+
accountId: "default",
316+
conversationId: "thread-1",
317+
parentConversationId: "parent-1",
318+
threadId: "thread-1",
319+
});
320+
321+
const result = await handleSubagentsFocusAction(
322+
buildFocusContext({
323+
requesterKey: "agent:main:subagent:leaf-a",
324+
}),
325+
);
326+
327+
expect(result.reply?.text).toContain("Leaf subagents cannot control other sessions.");
328+
expect(hoisted.resolveFocusTargetSessionMock).not.toHaveBeenCalled();
329+
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
330+
});
331+
294332
it("binds topic-chat topics as current conversations", async () => {
295333
hoisted.resolveConversationBindingContextMock.mockReturnValue({
296334
channel: TOPIC_CHANNEL,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { resolveFocusTargetSession } from "./commands-subagents/shared.js";
3+
4+
const hoisted = vi.hoisted(() => ({
5+
callGatewayMock: vi.fn(),
6+
}));
7+
8+
vi.mock("../../gateway/call.js", () => ({
9+
callGateway: (params: unknown) => hoisted.callGatewayMock(params),
10+
}));
11+
12+
describe("resolveFocusTargetSession", () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
});
16+
17+
it("restricts gateway fallback resolution to a subagent requester's children", async () => {
18+
hoisted.callGatewayMock.mockResolvedValue({
19+
key: "agent:main:subagent:child",
20+
});
21+
22+
const result = await resolveFocusTargetSession({
23+
runs: [],
24+
token: "child",
25+
requesterKey: "agent:main:subagent:parent",
26+
});
27+
28+
expect(result?.targetSessionKey).toBe("agent:main:subagent:child");
29+
expect(hoisted.callGatewayMock).toHaveBeenCalledWith({
30+
method: "sessions.resolve",
31+
params: {
32+
key: "child",
33+
spawnedBy: "agent:main:subagent:parent",
34+
},
35+
});
36+
});
37+
});

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ import { getSessionBindingService } from "../../../infra/outbound/session-bindin
2121
import { normalizeOptionalString } from "../../../shared/string-coerce.js";
2222
import type { CommandHandlerResult } from "../commands-types.js";
2323
import { resolveConversationBindingContextFromAcpCommand } from "../conversation-binding-input.js";
24-
import { type SubagentsCommandContext, resolveFocusTargetSession, stopWithText } from "./shared.js";
24+
import {
25+
type SubagentsCommandContext,
26+
resolveCommandSubagentController,
27+
resolveFocusTargetSession,
28+
stopWithText,
29+
} from "./shared.js";
2530

2631
type FocusBindingContext = {
2732
channel: string;
@@ -71,6 +76,11 @@ export async function handleSubagentsFocusAction(
7176
return stopWithText("Usage: /focus <subagent-label|session-key|session-id|session-label>");
7277
}
7378

79+
const controller = resolveCommandSubagentController(params, ctx.requesterKey);
80+
if (controller.controlScope !== "children") {
81+
return stopWithText("⚠️ Leaf subagents cannot control other sessions.");
82+
}
83+
7484
const bindingContext = resolveFocusBindingContext(params);
7585
if (!bindingContext) {
7686
return stopWithText("⚠️ /focus must be run inside a bindable conversation.");
@@ -85,7 +95,11 @@ export async function handleSubagentsFocusAction(
8595
return stopWithText("⚠️ Conversation bindings are unavailable for this account.");
8696
}
8797

88-
const focusTarget = await resolveFocusTargetSession({ runs, token });
98+
const focusTarget = await resolveFocusTargetSession({
99+
runs,
100+
token,
101+
requesterKey: controller.controllerSessionKey,
102+
});
89103
if (!focusTarget) {
90104
return stopWithText(`⚠️ Unable to resolve focus target: ${token}`);
91105
}

src/auto-reply/reply/commands-subagents/shared.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ export type FocusTargetResolution = {
307307
export async function resolveFocusTargetSession(params: {
308308
runs: SubagentRunRecord[];
309309
token: string;
310+
requesterKey?: string;
310311
}): Promise<FocusTargetResolution | null> {
311312
const subagentMatch = resolveSubagentTarget(params.runs, params.token);
312313
if (subagentMatch.entry) {
@@ -326,6 +327,8 @@ export async function resolveFocusTargetSession(params: {
326327
}
327328

328329
const attempts: Array<Record<string, string>> = [];
330+
const requesterKey = normalizeOptionalString(params.requesterKey);
331+
const spawnedBy = requesterKey && isSubagentSessionKey(requesterKey) ? requesterKey : undefined;
329332
attempts.push({ key: token });
330333
if (looksLikeSessionId(token)) {
331334
attempts.push({ sessionId: token });
@@ -336,7 +339,7 @@ export async function resolveFocusTargetSession(params: {
336339
try {
337340
const resolved = await callGateway({
338341
method: "sessions.resolve",
339-
params: attempt,
342+
params: spawnedBy ? { ...attempt, spawnedBy } : attempt,
340343
});
341344
const key = normalizeOptionalString(resolved?.key) ?? "";
342345
if (!key) {

0 commit comments

Comments
 (0)