diff --git a/extensions/discord/src/actions/runtime.guild.ts b/extensions/discord/src/actions/runtime.guild.ts index 4a90ed5a3f92..6f55f740cfae 100644 --- a/extensions/discord/src/actions/runtime.guild.ts +++ b/extensions/discord/src/actions/runtime.guild.ts @@ -37,6 +37,7 @@ import { uploadStickerDiscord, resolveEventCoverImage, } from "../send.js"; +import { createDiscordMessagingActionContext } from "./runtime.messaging.shared.js"; import { createDiscordActionOptions, readDiscordChannelCreateParams, @@ -364,8 +365,22 @@ export async function handleDiscordGuildAction( } assertGuildAdminActionEnabled(action, isActionEnabled); await verifySenderGuildAdminPermission({ action, values: params, accountId, cfg }); + const readTargetGate = createDiscordMessagingActionContext({ + action, + input: params, + isActionEnabled, + cfg, + options, + }); const withOpts = (extra?: Record) => createDiscordActionOptions({ cfg, accountId, extra }); + const assertGuildMetadataReadAllowed = async (guildId: string) => { + await readTargetGate.assertGuildReadTargetAllowed({ + guildId, + channelTargetRequiredMessage: + "Discord guild metadata reads require a wildcard channel allowlist for this guild.", + }); + }; switch (action) { case "memberInfo": { if (!isActionEnabled("memberInfo")) { @@ -374,6 +389,7 @@ export async function handleDiscordGuildAction( const guildId = readStringParam(params, "guildId", { required: true, }); + await assertGuildMetadataReadAllowed(guildId); const userId = readStringParam(params, "userId", { required: true, }); @@ -395,6 +411,7 @@ export async function handleDiscordGuildAction( const guildId = readStringParam(params, "guildId", { required: true, }); + await assertGuildMetadataReadAllowed(guildId); const roles = await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId, withOpts()); return jsonResult({ ok: true, roles }); } @@ -405,6 +422,7 @@ export async function handleDiscordGuildAction( const guildId = readStringParam(params, "guildId", { required: true, }); + await assertGuildMetadataReadAllowed(guildId); const emojis = await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, withOpts()); return jsonResult({ ok: true, emojis }); } @@ -489,6 +507,7 @@ export async function handleDiscordGuildAction( const channelId = readStringParam(params, "channelId", { required: true, }); + await readTargetGate.assertReadTargetAllowed({ channelId }); const channel = await discordGuildActionRuntime.fetchChannelInfoDiscord( channelId, withOpts(), @@ -502,6 +521,7 @@ export async function handleDiscordGuildAction( const guildId = readStringParam(params, "guildId", { required: true, }); + await assertGuildMetadataReadAllowed(guildId); const channels = await discordGuildActionRuntime.listGuildChannelsDiscord( guildId, withOpts(), @@ -515,6 +535,7 @@ export async function handleDiscordGuildAction( const guildId = readStringParam(params, "guildId", { required: true, }); + await assertGuildMetadataReadAllowed(guildId); const userId = readStringParam(params, "userId", { required: true, }); @@ -532,6 +553,7 @@ export async function handleDiscordGuildAction( const guildId = readStringParam(params, "guildId", { required: true, }); + await assertGuildMetadataReadAllowed(guildId); const events = await discordGuildActionRuntime.listScheduledEventsDiscord( guildId, withOpts(), diff --git a/extensions/discord/src/actions/runtime.messaging.send.ts b/extensions/discord/src/actions/runtime.messaging.send.ts index 866422b4fc1f..382c1a2cd8a3 100644 --- a/extensions/discord/src/actions/runtime.messaging.send.ts +++ b/extensions/discord/src/actions/runtime.messaging.send.ts @@ -365,6 +365,15 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction const includeArchived = readBooleanParam(ctx.params, "includeArchived"); const before = readStringParam(ctx.params, "before"); const limit = readPositiveIntegerParam(ctx.params, "limit"); + if (channelId && includeArchived === true) { + await ctx.assertReadTargetAllowed({ guildId, channelId }); + } else { + await ctx.assertGuildReadTargetAllowed({ + guildId, + channelTargetRequiredMessage: + "Discord active thread lists require a wildcard channel allowlist so each read target can be authorized.", + }); + } const threads = await discordMessagingActionRuntime.listThreadsDiscord( { guildId, diff --git a/extensions/discord/src/actions/runtime.messaging.shared.ts b/extensions/discord/src/actions/runtime.messaging.shared.ts index 923ec0f6455a..ffa8249be677 100644 --- a/extensions/discord/src/actions/runtime.messaging.shared.ts +++ b/extensions/discord/src/actions/runtime.messaging.shared.ts @@ -38,7 +38,10 @@ export type DiscordMessagingActionContext = { accountId?: string; resolveChannelId: () => string; assertReadTargetAllowed: (params: { guildId?: string; channelId: string }) => Promise; - assertGuildReadTargetAllowed: (params: { guildId: string }) => Promise; + assertGuildReadTargetAllowed: (params: { + guildId: string; + channelTargetRequiredMessage?: string; + }) => Promise; resolveReactionChannelId: () => Promise; withOpts: (extra?: Record) => { cfg: OpenClawConfig; accountId?: string }; withReactionRuntimeOptions: = Record>( @@ -357,7 +360,7 @@ export function createDiscordMessagingActionContext(params: { throw new Error("Discord read target channel is not allowed."); } }, - assertGuildReadTargetAllowed: async ({ guildId }) => { + assertGuildReadTargetAllowed: async ({ guildId, channelTargetRequiredMessage }) => { const guildInfo = await resolveReadGuildEntry(guildId); if ( !isDiscordGroupAllowedByPolicy({ @@ -374,7 +377,8 @@ export function createDiscordMessagingActionContext(params: { !allowsAllDiscordGuildChannels(guildInfo.channels) ) { throw new Error( - "Discord message search requires channelId or channelIds so each read target can be authorized.", + channelTargetRequiredMessage ?? + "Discord message search requires channelId or channelIds so each read target can be authorized.", ); } }, diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 46f8de597e58..f1f5bd8e7aa4 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -55,13 +55,18 @@ const discordSendMocks = { id: guildId, name: "Guild", })), + fetchMemberInfoDiscord: vi.fn(async () => ({ user: { id: "U1" } })), hasAnyChannelPermissionDiscord: vi.fn(async () => true), hasAnyGuildPermissionDiscord: vi.fn(async () => true), fetchMessageDiscord: vi.fn(async () => ({})), fetchReactionsDiscord: vi.fn(async () => ({})), + fetchRoleInfoDiscord: vi.fn(async () => []), + fetchVoiceStatusDiscord: vi.fn(async () => ({})), kickMemberDiscord: vi.fn(async () => ({})), listGuildChannelsDiscord: vi.fn(async () => []), + listGuildEmojisDiscord: vi.fn(async () => []), listPinsDiscord: vi.fn(async () => ({})), + listScheduledEventsDiscord: vi.fn(async () => []), listThreadsDiscord: vi.fn(async () => ({})), moveChannelDiscord: vi.fn(async () => ({ ok: true })), pinMessageDiscord: vi.fn(async () => ({})), @@ -94,13 +99,18 @@ const { fetchChannelInfoDiscord, fetchChannelPermissionsDiscord, fetchGuildInfoDiscord, + fetchMemberInfoDiscord, fetchReactionsDiscord, fetchMessageDiscord, + fetchRoleInfoDiscord, + fetchVoiceStatusDiscord, hasAnyChannelPermissionDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, listGuildChannelsDiscord, + listGuildEmojisDiscord, listPinsDiscord, + listScheduledEventsDiscord, listThreadsDiscord, moveChannelDiscord, reactMessageDiscord, @@ -127,6 +137,18 @@ const DISCORD_TEST_CFG = { }, } as OpenClawConfig; +function discordAllowlistCfg(guilds: Record): OpenClawConfig { + return { + channels: { + discord: { + token: "token", + groupPolicy: "allowlist", + guilds, + }, + }, + } as OpenClawConfig; +} + type MockCallSource = { mock: { calls: Array> } }; function mockCall(source: MockCallSource, label: string, callIndex = 0): Array { @@ -404,6 +426,71 @@ describe("handleDiscordMessagingAction", () => { expect(result.details).not.toHaveProperty("nextBefore"); }); + it("rejects archived Discord thread lists for non-allowlisted target channels", async () => { + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await expect( + handleMessagingAction( + "threadList", + { guildId: "111", channelId: "333", includeArchived: true }, + enableAllActions, + cfg, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(listThreadsDiscord).not.toHaveBeenCalled(); + }); + + it("requires guild-wide authorization for active Discord thread lists", async () => { + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await expect( + handleMessagingAction( + "threadList", + { guildId: "111", channelId: "222" }, + enableAllActions, + cfg, + ), + ).rejects.toThrow( + "Discord active thread lists require a wildcard channel allowlist so each read target can be authorized.", + ); + expect(listThreadsDiscord).not.toHaveBeenCalled(); + }); + + it("allows guild-wide Discord thread lists when the guild has a wildcard channel allowlist", async () => { + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "*": { enabled: true }, + }, + }, + }); + + await handleMessagingAction("threadList", { guildId: "111" }, enableAllActions, cfg); + + expect(listThreadsDiscord).toHaveBeenCalledWith( + { + guildId: "111", + channelId: undefined, + includeArchived: undefined, + before: undefined, + limit: undefined, + }, + { cfg }, + ); + }); + it("resolves Discord DM targets for reaction adds", async () => { const resolveReactionTarget = vi.fn(async () => "DM1"); discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget; @@ -1614,6 +1701,115 @@ describe("handleDiscordGuildAction", () => { expect(details.status).toBe("online"); expect(details.activities).toEqual([]); }); + + it.each([ + { + action: "memberInfo", + params: { guildId: "333", userId: "U1" }, + runtimeCall: fetchMemberInfoDiscord, + }, + { action: "roleInfo", params: { guildId: "333" }, runtimeCall: fetchRoleInfoDiscord }, + { action: "emojiList", params: { guildId: "333" }, runtimeCall: listGuildEmojisDiscord }, + { action: "channelList", params: { guildId: "333" }, runtimeCall: listGuildChannelsDiscord }, + { + action: "voiceStatus", + params: { guildId: "333", userId: "U1" }, + runtimeCall: fetchVoiceStatusDiscord, + }, + { action: "eventList", params: { guildId: "333" }, runtimeCall: listScheduledEventsDiscord }, + ])( + "rejects Discord guild metadata action $action for non-allowlisted guilds", + async ({ action, params, runtimeCall }) => { + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "*": { enabled: true }, + }, + }, + }); + + await expect(handleGuildAction(action, params, enableAllActions, cfg)).rejects.toThrow( + "Discord read target channel is not allowed.", + ); + expect(runtimeCall).not.toHaveBeenCalled(); + }, + ); + + it("requires a guild-wide allowlist for Discord guild metadata reads", async () => { + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await expect( + handleGuildAction("memberInfo", { guildId: "111", userId: "U1" }, enableAllActions, cfg), + ).rejects.toThrow( + "Discord guild metadata reads require a wildcard channel allowlist for this guild.", + ); + expect(fetchMemberInfoDiscord).not.toHaveBeenCalled(); + }); + + it("allows Discord guild metadata reads when the guild has a wildcard channel allowlist", async () => { + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "*": { enabled: true }, + }, + }, + }); + + await handleGuildAction("roleInfo", { guildId: "111" }, enableAllActions, cfg); + + expect(fetchRoleInfoDiscord).toHaveBeenCalledWith("111", { cfg }); + }); + + it("rejects Discord channel info reads for non-allowlisted target channels", async () => { + fetchChannelInfoDiscord.mockResolvedValue({ + id: "333", + guild_id: "111", + name: "private", + type: 0, + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await expect( + handleGuildAction("channelInfo", { channelId: "333" }, channelInfoEnabled, cfg), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(fetchChannelInfoDiscord).toHaveBeenCalledTimes(1); + }); + + it("allows Discord channel info reads for allowlisted target channels", async () => { + fetchChannelInfoDiscord.mockResolvedValue({ + id: "222", + guild_id: "111", + name: "allowed", + type: 0, + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await handleGuildAction("channelInfo", { channelId: "222" }, channelInfoEnabled, cfg); + + expect(fetchChannelInfoDiscord).toHaveBeenCalledTimes(2); + expect(mockCall(fetchChannelInfoDiscord, "fetchChannelInfoDiscord", 1)).toEqual([ + "222", + { cfg }, + ]); + }); }); const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";