refactor(security): centralize trusted sender checks for discord moderation

This commit is contained in:
Peter Steinberger
2026-02-19 15:39:21 +01:00
parent 81b19aaa1a
commit c9dee59266
11 changed files with 292 additions and 145 deletions

View File

@@ -379,21 +379,6 @@ describe("handleDiscordMessageAction", () => {
expect.any(Object),
);
});
it("rejects moderation when trusted sender id is missing in Discord tool context", async () => {
await expect(
handleDiscordMessageAction({
action: "kick",
params: {
guildId: "guild-1",
userId: "user-2",
},
cfg: {} as OpenClawConfig,
toolContext: { currentChannelProvider: "discord" },
}),
).rejects.toThrow("Sender user ID required for Discord moderation actions.");
expect(handleDiscordAction).not.toHaveBeenCalled();
});
});
describe("telegramMessageActions", () => {

View File

@@ -5,11 +5,15 @@ import {
readStringArrayParam,
readStringParam,
} from "../../../../agents/tools/common.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
} from "../../../../agents/tools/discord-actions-moderation-shared.js";
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
type Ctx = Pick<
ChannelMessageActionContext,
"action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "toolContext"
"action" | "params" | "cfg" | "accountId" | "requesterSenderId"
>;
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
@@ -345,35 +349,25 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
);
}
if (action === "timeout" || action === "kick" || action === "ban") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const userId = readStringParam(actionParams, "userId", { required: true });
const durationMinutes = readNumberParam(actionParams, "durationMin", {
integer: true,
});
const until = readStringParam(actionParams, "until");
const reason = readStringParam(actionParams, "reason");
const deleteMessageDays = readNumberParam(actionParams, "deleteDays", {
integer: true,
if (isDiscordModerationAction(action)) {
const moderation = readDiscordModerationCommand(action, {
...actionParams,
durationMinutes: readNumberParam(actionParams, "durationMin", { integer: true }),
deleteMessageDays: readNumberParam(actionParams, "deleteDays", {
integer: true,
}),
});
const senderUserId = ctx.requesterSenderId?.trim() || undefined;
// In channel/tool flows, require trusted sender identity for moderation authorization.
if (ctx.toolContext?.currentChannelProvider === "discord" && !senderUserId) {
throw new Error("Sender user ID required for Discord moderation actions.");
}
const discordAction = action;
return await handleDiscordAction(
{
action: discordAction,
action: moderation.action,
accountId: accountId ?? undefined,
guildId,
userId,
durationMinutes,
until,
reason,
deleteMessageDays,
guildId: moderation.guildId,
userId: moderation.userId,
durationMinutes: moderation.durationMinutes,
until: moderation.until,
reason: moderation.reason,
deleteMessageDays: moderation.deleteMessageDays,
senderUserId,
},
cfg,

View File

@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { ChannelPlugin } from "./types.js";
import { jsonResult } from "../../agents/tools/common.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { dispatchChannelMessageAction } from "./message-actions.js";
const handleAction = vi.fn(async () => jsonResult({ ok: true }));
const emptyRegistry = createTestRegistry([]);
const discordPlugin: ChannelPlugin = {
id: "discord",
meta: {
id: "discord",
label: "Discord",
selectionLabel: "Discord",
docsPath: "/channels/discord",
blurb: "Discord test plugin.",
},
capabilities: { chatTypes: ["direct", "group"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
actions: {
listActions: () => ["kick"],
supportsAction: ({ action }) => action === "kick",
handleAction,
},
};
describe("dispatchChannelMessageAction trusted sender guard", () => {
beforeEach(() => {
handleAction.mockClear();
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("rejects privileged discord moderation action without trusted sender in tool context", async () => {
await expect(
dispatchChannelMessageAction({
channel: "discord",
action: "kick",
cfg: {} as OpenClawConfig,
params: { guildId: "g1", userId: "u1" },
toolContext: { currentChannelProvider: "discord" },
}),
).rejects.toThrow("Trusted sender identity is required for discord:kick");
expect(handleAction).not.toHaveBeenCalled();
});
it("allows privileged discord moderation action with trusted sender in tool context", async () => {
await dispatchChannelMessageAction({
channel: "discord",
action: "kick",
cfg: {} as OpenClawConfig,
params: { guildId: "g1", userId: "u1" },
requesterSenderId: "trusted-user",
toolContext: { currentChannelProvider: "discord" },
});
expect(handleAction).toHaveBeenCalledOnce();
});
it("does not require trusted sender without tool context", async () => {
await dispatchChannelMessageAction({
channel: "discord",
action: "kick",
cfg: {} as OpenClawConfig,
params: { guildId: "g1", userId: "u1" },
});
expect(handleAction).toHaveBeenCalledOnce();
});
});

View File

@@ -1,7 +1,18 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { getChannelPlugin, listChannelPlugins } from "./index.js";
import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js";
import { getChannelPlugin, listChannelPlugins } from "./index.js";
const trustedRequesterRequiredByChannel: Readonly<
Partial<Record<string, ReadonlySet<ChannelMessageActionName>>>
> = {
discord: new Set<ChannelMessageActionName>(["timeout", "kick", "ban"]),
};
function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean {
const actions = trustedRequesterRequiredByChannel[ctx.channel];
return Boolean(actions?.has(ctx.action) && ctx.toolContext);
}
export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] {
const actions = new Set<ChannelMessageActionName>(["send", "broadcast"]);
@@ -60,6 +71,11 @@ export function supportsChannelMessageCardsForChannel(params: {
export async function dispatchChannelMessageAction(
ctx: ChannelMessageActionContext,
): Promise<AgentToolResult<unknown> | null> {
if (requiresTrustedRequesterSender(ctx) && !ctx.requesterSenderId?.trim()) {
throw new Error(
`Trusted sender identity is required for ${ctx.channel}:${ctx.action} in tool-driven contexts.`,
);
}
const plugin = getChannelPlugin(ctx.channel);
if (!plugin?.actions?.handleAction) {
return null;