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

This commit is contained in:
Peter Steinberger
2026-02-19 15:18:00 +01:00
parent baa335f258
commit 775816035e
15 changed files with 498 additions and 22 deletions

View File

@@ -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.

View File

@@ -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({

View File

@@ -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,
}),
];

View File

@@ -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" },
);
});
});

View File

@@ -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(
{

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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,
);

View File

@@ -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, 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");

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -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 = {},

View File

@@ -46,6 +46,10 @@ export {
export { sendDiscordComponentMessage } from "./send.components.js";
export {
fetchChannelPermissionsDiscord,
fetchMemberGuildPermissionsDiscord,
hasGuildPermissionDiscord,
} from "./send.permissions.js";
export {
fetchReactionsDiscord,
reactMessageDiscord,
removeOwnReactionsDiscord,

View File

@@ -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,