Fix Discord native commands bypassing group DM channel allowlist (#57735)

* Fix Discord native commands bypassing group DM channel allowlist

* Fix linting

* Update tests
This commit is contained in:
Devin Robison
2026-03-30 11:17:36 -06:00
committed by GitHub
parent dd17dae3e5
commit 8fdb19676a
3 changed files with 151 additions and 7 deletions

View File

@@ -43,10 +43,20 @@ function createNativeCommand(
if (!command) {
throw new Error(`missing native command: ${name}`);
}
const cfg = (opts?.cfg ?? {}) as ReturnType<typeof loadConfig>;
const discordConfig = (opts?.discordConfig ?? {}) as NonNullable<
const baseCfg = (opts?.cfg ?? {}) as ReturnType<typeof loadConfig>;
const discordConfig = (opts?.discordConfig ?? baseCfg.channels?.discord ?? {}) as NonNullable<
OpenClawConfig["channels"]
>["discord"];
const cfg =
opts?.discordConfig === undefined
? baseCfg
: ({
...baseCfg,
channels: {
...baseCfg.channels,
discord: discordConfig,
},
} as ReturnType<typeof loadConfig>);
return createDiscordNativeCommand({
command,
cfg,
@@ -199,6 +209,57 @@ describe("createDiscordNativeCommand option wiring", () => {
expect(respond).toHaveBeenCalledWith([]);
});
it("returns no autocomplete choices for group DMs outside dm.groupChannels", async () => {
const discordConfig = {
dm: {
enabled: true,
policy: "open",
groupEnabled: true,
groupChannels: ["allowed-group"],
},
} satisfies NonNullable<OpenClawConfig["channels"]>["discord"];
const command = createNativeCommand("think", {
cfg: {
commands: {
allowFrom: {
discord: ["user:allowed-user"],
},
},
} as ReturnType<typeof loadConfig>,
discordConfig,
});
const level = requireOption(command, "level");
const autocomplete = readAutocomplete(level);
if (typeof autocomplete !== "function") {
throw new Error("think level option did not wire autocomplete");
}
const respond = vi.fn(async (_choices: unknown[]) => undefined);
await autocomplete({
user: {
id: "allowed-user",
username: "allowed",
globalName: "Allowed",
},
channel: {
type: ChannelType.GroupDM,
id: "blocked-group",
name: "Blocked Group",
},
guild: undefined,
rawData: {
member: { roles: [] },
},
options: {
getFocused: () => ({ value: "xh" }),
},
respond,
client: {},
} as never);
expect(respond).toHaveBeenCalledWith([]);
});
it("truncates Discord command and option descriptions to Discord's limit", () => {
const longDescription = "x".repeat(140);
const cfg = {} as ReturnType<typeof loadConfig>;

View File

@@ -466,6 +466,41 @@ describe("Discord native plugin command dispatch", () => {
);
});
it("rejects group DM slash commands outside dm.groupChannels before dispatch", async () => {
const cfg = {
commands: {
allowFrom: {
discord: ["user:owner"],
},
},
channels: {
discord: {
dm: {
enabled: true,
policy: "open",
groupEnabled: true,
groupChannels: ["allowed-group"],
},
},
},
} as OpenClawConfig;
const interaction = createInteraction({
channelType: ChannelType.GroupDM,
channelId: "blocked-group",
});
const dispatchSpy = createDispatchSpy();
const command = await createStatusCommand(cfg);
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(
expect.objectContaining({
content: "This group DM is not allowed.",
}),
);
});
it("executes matched plugin commands directly without invoking the agent dispatcher", async () => {
const cfg = createConfig();
const commandSpec: NativeCommandSpec = {

View File

@@ -53,6 +53,7 @@ import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveGroupDmAllow,
resolveDiscordChannelConfigWithFallback,
resolveDiscordAllowListMatch,
resolveDiscordGuildEntry,
@@ -283,6 +284,33 @@ function shouldBypassConfiguredAcpEnsure(commandName: string): boolean {
return normalized === "acp" || normalized === "new" || normalized === "reset";
}
function resolveDiscordNativeGroupDmAccess(params: {
isGroupDm: boolean;
groupEnabled?: boolean;
groupChannels?: string[];
channelId: string;
channelName?: string;
channelSlug: string;
}): { allowed: true } | { allowed: false; reason: "disabled" | "not-allowlisted" } {
if (!params.isGroupDm) {
return { allowed: true };
}
if (params.groupEnabled === false) {
return { allowed: false, reason: "disabled" };
}
if (
!resolveGroupDmAllow({
channels: params.groupChannels,
channelId: params.channelId,
channelName: params.channelName,
channelSlug: params.channelSlug,
})
) {
return { allowed: false, reason: "not-allowlisted" };
}
return { allowed: true };
}
async function resolveDiscordNativeAutocompleteAuthorized(params: {
interaction: AutocompleteInteraction;
cfg: ReturnType<typeof loadConfig>;
@@ -421,7 +449,15 @@ async function resolveDiscordNativeAutocompleteAuthorized(params: {
return false;
}
}
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
const groupDmAccess = resolveDiscordNativeGroupDmAccess({
isGroupDm,
groupEnabled: discordConfig?.dm?.groupEnabled,
groupChannels: discordConfig?.dm?.groupChannels,
channelId: rawChannelId,
channelName,
channelSlug,
});
if (!groupDmAccess.allowed) {
return false;
}
if (!isDirectMessage) {
@@ -832,6 +868,22 @@ async function dispatchDiscordCommandInteraction(params: {
return;
}
}
const groupDmAccess = resolveDiscordNativeGroupDmAccess({
isGroupDm,
groupEnabled: discordConfig?.dm?.groupEnabled,
groupChannels: discordConfig?.dm?.groupChannels,
channelId: rawChannelId,
channelName,
channelSlug,
});
if (!groupDmAccess.allowed) {
await respond(
groupDmAccess.reason === "disabled"
? "Discord group DMs are disabled."
: "This group DM is not allowed.",
);
return;
}
if (!isDirectMessage) {
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
@@ -866,10 +918,6 @@ async function dispatchDiscordCommandInteraction(params: {
return;
}
}
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
await respond("Discord group DMs are disabled.");
return;
}
const menu = resolveCommandArgMenu({
command,