From 665b0ef542ef9a6df9b5bfd5e9373fd8d9ace121 Mon Sep 17 00:00:00 2001 From: Michael Appel Date: Wed, 29 Apr 2026 15:56:26 -0400 Subject: [PATCH] fix(agents): move groupId trust check into resolveGroupToolPolicy for all callers [AI-assisted] (#73720) * fix: address issue * fix: address review feedback * fix(gateway): validate groupId against session key before persisting to session entry * test(gateway): verify groupId is validated against session key before session entry write * fix(agents): trust stored group metadata * fix(gateway): keep first group selectors * docs: add group policy trust changelog entry --------- Co-authored-by: Devin Robison --- CHANGELOG.md | 1 + .../effective-tool-policy.ts | 30 +-- src/agents/pi-tools-agent-config.test.ts | 30 +++ src/agents/pi-tools.policy.test.ts | 133 +++++++++++++ src/agents/pi-tools.policy.ts | 60 +++++- src/gateway/server-methods/agent.test.ts | 177 ++++++++++++++++++ src/gateway/server-methods/agent.ts | 116 ++++++++++-- 7 files changed, 504 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be67829f8cd..30e7bae6e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -228,6 +228,7 @@ Docs: https://docs.openclaw.ai - 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. - 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. - Gateway/startup: skip inherited workspace startup memory for sandboxed spawned sessions without real-workspace write access, so `/new` no longer preloads host workspace memory into isolated child runs. (#73611) Thanks @drobison00. +- Agents/tool policy: validate caller group IDs against session or spawned context before applying group-scoped tool policies or persisting gateway group metadata, so forged group IDs cannot unlock more permissive tools. (#73720) Thanks @mmaps. ## 2026.4.27 diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.ts b/src/agents/pi-embedded-runner/effective-tool-policy.ts index eb9fec80368..9a8f6d422b0 100644 --- a/src/agents/pi-embedded-runner/effective-tool-policy.ts +++ b/src/agents/pi-embedded-runner/effective-tool-policy.ts @@ -2,8 +2,8 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getPluginToolMeta } from "../../plugins/tools.js"; import { resolveEffectiveToolPolicy, - resolveGroupContextFromSessionKey, resolveGroupToolPolicy, + resolveTrustedGroupId, resolveSubagentToolPolicyForSession, } from "../pi-tools.policy.js"; import { @@ -60,32 +60,6 @@ type FinalEffectiveToolPolicyParams = { warn: (message: string) => void; }; -function resolveTrustedGroupId(params: FinalEffectiveToolPolicyParams): { - groupId: string | null | undefined; - dropped: boolean; -} { - const callerGroupId = (params.groupId ?? "").trim(); - if (!callerGroupId) { - return { groupId: params.groupId, dropped: false }; - } - const sessionGroupIds = resolveGroupContextFromSessionKey(params.sessionKey).groupIds ?? []; - const spawnedGroupIds = resolveGroupContextFromSessionKey(params.spawnedBy).groupIds ?? []; - const trusted = [...sessionGroupIds, ...spawnedGroupIds]; - // Fail-closed: if the session/spawnedBy keys do not encode a group context, - // we have no server-verified ground truth to compare the caller value - // against. A non-group session (direct, subagent, cron) should not consult - // a group-scoped tool policy at all, and accepting the caller's groupId - // here would let an attacker widen bundled-tool availability by sending - // an arbitrary group id. - if (trusted.length === 0) { - return { groupId: null, dropped: true }; - } - if (trusted.includes(callerGroupId)) { - return { groupId: params.groupId, dropped: false }; - } - return { groupId: null, dropped: true }; -} - export function applyFinalEffectiveToolPolicy( params: FinalEffectiveToolPolicyParams, ): AnyAgentTool[] { @@ -93,6 +67,8 @@ export function applyFinalEffectiveToolPolicy( return params.bundledTools; } const trustedGroup = resolveTrustedGroupId(params); + // Resolve here for warnings and to strip caller-only group metadata before + // this pass; resolveGroupToolPolicy re-checks internally for all callers. if (trustedGroup.dropped) { params.warn( "effective tool policy: dropping caller-provided groupId that does not match session-derived group context", diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index fdfe7bee15d..6036020ab2f 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -462,6 +462,36 @@ describe("Agent-specific tool filtering", () => { }); }); + it("should not apply forged caller group tool policy for non-group sessions", () => { + const cfg: OpenClawConfig = { + tools: { allow: ["read"] }, + channels: { + whatsapp: { + groups: { + "trusted-group": { + tools: { allow: ["exec", "read", "write", "edit"] }, + }, + }, + }, + }, + }; + + const tools = createOpenClawCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + messageProvider: "whatsapp", + groupId: "trusted-group", + workspaceDir: "/tmp/test-forged-group-policy", + agentDir: "/tmp/agent-forged-group-policy", + }); + const names = tools.map((t) => t.name); + expect(names).toContain("read"); + expect(names).not.toContain("exec"); + expect(names).not.toContain("write"); + expect(names).not.toContain("edit"); + expect(names).not.toContain("apply_patch"); + }); + it("should resolve feishu group tool policy for sender-scoped session keys", () => { const cfg: OpenClawConfig = { channels: { diff --git a/src/agents/pi-tools.policy.test.ts b/src/agents/pi-tools.policy.test.ts index 28e83bb5ac8..8a121cd3537 100644 --- a/src/agents/pi-tools.policy.test.ts +++ b/src/agents/pi-tools.policy.test.ts @@ -7,8 +7,10 @@ import { filterToolsByPolicy, isToolAllowedByPolicyName, resolveEffectiveToolPolicy, + resolveGroupToolPolicy, resolveSubagentToolPolicy, resolveSubagentToolPolicyForSession, + resolveTrustedGroupId, } from "./pi-tools.policy.js"; import { createStubTool } from "./test-helpers/pi-tool-stubs.js"; import { providerAliasCases } from "./test-helpers/provider-alias-cases.js"; @@ -40,6 +42,137 @@ describe("pi-tools.policy", () => { }); }); +describe("resolveGroupToolPolicy group context validation", () => { + const cfg: OpenClawConfig = { + channels: { + whatsapp: { + groups: { + "safe-room": { + tools: { allow: ["read"] }, + }, + "trusted-group": { + tools: { allow: ["exec", "read", "write", "edit"] }, + }, + }, + }, + }, + tools: { allow: ["read"] }, + }; + + it("rejects forged groupId when the session has no group context", () => { + expect( + resolveGroupToolPolicy({ + config: cfg, + sessionKey: "agent:main:main", + messageProvider: "whatsapp", + groupId: "trusted-group", + groupChannel: "whatsapp", + }), + ).toBeUndefined(); + }); + + it("uses session-derived group policy when caller groupId disagrees", () => { + expect( + resolveGroupToolPolicy({ + config: cfg, + sessionKey: "agent:main:whatsapp:group:safe-room", + messageProvider: "whatsapp", + groupId: "trusted-group", + groupChannel: "whatsapp", + }), + ).toEqual({ allow: ["read"] }); + }); + + it("accepts caller groupId when it matches session-derived group context", () => { + expect( + resolveTrustedGroupId({ + sessionKey: "agent:main:whatsapp:group:trusted-group", + groupId: "trusted-group", + }), + ).toEqual({ groupId: "trusted-group", dropped: false }); + expect( + resolveGroupToolPolicy({ + config: cfg, + sessionKey: "agent:main:whatsapp:group:trusted-group", + messageProvider: "whatsapp", + groupId: "trusted-group", + groupChannel: "whatsapp", + }), + ).toEqual({ allow: ["exec", "read", "write", "edit"] }); + }); + + it("accepts caller groupId when spawnedBy provides the trusted group context", () => { + expect( + resolveTrustedGroupId({ + sessionKey: "agent:main:main", + spawnedBy: "agent:main:whatsapp:group:trusted-group", + groupId: "trusted-group", + }), + ).toEqual({ groupId: "trusted-group", dropped: false }); + expect( + resolveGroupToolPolicy({ + config: cfg, + sessionKey: "agent:main:main", + spawnedBy: "agent:main:whatsapp:group:trusted-group", + messageProvider: "whatsapp", + groupId: "trusted-group", + }), + ).toEqual({ allow: ["exec", "read", "write", "edit"] }); + }); + + it("keeps specific session group policy ahead of trusted parent caller groupId", () => { + const scopedCfg: OpenClawConfig = { + channels: { + whatsapp: { + groups: { + room: { + tools: { allow: ["exec", "read"] }, + }, + "room:sender:alice": { + tools: { allow: ["read"] }, + }, + }, + }, + }, + }; + + expect( + resolveGroupToolPolicy({ + config: scopedCfg, + sessionKey: "agent:main:whatsapp:group:room:sender:alice", + messageProvider: "whatsapp", + groupId: "room", + }), + ).toEqual({ allow: ["read"] }); + }); + + it("prefers the session-derived channel over caller-supplied messageProvider", () => { + const channelCfg = { + channels: { + discord: { + groups: { + C123: { tools: { allow: ["exec"] } }, + }, + }, + slack: { + groups: { + C123: { tools: { allow: ["read"] } }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const policy = resolveGroupToolPolicy({ + config: channelCfg, + sessionKey: "agent:main:slack:group:C123", + messageProvider: "discord", + groupId: "C123", + }); + + expect(policy).toEqual({ allow: ["read"] }); + }); +}); + describe("resolveSubagentToolPolicy depth awareness", () => { const baseCfg = { agents: { defaults: { subagents: { maxSpawnDepth: 2 } } }, diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 647e8401628..105e0f747d6 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -279,6 +279,51 @@ export function resolveGroupContextFromSessionKey(sessionKey?: string | null): { }; } +type GroupToolPolicyContext = ReturnType; + +function resolveTrustedGroupIdFromContexts(params: { + groupId?: string | null; + sessionContext: GroupToolPolicyContext; + spawnedContext: GroupToolPolicyContext; +}): { + groupId: string | null | undefined; + dropped: boolean; +} { + const callerGroupId = (params.groupId ?? "").trim(); + if (!callerGroupId) { + return { groupId: params.groupId, dropped: false }; + } + const trustedGroupIds = collectUniqueStrings([ + ...(params.sessionContext.groupIds ?? []), + ...(params.spawnedContext.groupIds ?? []), + ]); + // Fail closed when no server-derived session/spawn context can vouch for the + // caller group id. Non-group sessions must not opt into group-scoped tool + // policy by supplying an arbitrary groupId. + if (trustedGroupIds.length === 0) { + return { groupId: null, dropped: true }; + } + if (trustedGroupIds.includes(callerGroupId)) { + return { groupId: params.groupId, dropped: false }; + } + return { groupId: null, dropped: true }; +} + +export function resolveTrustedGroupId(params: { + groupId?: string | null; + sessionKey?: string | null; + spawnedBy?: string | null; +}): { + groupId: string | null | undefined; + dropped: boolean; +} { + return resolveTrustedGroupIdFromContexts({ + groupId: params.groupId, + sessionContext: resolveGroupContextFromSessionKey(params.sessionKey), + spawnedContext: resolveGroupContextFromSessionKey(params.spawnedBy), + }); +} + function resolveProviderToolPolicy(params: { byProvider?: Record; modelProvider?: string; @@ -421,15 +466,22 @@ export function resolveGroupToolPolicy(params: { } const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey); const spawnedContext = resolveGroupContextFromSessionKey(params.spawnedBy); + const trustedGroup = resolveTrustedGroupIdFromContexts({ + groupId: params.groupId, + sessionContext, + spawnedContext, + }); + // Keep server-derived ids first so a caller cannot use a trusted parent + // candidate to skip a more-specific session group policy. const groupIds = collectUniqueStrings([ - ...buildScopedGroupIdCandidates(params.groupId), ...(sessionContext.groupIds ?? []), ...(spawnedContext.groupIds ?? []), + ...buildScopedGroupIdCandidates(trustedGroup.groupId), ]); if (groupIds.length === 0) { return undefined; } - const channelRaw = params.messageProvider ?? sessionContext.channel ?? spawnedContext.channel; + const channelRaw = sessionContext.channel ?? spawnedContext.channel ?? params.messageProvider; const channel = normalizeMessageChannel(channelRaw); if (!channel) { return undefined; @@ -444,8 +496,8 @@ export function resolveGroupToolPolicy(params: { const toolsConfig = plugin?.groups?.resolveToolPolicy?.({ cfg: params.config, groupId, - groupChannel: params.groupChannel, - groupSpace: params.groupSpace, + groupChannel: trustedGroup.dropped ? null : params.groupChannel, + groupSpace: trustedGroup.dropped ? null : params.groupSpace, accountId: params.accountId, senderId: params.senderId, senderName: params.senderName, diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 51bdf82bcdc..dd2bdd45646 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -445,6 +445,118 @@ describe("gateway agent handler", () => { expect(capturedEntry?.acp).toEqual(existingAcpMeta); }); + it("keeps stored group metadata when a trusted group session receives caller-supplied selectors", async () => { + const sessionKey = "agent:main:slack:group:C123"; + const existingEntry = buildExistingMainStoreEntry({ + channel: "slack", + groupId: "C123", + groupChannel: "#trusted", + space: "TTRUSTED", + }); + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: existingEntry, + canonicalKey: sessionKey, + }); + + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + [sessionKey]: { ...existingEntry }, + }; + const result = await updater(store); + capturedEntry = result as Record; + return result; + }); + + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "trusted group turn", + agentId: "main", + sessionKey, + channel: "slack", + groupId: "C123", + groupChannel: "#forged-admin", + groupSpace: "TFORGED", + idempotencyKey: "trusted-group-forged-selectors", + }, + { reqId: "trusted-group-forged-selectors" }, + ); + + expect(mocks.updateSessionStore).toHaveBeenCalled(); + expect(capturedEntry?.groupId).toBe("C123"); + expect(capturedEntry?.groupChannel).toBe("#trusted"); + expect(capturedEntry?.space).toBe("TTRUSTED"); + await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled()); + const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as { + groupChannel?: string; + groupSpace?: string; + runContext?: { groupChannel?: string; groupSpace?: string }; + }; + expect(callArgs.groupChannel).toBe("#trusted"); + expect(callArgs.groupSpace).toBe("TTRUSTED"); + expect(callArgs.runContext?.groupChannel).toBe("#trusted"); + expect(callArgs.runContext?.groupSpace).toBe("TTRUSTED"); + }); + + it("persists first-turn group selectors for a trusted new group session", async () => { + const sessionKey = "agent:main:slack:group:C123"; + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: undefined, + canonicalKey: sessionKey, + }); + + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = {}; + const result = await updater(store); + capturedEntry = result as Record; + return result; + }); + + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "first trusted group turn", + agentId: "main", + sessionKey, + channel: "slack", + groupId: "C123", + groupChannel: "#general", + groupSpace: "TWORKSPACE", + idempotencyKey: "trusted-group-first-turn-selectors", + }, + { reqId: "trusted-group-first-turn-selectors" }, + ); + + expect(mocks.updateSessionStore).toHaveBeenCalled(); + expect(capturedEntry?.groupId).toBe("C123"); + expect(capturedEntry?.groupChannel).toBe("#general"); + expect(capturedEntry?.space).toBe("TWORKSPACE"); + await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled()); + const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as { + groupChannel?: string; + groupSpace?: string; + runContext?: { groupChannel?: string; groupSpace?: string }; + }; + expect(callArgs.groupChannel).toBe("#general"); + expect(callArgs.groupSpace).toBe("TWORKSPACE"); + expect(callArgs.runContext?.groupChannel).toBe("#general"); + expect(callArgs.runContext?.groupSpace).toBe("TWORKSPACE"); + }); + it("tags newly-created plugin runtime sessions with the plugin owner", async () => { const sessionKey = "agent:main:dreaming-narrative-light-workspace-1"; mocks.loadSessionEntry.mockReturnValue({ @@ -2539,6 +2651,71 @@ describe("gateway agent handler", () => { undefined, ); }); + + describe("groupId session-entry persistence validation", () => { + async function captureGroupEntryFields( + sessionKey: string, + entry: Record, + requestGroupId?: string, + ) { + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: { sessionId: "existing-session-id", updatedAt: Date.now(), ...entry }, + canonicalKey: sessionKey, + }); + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + [sessionKey]: { sessionId: "existing-session-id" }, + }; + await updater(store); + capturedEntry = store[sessionKey] as Record; + }); + mocks.agentCommand.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } }); + await invokeAgent({ + message: "hi", + agentId: "main", + sessionKey, + idempotencyKey: `group-persist-${sessionKey}-${requestGroupId ?? "none"}`, + ...(requestGroupId !== undefined ? { groupId: requestGroupId } : {}), + }); + return capturedEntry; + } + + it("drops forged groupId on non-group session before writing session entry", async () => { + const entry = await captureGroupEntryFields("agent:main:main", {}, "trusted-group"); + expect(entry?.groupId).toBeUndefined(); + }); + + it("preserves groupId when session key encodes matching group membership", async () => { + const entry = await captureGroupEntryFields( + "agent:main:slack:group:trusted-group", + {}, + "trusted-group", + ); + expect(entry?.groupId).toBe("trusted-group"); + }); + + it("clears a previously forged groupId from the session entry on reconnection", async () => { + // Entry carries a forged groupId from a prior request; new request supplies none. + const entry = await captureGroupEntryFields( + "agent:main:main", + { groupId: "trusted-group" }, + undefined, + ); + expect(entry?.groupId).toBeUndefined(); + }); + + it("trusts groupId when spawnedBy session key encodes the matching group", async () => { + const entry = await captureGroupEntryFields( + "agent:main:main", + { spawnedBy: "agent:main:slack:group:trusted-group" }, + "trusted-group", + ); + expect(entry?.groupId).toBe("trusted-group"); + }); + }); }); describe("gateway agent handler chat.abort integration", () => { diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index c908bf9cd10..17008eb119f 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -10,6 +10,7 @@ import { resolvePublicAgentAvatarSource, } from "../../agents/identity-avatar.js"; import type { AgentInternalEvent } from "../../agents/internal-events.js"; +import { resolveTrustedGroupId } from "../../agents/pi-tools.policy.js"; import { resolveSandboxConfigForAgent } from "../../agents/sandbox/config.js"; import { normalizeSpawnedRunMetadata, @@ -65,6 +66,10 @@ import { type InputProvenance, } from "../../sessions/input-provenance.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { + parseRawSessionConversationRef, + parseThreadSessionSuffix, +} from "../../sessions/session-key-utils.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -207,6 +212,62 @@ function shouldSkipStartupContextForSpawnedSandbox(params: { return sandboxCfg.workspaceAccess !== "rw"; } +type TrustedGroupMetadata = { + groupId?: string; + groupChannel?: string; + groupSpace?: string; +}; + +function normalizeTrustedGroupMetadata(value?: { + groupId?: unknown; + groupChannel?: unknown; + groupSpace?: unknown; + space?: unknown; +}): TrustedGroupMetadata { + return { + groupId: normalizeOptionalString(value?.groupId), + groupChannel: normalizeOptionalString(value?.groupChannel), + groupSpace: normalizeOptionalString(value?.groupSpace ?? value?.space), + }; +} + +function resolveSessionKeyGroupId(sessionKey: string): string | undefined { + const { baseSessionKey } = parseThreadSessionSuffix(sessionKey); + const conversation = parseRawSessionConversationRef(baseSessionKey ?? sessionKey); + if (!conversation || (conversation.kind !== "group" && conversation.kind !== "channel")) { + return undefined; + } + return conversation.rawId; +} + +function resolveTrustedGroupMetadata(params: { + sessionKey: string; + spawnedBy?: string; + stored: TrustedGroupMetadata; + inherited?: TrustedGroupMetadata; +}): TrustedGroupMetadata { + return { + groupId: + params.stored.groupId ?? + params.inherited?.groupId ?? + resolveSessionKeyGroupId(params.sessionKey) ?? + (params.spawnedBy ? resolveSessionKeyGroupId(params.spawnedBy) : undefined), + groupChannel: params.stored.groupChannel ?? params.inherited?.groupChannel, + groupSpace: params.stored.groupSpace ?? params.inherited?.groupSpace, + }; +} + +function requestGroupMatchesTrusted(params: { + requestGroupId?: string; + trustedGroupId?: string; +}): boolean { + const requestGroupId = params.requestGroupId?.trim(); + if (!requestGroupId) { + return true; + } + return Boolean(params.trustedGroupId && requestGroupId === params.trustedGroupId); +} + function emitSessionsChanged( context: Pick< GatewayRequestHandlerOptions["context"], @@ -864,24 +925,55 @@ export const agentHandlers: GatewayRequestHandlers = { : normalizeOptionalString(entry.pluginOwnerId); const sessionAgent = resolveAgentIdFromSessionKey(canonicalKey); spawnedByValue = canonicalizeSpawnedByForAgent(cfg, sessionAgent, entry?.spawnedBy); - let inheritedGroup: - | { groupId?: string; groupChannel?: string; groupSpace?: string } - | undefined; - if (spawnedByValue && (!resolvedGroupId || !resolvedGroupChannel || !resolvedGroupSpace)) { + const storedGroup = normalizeTrustedGroupMetadata(entry); + let inheritedGroup: TrustedGroupMetadata | undefined; + if ( + spawnedByValue && + (!storedGroup.groupId || !storedGroup.groupChannel || !storedGroup.groupSpace) + ) { try { const parentEntry = loadSessionEntry(spawnedByValue)?.entry; - inheritedGroup = { + inheritedGroup = normalizeTrustedGroupMetadata({ groupId: parentEntry?.groupId, groupChannel: parentEntry?.groupChannel, groupSpace: parentEntry?.space, - }; + }); } catch { inheritedGroup = undefined; } } - resolvedGroupId = resolvedGroupId || inheritedGroup?.groupId; - resolvedGroupChannel = resolvedGroupChannel || inheritedGroup?.groupChannel; - resolvedGroupSpace = resolvedGroupSpace || inheritedGroup?.groupSpace; + const trustedGroup = resolveTrustedGroupMetadata({ + sessionKey: canonicalKey, + spawnedBy: spawnedByValue, + stored: storedGroup, + inherited: inheritedGroup, + }); + const validatedGroup = trustedGroup.groupId + ? resolveTrustedGroupId({ + groupId: trustedGroup.groupId, + sessionKey: canonicalKey, + spawnedBy: spawnedByValue, + }) + : undefined; + if (validatedGroup?.dropped) { + resolvedGroupId = undefined; + resolvedGroupChannel = undefined; + resolvedGroupSpace = undefined; + } else { + const trustRequestSelectors = + Boolean(trustedGroup.groupId) && + requestGroupMatchesTrusted({ + requestGroupId: normalizedSpawned.groupId, + trustedGroupId: trustedGroup.groupId, + }); + resolvedGroupId = trustedGroup.groupId; + resolvedGroupChannel = + trustedGroup.groupChannel ?? + (trustRequestSelectors ? normalizedSpawned.groupChannel : undefined); + resolvedGroupSpace = + trustedGroup.groupSpace ?? + (trustRequestSelectors ? normalizedSpawned.groupSpace : undefined); + } const deliveryFields = normalizeSessionDeliveryFields(entry); // When the session has no delivery context yet (e.g. a freshly-spawned subagent // with deliver: false), seed it from the request's channel/to/threadId params. @@ -935,9 +1027,9 @@ export const agentHandlers: GatewayRequestHandlers = { spawnedWorkspaceDir: entry?.spawnedWorkspaceDir, spawnDepth: entry?.spawnDepth, channel: entry?.channel ?? request.channel?.trim(), - groupId: resolvedGroupId ?? entry?.groupId, - groupChannel: resolvedGroupChannel ?? entry?.groupChannel, - space: resolvedGroupSpace ?? entry?.space, + groupId: resolvedGroupId, + groupChannel: resolvedGroupChannel, + space: resolvedGroupSpace, ...(pluginOwnerId ? { pluginOwnerId } : {}), cliSessionIds: entry?.cliSessionIds, cliSessionBindings: entry?.cliSessionBindings,