From c1964e73a8fbad4dbb3a2c8f41e83cb01d9a95cf Mon Sep 17 00:00:00 2001 From: bmendonca3 Date: Tue, 24 Feb 2026 21:57:41 -0700 Subject: [PATCH] fix(discord): gate component command authorization for guild interactions (#26119) * Discord: gate component command authorization * test: cover allowlisted guild component authorization path (#26119) (thanks @bmendonca3) --------- Co-authored-by: Brian Mendonca Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/discord/monitor/agent-components.ts | 64 ++++++++++++++++++++++++- src/discord/monitor/monitor.test.ts | 64 +++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f51468703..395b96e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin. - Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin. - Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck. +- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3. ## 2026.2.24 diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index c4d317803..e39adf581 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -23,6 +23,7 @@ import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto- import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -727,6 +728,57 @@ function formatModalSubmissionText( return lines.join("\n"); } +function resolveComponentCommandAuthorized(params: { + ctx: AgentComponentContext; + interactionCtx: ComponentInteractionContext; + channelConfig: ReturnType; + guildInfo: ReturnType; + allowNameMatching: boolean; +}): boolean { + const { ctx, interactionCtx, channelConfig, guildInfo } = params; + if (interactionCtx.isDirectMessage) { + return true; + } + + const ownerAllowList = normalizeDiscordAllowList(ctx.allowFrom, ["discord:", "user:", "pk:"]); + const ownerOk = ownerAllowList + ? resolveDiscordAllowListMatch({ + allowList: ownerAllowList, + candidate: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }).allowed + : false; + + const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ + channelConfig, + guildInfo, + memberRoleIds: interactionCtx.memberRoleIds, + sender: { + id: interactionCtx.user.id, + name: interactionCtx.user.username, + tag: formatDiscordUserTag(interactionCtx.user), + }, + allowNameMatching: params.allowNameMatching, + }); + const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false; + const authorizers = useAccessGroups + ? [ + { configured: ownerAllowList != null, allowed: ownerOk }, + { configured: hasAccessRestrictions, allowed: memberAllowed }, + ] + : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; + + return resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers, + modeWhenAccessGroupsOff: "configured", + }); +} + async function dispatchDiscordComponentEvent(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; @@ -780,12 +832,20 @@ async function dispatchDiscordComponentEvent(params: { parentSlug: channelCtx.parentSlug, scope: channelCtx.isThread ? "thread" : "channel", }); + const allowNameMatching = isDangerousNameMatchingEnabled(ctx.discordConfig); const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined; const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ channelConfig, guildInfo, sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag }, - allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig), + allowNameMatching, + }); + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, }); const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId }); const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg); @@ -830,7 +890,7 @@ async function dispatchDiscordComponentEvent(params: { Provider: "discord" as const, Surface: "discord" as const, WasMentioned: true, - CommandAuthorized: true, + CommandAuthorized: commandAuthorized, CommandSource: "text" as const, MessageSid: interaction.rawData.id, Timestamp: timestamp, diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts index 18fdce2e7..afa9bbd93 100644 --- a/src/discord/monitor/monitor.test.ts +++ b/src/discord/monitor/monitor.test.ts @@ -391,6 +391,70 @@ describe("discord component interactions", () => { expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull(); }); + it("does not mark guild modal events as command-authorized for non-allowlisted users", async () => { + registerDiscordComponentEntries({ + entries: [], + modals: [createModalEntry()], + }); + + const modal = createDiscordComponentModal( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["owner-1"], + }), + ); + const { interaction, acknowledge } = createModalInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-1", + member: { roles: [] }, + } as unknown as ModalInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"], + }); + + await modal.run(interaction, { mid: "mdl_1" } as ComponentData); + + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(dispatchReplyMock).toHaveBeenCalledTimes(1); + expect(lastDispatchCtx?.CommandAuthorized).toBe(false); + }); + + it("marks guild modal events as command-authorized for allowlisted users", async () => { + registerDiscordComponentEntries({ + entries: [], + modals: [createModalEntry()], + }); + + const modal = createDiscordComponentModal( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["123456789"], + }), + ); + const { interaction, acknowledge } = createModalInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-2", + member: { roles: [] }, + } as unknown as ModalInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"], + }); + + await modal.run(interaction, { mid: "mdl_1" } as ComponentData); + + expect(acknowledge).toHaveBeenCalledTimes(1); + expect(dispatchReplyMock).toHaveBeenCalledTimes(1); + expect(lastDispatchCtx?.CommandAuthorized).toBe(true); + }); + it("keeps reusable modal entries active after submission", async () => { const { acknowledge } = await runModalSubmission({ reusable: true });