mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(security): enforce trusted sender auth for discord moderation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
157
src/agents/tools/discord-actions-moderation.authz.test.ts
Normal file
157
src/agents/tools/discord-actions-moderation.authz.test.ts
Normal 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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
128
src/discord/send.permissions.authz.test.ts
Normal file
128
src/discord/send.permissions.authz.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -46,6 +46,10 @@ export {
|
||||
export { sendDiscordComponentMessage } from "./send.components.js";
|
||||
export {
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchMemberGuildPermissionsDiscord,
|
||||
hasGuildPermissionDiscord,
|
||||
} from "./send.permissions.js";
|
||||
export {
|
||||
fetchReactionsDiscord,
|
||||
reactMessageDiscord,
|
||||
removeOwnReactionsDiscord,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user