VYPR
Low severityNVD Advisory· Published Feb 21, 2026· Updated Feb 24, 2026

OpenClaw Discord moderation authorization used untrusted sender identity in tool-driven flows

CVE-2026-27484

Description

OpenClaw is a personal AI assistant. In versions 2026.2.17 and below, the Discord moderation action handling (timeout, kick, ban) uses sender identity from request parameters in tool-driven flows, instead of trusted runtime sender context. In setups where Discord moderation actions are enabled and the bot has the necessary guild permissions, a non-admin user can request moderation actions by spoofing sender identity fields. This issue has been fixed in version 2026.2.18.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.182026.2.18

Affected products

1

Patches

1
775816035ecc

fix(security): enforce trusted sender auth for discord moderation

https://github.com/openclaw/openclawPeter SteinbergerFeb 19, 2026via ghsa
15 files changed · +498 22
  • CHANGELOG.md+1 0 modified
    @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
     ### Fixes
     
     - Security/Net: harden SSRF IPv4 literal parsing to block octal/hex/short/packed legacy forms (for example `0177.0.0.1`, `127.1`, `2130706433`) in pre-DNS guard checks.
    +- Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting.
     - Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage.
     - Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift.
     - Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance.
    
  • src/agents/openclaw-tools.ts+6 3 modified
    @@ -1,12 +1,12 @@
     import type { OpenClawConfig } from "../config/config.js";
    -import { resolvePluginTools } from "../plugins/tools.js";
     import type { GatewayMessageChannel } from "../utils/message-channel.js";
    -import { resolveSessionAgentId } from "./agent-scope.js";
     import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
    +import type { AnyAgentTool } from "./tools/common.js";
    +import { resolvePluginTools } from "../plugins/tools.js";
    +import { resolveSessionAgentId } from "./agent-scope.js";
     import { createAgentsListTool } from "./tools/agents-list-tool.js";
     import { createBrowserTool } from "./tools/browser-tool.js";
     import { createCanvasTool } from "./tools/canvas-tool.js";
    -import type { AnyAgentTool } from "./tools/common.js";
     import { createCronTool } from "./tools/cron-tool.js";
     import { createGatewayTool } from "./tools/gateway-tool.js";
     import { createImageTool } from "./tools/image-tool.js";
    @@ -61,6 +61,8 @@ export function createOpenClawTools(options?: {
       requireExplicitMessageTarget?: boolean;
       /** If true, omit the message tool from the tool list. */
       disableMessageTool?: boolean;
    +  /** Trusted sender id from inbound context (not tool args). */
    +  requesterSenderId?: string | null;
       /** Whether the requesting sender is an owner. */
       senderIsOwner?: boolean;
     }): AnyAgentTool[] {
    @@ -98,6 +100,7 @@ export function createOpenClawTools(options?: {
             hasRepliedRef: options?.hasRepliedRef,
             sandboxRoot: options?.sandboxRoot,
             requireExplicitTarget: options?.requireExplicitMessageTarget,
    +        requesterSenderId: options?.requesterSenderId ?? undefined,
           });
       const tools: AnyAgentTool[] = [
         createBrowserTool({
    
  • src/agents/pi-tools.ts+4 3 modified
    @@ -7,6 +7,9 @@ import {
     } from "@mariozechner/pi-coding-agent";
     import type { OpenClawConfig } from "../config/config.js";
     import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
    +import type { ModelAuthMode } from "./model-auth.js";
    +import type { AnyAgentTool } from "./pi-tools.types.js";
    +import type { SandboxContext } from "./sandbox.js";
     import { logWarn } from "../logger.js";
     import { getPluginToolMeta } from "../plugins/tools.js";
     import { isSubagentSessionKey } from "../routing/session-key.js";
    @@ -21,7 +24,6 @@ import {
     } from "./bash-tools.js";
     import { listChannelAgentTools } from "./channel-tools.js";
     import { resolveImageSanitizationLimits } from "./image-sanitization.js";
    -import type { ModelAuthMode } from "./model-auth.js";
     import { createOpenClawTools } from "./openclaw-tools.js";
     import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
     import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
    @@ -44,8 +46,6 @@ import {
       wrapToolParamNormalization,
     } from "./pi-tools.read.js";
     import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
    -import type { AnyAgentTool } from "./pi-tools.types.js";
    -import type { SandboxContext } from "./sandbox.js";
     import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
     import {
       applyToolPolicyPipeline,
    @@ -455,6 +455,7 @@ export function createOpenClawCodingTools(options?: {
           requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
           disableMessageTool: options?.disableMessageTool,
           requesterAgentIdOverride: agentId,
    +      requesterSenderId: options?.senderId,
           senderIsOwner: options?.senderIsOwner,
         }),
       ];
    
  • src/agents/tools/discord-actions-moderation.authz.test.ts+157 0 added
    @@ -0,0 +1,157 @@
    +import { PermissionFlagsBits } from "discord-api-types/v10";
    +import { describe, expect, it, vi } from "vitest";
    +import type { DiscordActionConfig } from "../../config/config.js";
    +import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
    +
    +const discordSendMocks = vi.hoisted(() => ({
    +  banMemberDiscord: vi.fn(async () => ({ ok: true })),
    +  kickMemberDiscord: vi.fn(async () => ({ ok: true })),
    +  timeoutMemberDiscord: vi.fn(async () => ({ id: "user-1" })),
    +  hasGuildPermissionDiscord: vi.fn(async () => false),
    +}));
    +
    +const { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord, hasGuildPermissionDiscord } =
    +  discordSendMocks;
    +
    +vi.mock("../../discord/send.js", () => ({
    +  ...discordSendMocks,
    +}));
    +
    +const enableAllActions = (_key: keyof DiscordActionConfig, _defaultValue = true) => true;
    +
    +describe("discord moderation sender authorization", () => {
    +  it("rejects ban when sender lacks BAN_MEMBERS", async () => {
    +    hasGuildPermissionDiscord.mockResolvedValueOnce(false);
    +
    +    await expect(
    +      handleDiscordModerationAction(
    +        "ban",
    +        {
    +          guildId: "guild-1",
    +          userId: "user-1",
    +          senderUserId: "sender-1",
    +        },
    +        enableAllActions,
    +      ),
    +    ).rejects.toThrow("required permissions");
    +
    +    expect(hasGuildPermissionDiscord).toHaveBeenCalledWith(
    +      "guild-1",
    +      "sender-1",
    +      [PermissionFlagsBits.BanMembers],
    +      undefined,
    +    );
    +    expect(banMemberDiscord).not.toHaveBeenCalled();
    +  });
    +
    +  it("rejects kick when sender lacks KICK_MEMBERS", async () => {
    +    hasGuildPermissionDiscord.mockResolvedValueOnce(false);
    +
    +    await expect(
    +      handleDiscordModerationAction(
    +        "kick",
    +        {
    +          guildId: "guild-1",
    +          userId: "user-1",
    +          senderUserId: "sender-1",
    +        },
    +        enableAllActions,
    +      ),
    +    ).rejects.toThrow("required permissions");
    +
    +    expect(hasGuildPermissionDiscord).toHaveBeenCalledWith(
    +      "guild-1",
    +      "sender-1",
    +      [PermissionFlagsBits.KickMembers],
    +      undefined,
    +    );
    +    expect(kickMemberDiscord).not.toHaveBeenCalled();
    +  });
    +
    +  it("rejects timeout when sender lacks MODERATE_MEMBERS", async () => {
    +    hasGuildPermissionDiscord.mockResolvedValueOnce(false);
    +
    +    await expect(
    +      handleDiscordModerationAction(
    +        "timeout",
    +        {
    +          guildId: "guild-1",
    +          userId: "user-1",
    +          senderUserId: "sender-1",
    +          durationMinutes: 60,
    +        },
    +        enableAllActions,
    +      ),
    +    ).rejects.toThrow("required permissions");
    +
    +    expect(hasGuildPermissionDiscord).toHaveBeenCalledWith(
    +      "guild-1",
    +      "sender-1",
    +      [PermissionFlagsBits.ModerateMembers],
    +      undefined,
    +    );
    +    expect(timeoutMemberDiscord).not.toHaveBeenCalled();
    +  });
    +
    +  it("executes moderation action when sender has required permission", async () => {
    +    hasGuildPermissionDiscord.mockResolvedValueOnce(true);
    +    kickMemberDiscord.mockResolvedValueOnce({ ok: true });
    +
    +    await handleDiscordModerationAction(
    +      "kick",
    +      {
    +        guildId: "guild-1",
    +        userId: "user-1",
    +        senderUserId: "sender-1",
    +        reason: "rule violation",
    +      },
    +      enableAllActions,
    +    );
    +
    +    expect(hasGuildPermissionDiscord).toHaveBeenCalledWith(
    +      "guild-1",
    +      "sender-1",
    +      [PermissionFlagsBits.KickMembers],
    +      undefined,
    +    );
    +    expect(kickMemberDiscord).toHaveBeenCalledWith({
    +      guildId: "guild-1",
    +      userId: "user-1",
    +      reason: "rule violation",
    +    });
    +  });
    +
    +  it("forwards accountId into permission check and moderation execution", async () => {
    +    hasGuildPermissionDiscord.mockResolvedValueOnce(true);
    +    timeoutMemberDiscord.mockResolvedValueOnce({ id: "user-1" });
    +
    +    await handleDiscordModerationAction(
    +      "timeout",
    +      {
    +        guildId: "guild-1",
    +        userId: "user-1",
    +        senderUserId: "sender-1",
    +        accountId: "ops",
    +        durationMinutes: 5,
    +      },
    +      enableAllActions,
    +    );
    +
    +    expect(hasGuildPermissionDiscord).toHaveBeenCalledWith(
    +      "guild-1",
    +      "sender-1",
    +      [PermissionFlagsBits.ModerateMembers],
    +      { accountId: "ops" },
    +    );
    +    expect(timeoutMemberDiscord).toHaveBeenCalledWith(
    +      {
    +        guildId: "guild-1",
    +        userId: "user-1",
    +        durationMinutes: 5,
    +        until: undefined,
    +        reason: undefined,
    +      },
    +      { accountId: "ops" },
    +    );
    +  });
    +});
    
  • src/agents/tools/discord-actions-moderation.ts+47 1 modified
    @@ -1,14 +1,42 @@
     import type { AgentToolResult } from "@mariozechner/pi-agent-core";
    +import { PermissionFlagsBits } from "discord-api-types/v10";
     import type { DiscordActionConfig } from "../../config/config.js";
    -import { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord } from "../../discord/send.js";
    +import {
    +  banMemberDiscord,
    +  hasGuildPermissionDiscord,
    +  kickMemberDiscord,
    +  timeoutMemberDiscord,
    +} from "../../discord/send.js";
     import { type ActionGate, jsonResult, readStringParam } from "./common.js";
     
    +async function verifySenderModerationPermission(params: {
    +  guildId: string;
    +  senderUserId?: string;
    +  requiredPermissions: bigint[];
    +  accountId?: string;
    +}) {
    +  // CLI/manual flows may not have sender context; enforce only when present.
    +  if (!params.senderUserId) {
    +    return;
    +  }
    +  const hasPermission = await hasGuildPermissionDiscord(
    +    params.guildId,
    +    params.senderUserId,
    +    params.requiredPermissions,
    +    params.accountId ? { accountId: params.accountId } : undefined,
    +  );
    +  if (!hasPermission) {
    +    throw new Error("Sender does not have required permissions for this moderation action.");
    +  }
    +}
    +
     export async function handleDiscordModerationAction(
       action: string,
       params: Record<string, unknown>,
       isActionEnabled: ActionGate<DiscordActionConfig>,
     ): Promise<AgentToolResult<unknown>> {
       const accountId = readStringParam(params, "accountId");
    +  const senderUserId = readStringParam(params, "senderUserId");
       switch (action) {
         case "timeout": {
           if (!isActionEnabled("moderation", false)) {
    @@ -26,6 +54,12 @@ export async function handleDiscordModerationAction(
               : undefined;
           const until = readStringParam(params, "until");
           const reason = readStringParam(params, "reason");
    +      await verifySenderModerationPermission({
    +        guildId,
    +        senderUserId,
    +        requiredPermissions: [PermissionFlagsBits.ModerateMembers],
    +        accountId,
    +      });
           const member = accountId
             ? await timeoutMemberDiscord(
                 {
    @@ -57,6 +91,12 @@ export async function handleDiscordModerationAction(
             required: true,
           });
           const reason = readStringParam(params, "reason");
    +      await verifySenderModerationPermission({
    +        guildId,
    +        senderUserId,
    +        requiredPermissions: [PermissionFlagsBits.KickMembers],
    +        accountId,
    +      });
           if (accountId) {
             await kickMemberDiscord({ guildId, userId, reason }, { accountId });
           } else {
    @@ -79,6 +119,12 @@ export async function handleDiscordModerationAction(
             typeof params.deleteMessageDays === "number" && Number.isFinite(params.deleteMessageDays)
               ? params.deleteMessageDays
               : undefined;
    +      await verifySenderModerationPermission({
    +        guildId,
    +        senderUserId,
    +        requiredPermissions: [PermissionFlagsBits.BanMembers],
    +        accountId,
    +      });
           if (accountId) {
             await banMemberDiscord(
               {
    
  • src/agents/tools/message-tool.e2e.test.ts+18 0 modified
    @@ -329,4 +329,22 @@ describe("message tool sandbox passthrough", () => {
         const call = mocks.runMessageAction.mock.calls[0]?.[0];
         expect(call?.sandboxRoot).toBeUndefined();
       });
    +
    +  it("forwards trusted requesterSenderId to runMessageAction", async () => {
    +    mockSendResult({ to: "discord:123" });
    +
    +    const tool = createMessageTool({
    +      config: {} as never,
    +      requesterSenderId: "1234567890",
    +    });
    +
    +    await tool.execute("1", {
    +      action: "send",
    +      target: "discord:123",
    +      message: "hi",
    +    });
    +
    +    const call = mocks.runMessageAction.mock.calls[0]?.[0];
    +    expect(call?.requesterSenderId).toBe("1234567890");
    +  });
     });
    
  • src/agents/tools/message-tool.ts+4 2 modified
    @@ -1,4 +1,6 @@
     import { Type } from "@sinclair/typebox";
    +import type { OpenClawConfig } from "../../config/config.js";
    +import type { AnyAgentTool } from "./common.js";
     import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
     import {
       listChannelMessageActions,
    @@ -11,7 +13,6 @@ import {
       CHANNEL_MESSAGE_ACTION_NAMES,
       type ChannelMessageActionName,
     } from "../../channels/plugins/types.js";
    -import type { OpenClawConfig } from "../../config/config.js";
     import { loadConfig } from "../../config/config.js";
     import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
     import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
    @@ -22,7 +23,6 @@ import { normalizeMessageChannel } from "../../utils/message-channel.js";
     import { resolveSessionAgentId } from "../agent-scope.js";
     import { listChannelSupportedActions } from "../channel-tools.js";
     import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
    -import type { AnyAgentTool } from "./common.js";
     import { jsonResult, readNumberParam, readStringParam } from "./common.js";
     import { resolveGatewayOptions } from "./gateway.js";
     
    @@ -429,6 +429,7 @@ type MessageToolOptions = {
       hasRepliedRef?: { value: boolean };
       sandboxRoot?: string;
       requireExplicitTarget?: boolean;
    +  requesterSenderId?: string;
     };
     
     function resolveMessageToolSchemaActions(params: {
    @@ -656,6 +657,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
             action,
             params,
             defaultAccountId: accountId ?? undefined,
    +        requesterSenderId: options?.requesterSenderId,
             gateway,
             toolContext,
             sessionKey: options?.agentSessionKey,
    
  • src/channels/plugins/actions/actions.test.ts+41 0 modified
    @@ -353,6 +353,47 @@ describe("handleDiscordMessageAction", () => {
           expect.any(Object),
         );
       });
    +
    +  it("uses trusted requesterSenderId for moderation and ignores params senderUserId", async () => {
    +    await handleDiscordMessageAction({
    +      action: "timeout",
    +      params: {
    +        guildId: "guild-1",
    +        userId: "user-2",
    +        durationMin: 5,
    +        senderUserId: "spoofed-admin-id",
    +      },
    +      cfg: {} as OpenClawConfig,
    +      requesterSenderId: "trusted-sender-id",
    +      toolContext: { currentChannelProvider: "discord" },
    +    });
    +
    +    expect(handleDiscordAction).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        action: "timeout",
    +        guildId: "guild-1",
    +        userId: "user-2",
    +        durationMinutes: 5,
    +        senderUserId: "trusted-sender-id",
    +      }),
    +      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", () => {
    
  • src/channels/plugins/actions/discord/handle-action.guild-admin.ts+11 2 modified
    @@ -1,13 +1,16 @@
     import type { AgentToolResult } from "@mariozechner/pi-agent-core";
    +import type { ChannelMessageActionContext } from "../../types.js";
     import {
       readNumberParam,
       readStringArrayParam,
       readStringParam,
     } from "../../../../agents/tools/common.js";
     import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
    -import type { ChannelMessageActionContext } from "../../types.js";
     
    -type Ctx = Pick<ChannelMessageActionContext, "action" | "params" | "cfg" | "accountId">;
    +type Ctx = Pick<
    +  ChannelMessageActionContext,
    +  "action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "toolContext"
    +>;
     
     export async function tryHandleDiscordMessageActionGuildAdmin(params: {
       ctx: Ctx;
    @@ -355,6 +358,11 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
         const 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(
           {
    @@ -366,6 +374,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
             until,
             reason,
             deleteMessageDays,
    +        senderUserId,
           },
           cfg,
         );
    
  • src/channels/plugins/actions/discord/handle-action.ts+5 2 modified
    @@ -1,12 +1,12 @@
     import type { AgentToolResult } from "@mariozechner/pi-agent-core";
    +import type { ChannelMessageActionContext } from "../../types.js";
     import {
       readNumberParam,
       readStringArrayParam,
       readStringParam,
     } from "../../../../agents/tools/common.js";
     import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
     import { resolveDiscordChannelId } from "../../../../discord/targets.js";
    -import type { ChannelMessageActionContext } from "../../types.js";
     import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
     
     const providerId = "discord";
    @@ -22,7 +22,10 @@ function readParentIdParam(params: Record<string, unknown>): string | null | und
     }
     
     export async function handleDiscordMessageAction(
    -  ctx: Pick<ChannelMessageActionContext, "action" | "params" | "cfg" | "accountId">,
    +  ctx: Pick<
    +    ChannelMessageActionContext,
    +    "action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "toolContext"
    +  >,
     ): Promise<AgentToolResult<unknown>> {
       const { action, params, cfg } = ctx;
       const accountId = ctx.accountId ?? readStringParam(params, "accountId");
    
  • src/channels/plugins/types.core.ts+5 0 modified
    @@ -304,6 +304,11 @@ export type ChannelMessageActionContext = {
       cfg: OpenClawConfig;
       params: Record<string, unknown>;
       accountId?: string | null;
    +  /**
    +   * Trusted sender id from inbound context. This is server-injected and must
    +   * never be sourced from tool/model-controlled params.
    +   */
    +  requesterSenderId?: string | null;
       gateway?: {
         url?: string;
         token?: string;
    
  • src/discord/send.permissions.authz.test.ts+128 0 added
    @@ -0,0 +1,128 @@
    +import type { RequestClient } from "@buape/carbon";
    +import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
    +import { describe, expect, it, vi } from "vitest";
    +import {
    +  fetchMemberGuildPermissionsDiscord,
    +  hasGuildPermissionDiscord,
    +} from "./send.permissions.js";
    +
    +const mockRest = vi.hoisted(() => ({
    +  get: vi.fn(),
    +}));
    +
    +vi.mock("./client.js", () => ({
    +  resolveDiscordRest: () => mockRest as unknown as RequestClient,
    +}));
    +
    +describe("discord guild permission authorization", () => {
    +  describe("fetchMemberGuildPermissionsDiscord", () => {
    +    it("returns null when user is not a guild member", async () => {
    +      mockRest.get.mockRejectedValueOnce(new Error("404 Member not found"));
    +
    +      const result = await fetchMemberGuildPermissionsDiscord("guild-1", "user-1");
    +      expect(result).toBeNull();
    +    });
    +
    +    it("includes @everyone and member roles in computed permissions", async () => {
    +      mockRest.get.mockImplementation(async (route: string) => {
    +        if (route === Routes.guild("guild-1")) {
    +          return {
    +            id: "guild-1",
    +            roles: [
    +              { id: "guild-1", permissions: PermissionFlagsBits.ViewChannel.toString() },
    +              { id: "role-mod", permissions: PermissionFlagsBits.KickMembers.toString() },
    +            ],
    +          };
    +        }
    +        if (route === Routes.guildMember("guild-1", "user-1")) {
    +          return {
    +            id: "user-1",
    +            roles: ["role-mod"],
    +          };
    +        }
    +        throw new Error(`Unexpected route: ${route}`);
    +      });
    +
    +      const result = await fetchMemberGuildPermissionsDiscord("guild-1", "user-1");
    +      expect(result).not.toBeNull();
    +      expect((result! & PermissionFlagsBits.ViewChannel) === PermissionFlagsBits.ViewChannel).toBe(
    +        true,
    +      );
    +      expect((result! & PermissionFlagsBits.KickMembers) === PermissionFlagsBits.KickMembers).toBe(
    +        true,
    +      );
    +    });
    +  });
    +
    +  describe("hasGuildPermissionDiscord", () => {
    +    it("returns true when user has required permission", async () => {
    +      mockRest.get.mockImplementation(async (route: string) => {
    +        if (route === Routes.guild("guild-1")) {
    +          return {
    +            id: "guild-1",
    +            roles: [
    +              { id: "guild-1", permissions: "0" },
    +              { id: "role-mod", permissions: PermissionFlagsBits.KickMembers.toString() },
    +            ],
    +          };
    +        }
    +        if (route === Routes.guildMember("guild-1", "user-1")) {
    +          return { id: "user-1", roles: ["role-mod"] };
    +        }
    +        throw new Error(`Unexpected route: ${route}`);
    +      });
    +
    +      const result = await hasGuildPermissionDiscord("guild-1", "user-1", [
    +        PermissionFlagsBits.KickMembers,
    +      ]);
    +      expect(result).toBe(true);
    +    });
    +
    +    it("returns true when user has ADMINISTRATOR", async () => {
    +      mockRest.get.mockImplementation(async (route: string) => {
    +        if (route === Routes.guild("guild-1")) {
    +          return {
    +            id: "guild-1",
    +            roles: [
    +              { id: "guild-1", permissions: "0" },
    +              {
    +                id: "role-admin",
    +                permissions: PermissionFlagsBits.Administrator.toString(),
    +              },
    +            ],
    +          };
    +        }
    +        if (route === Routes.guildMember("guild-1", "user-1")) {
    +          return { id: "user-1", roles: ["role-admin"] };
    +        }
    +        throw new Error(`Unexpected route: ${route}`);
    +      });
    +
    +      const result = await hasGuildPermissionDiscord("guild-1", "user-1", [
    +        PermissionFlagsBits.KickMembers,
    +      ]);
    +      expect(result).toBe(true);
    +    });
    +
    +    it("returns false when user lacks all required permissions", async () => {
    +      mockRest.get.mockImplementation(async (route: string) => {
    +        if (route === Routes.guild("guild-1")) {
    +          return {
    +            id: "guild-1",
    +            roles: [{ id: "guild-1", permissions: PermissionFlagsBits.ViewChannel.toString() }],
    +          };
    +        }
    +        if (route === Routes.guildMember("guild-1", "user-1")) {
    +          return { id: "user-1", roles: [] };
    +        }
    +        throw new Error(`Unexpected route: ${route}`);
    +      });
    +
    +      const result = await hasGuildPermissionDiscord("guild-1", "user-1", [
    +        PermissionFlagsBits.BanMembers,
    +        PermissionFlagsBits.KickMembers,
    +      ]);
    +      expect(result).toBe(false);
    +    });
    +  });
    +});
    
  • src/discord/send.permissions.ts+57 1 modified
    @@ -1,8 +1,8 @@
     import type { RequestClient } from "@buape/carbon";
     import type { APIChannel, APIGuild, APIGuildMember, APIRole } from "discord-api-types/v10";
     import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10";
    -import { resolveDiscordRest } from "./client.js";
     import type { DiscordPermissionsSummary, DiscordReactOpts } from "./send.types.js";
    +import { resolveDiscordRest } from "./client.js";
     
     const PERMISSION_ENTRIES = Object.entries(PermissionFlagsBits).filter(
       ([, value]) => typeof value === "bigint",
    @@ -34,6 +34,10 @@ function hasAdministrator(bitfield: bigint) {
       return (bitfield & ADMINISTRATOR_BIT) === ADMINISTRATOR_BIT;
     }
     
    +function hasPermissionBit(bitfield: bigint, permission: bigint) {
    +  return (bitfield & permission) === permission;
    +}
    +
     export function isThreadChannelType(channelType?: number) {
       return (
         channelType === ChannelType.GuildNewsThread ||
    @@ -50,6 +54,58 @@ async function fetchBotUserId(rest: RequestClient) {
       return me.id;
     }
     
    +/**
    + * Fetch guild-level permissions for a user. This does not include channel-specific overwrites.
    + */
    +export async function fetchMemberGuildPermissionsDiscord(
    +  guildId: string,
    +  userId: string,
    +  opts: DiscordReactOpts = {},
    +): Promise<bigint | null> {
    +  const rest = resolveDiscordRest(opts);
    +  try {
    +    const [guild, member] = await Promise.all([
    +      rest.get(Routes.guild(guildId)) as Promise<APIGuild>,
    +      rest.get(Routes.guildMember(guildId, userId)) as Promise<APIGuildMember>,
    +    ]);
    +    const rolesById = new Map<string, APIRole>((guild.roles ?? []).map((role) => [role.id, role]));
    +    const everyoneRole = rolesById.get(guildId);
    +    let permissions = 0n;
    +    if (everyoneRole?.permissions) {
    +      permissions = addPermissionBits(permissions, everyoneRole.permissions);
    +    }
    +    for (const roleId of member.roles ?? []) {
    +      const role = rolesById.get(roleId);
    +      if (role?.permissions) {
    +        permissions = addPermissionBits(permissions, role.permissions);
    +      }
    +    }
    +    return permissions;
    +  } catch {
    +    // Not a guild member, guild not found, or API failure.
    +    return null;
    +  }
    +}
    +
    +/**
    + * Returns true when the user has ADMINISTRATOR or any required permission bit.
    + */
    +export async function hasGuildPermissionDiscord(
    +  guildId: string,
    +  userId: string,
    +  requiredPermissions: bigint[],
    +  opts: DiscordReactOpts = {},
    +): Promise<boolean> {
    +  const permissions = await fetchMemberGuildPermissionsDiscord(guildId, userId, opts);
    +  if (permissions === null) {
    +    return false;
    +  }
    +  if (hasAdministrator(permissions)) {
    +    return true;
    +  }
    +  return requiredPermissions.some((permission) => hasPermissionBit(permissions, permission));
    +}
    +
     export async function fetchChannelPermissionsDiscord(
       channelId: string,
       opts: DiscordReactOpts = {},
    
  • src/discord/send.ts+4 0 modified
    @@ -46,6 +46,10 @@ export {
     export { sendDiscordComponentMessage } from "./send.components.js";
     export {
       fetchChannelPermissionsDiscord,
    +  fetchMemberGuildPermissionsDiscord,
    +  hasGuildPermissionDiscord,
    +} from "./send.permissions.js";
    +export {
       fetchReactionsDiscord,
       reactMessageDiscord,
       removeOwnReactionsDiscord,
    
  • src/infra/outbound/message-action-runner.ts+10 8 modified
    @@ -1,4 +1,12 @@
     import type { AgentToolResult } from "@mariozechner/pi-agent-core";
    +import type {
    +  ChannelId,
    +  ChannelMessageActionName,
    +  ChannelThreadingToolContext,
    +} from "../../channels/plugins/types.js";
    +import type { OpenClawConfig } from "../../config/config.js";
    +import type { OutboundSendDeps } from "./deliver.js";
    +import type { MessagePollResult, MessageSendResult } from "./message.js";
     import { resolveSessionAgentId } from "../../agents/agent-scope.js";
     import {
       readNumberParam,
    @@ -7,12 +15,6 @@ import {
     } from "../../agents/tools/common.js";
     import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
     import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
    -import type {
    -  ChannelId,
    -  ChannelMessageActionName,
    -  ChannelThreadingToolContext,
    -} from "../../channels/plugins/types.js";
    -import type { OpenClawConfig } from "../../config/config.js";
     import {
       isDeliverableMessageChannel,
       normalizeMessageChannel,
    @@ -25,7 +27,6 @@ import {
       resolveMessageChannelSelection,
     } from "./channel-selection.js";
     import { applyTargetToParams } from "./channel-target.js";
    -import type { OutboundSendDeps } from "./deliver.js";
     import {
       hydrateSendAttachmentParams,
       hydrateSetGroupIconParams,
    @@ -39,7 +40,6 @@ import {
       resolveTelegramAutoThreadId,
     } from "./message-action-params.js";
     import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js";
    -import type { MessagePollResult, MessageSendResult } from "./message.js";
     import {
       applyCrossContextDecoration,
       buildCrossContextDecoration,
    @@ -93,6 +93,7 @@ export type RunMessageActionParams = {
       action: ChannelMessageActionName;
       params: Record<string, unknown>;
       defaultAccountId?: string;
    +  requesterSenderId?: string | null;
       toolContext?: ChannelThreadingToolContext;
       gateway?: MessageActionRunnerGateway;
       deps?: OutboundSendDeps;
    @@ -668,6 +669,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
         cfg,
         params,
         accountId: accountId ?? undefined,
    +    requesterSenderId: input.requesterSenderId ?? undefined,
         gateway,
         toolContext: input.toolContext,
         dryRun,
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.