diff --git a/CHANGELOG.md b/CHANGELOG.md index 722fbb2420..36950bbdf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index eb2bb369dc..fbdab56352 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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({ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 340b342770..a5de24a34f 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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, }), ]; diff --git a/src/agents/tools/discord-actions-moderation.authz.test.ts b/src/agents/tools/discord-actions-moderation.authz.test.ts new file mode 100644 index 0000000000..5d6a579989 --- /dev/null +++ b/src/agents/tools/discord-actions-moderation.authz.test.ts @@ -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" }, + ); + }); +}); diff --git a/src/agents/tools/discord-actions-moderation.ts b/src/agents/tools/discord-actions-moderation.ts index bd3a1e4b31..69960f5dce 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/src/agents/tools/discord-actions-moderation.ts @@ -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, isActionEnabled: ActionGate, ): Promise> { 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( { diff --git a/src/agents/tools/message-tool.e2e.test.ts b/src/agents/tools/message-tool.e2e.test.ts index f75eedf7c3..77d4441ae1 100644 --- a/src/agents/tools/message-tool.e2e.test.ts +++ b/src/agents/tools/message-tool.e2e.test.ts @@ -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"); + }); }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 48f1b8013a..f2d2616bad 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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, diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 71d4fe090e..40e10836e3 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -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", () => { diff --git a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts index a52ab21efd..e350fa54a5 100644 --- a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts +++ b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts @@ -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; +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, ); diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 91d0206328..af737223f1 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -1,4 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { ChannelMessageActionContext } from "../../types.js"; import { readNumberParam, readStringArrayParam, @@ -6,7 +7,6 @@ import { } 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 | null | und } export async function handleDiscordMessageAction( - ctx: Pick, + ctx: Pick< + ChannelMessageActionContext, + "action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "toolContext" + >, ): Promise> { const { action, params, cfg } = ctx; const accountId = ctx.accountId ?? readStringParam(params, "accountId"); diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 2178acd5ee..63fde936de 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -304,6 +304,11 @@ export type ChannelMessageActionContext = { cfg: OpenClawConfig; params: Record; 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; diff --git a/src/discord/send.permissions.authz.test.ts b/src/discord/send.permissions.authz.test.ts new file mode 100644 index 0000000000..ed62d22dfd --- /dev/null +++ b/src/discord/send.permissions.authz.test.ts @@ -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); + }); + }); +}); diff --git a/src/discord/send.permissions.ts b/src/discord/send.permissions.ts index c24b6a65bd..38d2e7a218 100644 --- a/src/discord/send.permissions.ts +++ b/src/discord/send.permissions.ts @@ -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 { + const rest = resolveDiscordRest(opts); + try { + const [guild, member] = await Promise.all([ + rest.get(Routes.guild(guildId)) as Promise, + rest.get(Routes.guildMember(guildId, userId)) as Promise, + ]); + const rolesById = new Map((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 { + 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 = {}, diff --git a/src/discord/send.ts b/src/discord/send.ts index ee247ab6a2..fb1c258bfc 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -46,6 +46,10 @@ export { export { sendDiscordComponentMessage } from "./send.components.js"; export { fetchChannelPermissionsDiscord, + fetchMemberGuildPermissionsDiscord, + hasGuildPermissionDiscord, +} from "./send.permissions.js"; +export { fetchReactionsDiscord, reactMessageDiscord, removeOwnReactionsDiscord, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index b48a36ff0b..20cdad8ec4 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -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; defaultAccountId?: string; + requesterSenderId?: string | null; toolContext?: ChannelThreadingToolContext; gateway?: MessageActionRunnerGateway; deps?: OutboundSendDeps; @@ -668,6 +669,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise