fix(discord): gate guild metadata reads [AI] (#98966)

* fix(discord): gate guild metadata reads

* fix(discord): require guild-wide active thread reads
This commit is contained in:
Pavan Kumar Gondhi
2026-07-02 15:26:36 +05:30
committed by GitHub
parent 22d686d13e
commit 5bcd25f0fb
4 changed files with 234 additions and 3 deletions
@@ -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<string, unknown>) =>
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(),
@@ -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,
@@ -38,7 +38,10 @@ export type DiscordMessagingActionContext = {
accountId?: string;
resolveChannelId: () => string;
assertReadTargetAllowed: (params: { guildId?: string; channelId: string }) => Promise<void>;
assertGuildReadTargetAllowed: (params: { guildId: string }) => Promise<void>;
assertGuildReadTargetAllowed: (params: {
guildId: string;
channelTargetRequiredMessage?: string;
}) => Promise<void>;
resolveReactionChannelId: () => Promise<string>;
withOpts: (extra?: Record<string, unknown>) => { cfg: OpenClawConfig; accountId?: string };
withReactionRuntimeOptions: <T extends Record<string, unknown> = Record<string, never>>(
@@ -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.",
);
}
},
@@ -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<string, unknown>): OpenClawConfig {
return {
channels: {
discord: {
token: "token",
groupPolicy: "allowlist",
guilds,
},
},
} as OpenClawConfig;
}
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
function mockCall(source: MockCallSource, label: string, callIndex = 0): Array<unknown> {
@@ -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";