Compute plugin callback authorization dynamically [AI] (#78866)

* fix: compute plugin callback command authorization

* addressing codex review

* addressing ci

* addressing ci

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-07 18:05:21 +05:30
committed by GitHub
parent be33b68fd4
commit c65f3bc70e
5 changed files with 427 additions and 10 deletions

View File

@@ -147,6 +147,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987.
- fix(active-memory): require admin scope for global toggles [AI]. (#78863) Thanks @pgondhi987.
- Honor owner enforcement for native commands [AI]. (#78864) Thanks @pgondhi987.
- Config/BlueBubbles: remove the duplicate core-owned BlueBubbles config schema while preserving plugin-owned `dmPolicy` allowFrom validation for channel and account configs. Fixes #69238. Thanks @omarshahine.

View File

@@ -2,6 +2,10 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
resolveCommandAuthorization,
resolveCommandAuthorizedFromAuthorizers,
} from "openclaw/plugin-sdk/command-auth-native";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
@@ -13,7 +17,9 @@ import {
SLACK_REPLY_BUTTON_ACTION_ID,
SLACK_REPLY_SELECT_ACTION_ID,
} from "../../reply-action-ids.js";
import { authorizeSlackSystemEventSender } from "../auth.js";
import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js";
import { authorizeSlackSystemEventSender, resolveSlackEffectiveAllowFrom } from "../auth.js";
import { resolveSlackChannelConfig } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
import {
buildPluginBindingResolvedText,
@@ -673,6 +679,79 @@ async function dispatchSlackPluginInteraction(params: {
return pluginResult.matched && pluginResult.handled;
}
async function resolveSlackBlockActionCommandAuthorized(params: {
ctx: SlackMonitorContext;
parsed: ParsedSlackBlockAction;
auth: { channelType?: "im" | "mpim" | "channel" | "group"; channelName?: string };
}): Promise<boolean> {
const commandsAllowFrom = params.ctx.cfg.commands?.allowFrom;
const commandsAllowFromConfigured =
commandsAllowFrom != null &&
typeof commandsAllowFrom === "object" &&
(Array.isArray(commandsAllowFrom.slack) || Array.isArray(commandsAllowFrom["*"]));
if (commandsAllowFromConfigured) {
return resolveCommandAuthorization({
ctx: {
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
AccountId: params.ctx.accountId,
ChatType: params.auth.channelType === "im" ? "direct" : "group",
From: params.parsed.channelId ? `slack:${params.parsed.channelId}` : "slack",
SenderId: params.parsed.userId,
},
cfg: params.ctx.cfg,
commandAuthorized: false,
}).isAuthorizedSender;
}
const isDirectMessage = params.auth.channelType === "im";
const isRoom = params.auth.channelType === "channel" || params.auth.channelType === "group";
const { allowFromLower } = await resolveSlackEffectiveAllowFrom(params.ctx, {
includePairingStore: isDirectMessage,
});
const sender = await params.ctx.resolveUserName(params.parsed.userId).catch(() => undefined);
const senderName = sender?.name;
const ownerAllowed = resolveSlackAllowListMatch({
allowList: allowFromLower,
id: params.parsed.userId,
name: senderName,
allowNameMatching: params.ctx.allowNameMatching,
}).allowed;
let channelUsersAllowlistConfigured = false;
let channelUserAllowed = false;
if (isRoom && params.parsed.channelId) {
const channelConfig = resolveSlackChannelConfig({
channelId: params.parsed.channelId,
channelName: params.auth.channelName,
channels: params.ctx.channelsConfig,
channelKeys: params.ctx.channelsConfigKeys,
defaultRequireMention: params.ctx.defaultRequireMention,
allowNameMatching: params.ctx.allowNameMatching,
});
channelUsersAllowlistConfigured =
Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
channelUserAllowed = channelUsersAllowlistConfigured
? resolveSlackUserAllowed({
allowList: channelConfig?.users,
userId: params.parsed.userId,
userName: senderName,
allowNameMatching: params.ctx.allowNameMatching,
})
: false;
}
return resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: params.ctx.useAccessGroups,
authorizers: [
{ configured: allowFromLower.length > 0, allowed: ownerAllowed },
{ configured: channelUsersAllowlistConfigured, allowed: channelUserAllowed },
],
modeWhenAccessGroupsOff: "configured",
});
}
function enqueueSlackBlockActionEvent(params: {
ctx: SlackMonitorContext;
parsed: ParsedSlackBlockAction;
@@ -844,12 +923,17 @@ async function handleSlackBlockAction(params: {
return;
}
} else if (pluginInteractionData) {
const isAuthorizedSender = await resolveSlackBlockActionCommandAuthorized({
ctx: params.ctx,
parsed,
auth,
});
const handled = await dispatchSlackPluginInteraction({
ctx: params.ctx,
parsed,
pluginInteractionData,
auth: {
isAuthorizedSender: true,
isAuthorizedSender,
},
respond,
});

View File

@@ -1,8 +1,13 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
type DispatchPluginInteractiveHandlerResult = {
matched: boolean;
handled: boolean;
duplicate: boolean;
};
const dispatchPluginInteractiveHandlerMock = vi.hoisted(() =>
vi.fn(async () => ({
vi.fn<(arg: unknown) => Promise<DispatchPluginInteractiveHandlerResult>>(async () => ({
matched: false,
handled: false,
duplicate: false,
@@ -171,6 +176,7 @@ function createContext(overrides?: {
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
allowFrom?: string[];
allowNameMatching?: boolean;
useAccessGroups?: boolean;
channelsConfig?: Record<string, { users?: string[] }>;
cfg?: Record<string, unknown>;
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
@@ -249,6 +255,7 @@ function createContext(overrides?: {
dmPolicy: overrides?.dmPolicy ?? ("open" as const),
allowFrom: overrides?.allowFrom ?? ["*"],
allowNameMatching: overrides?.allowNameMatching ?? false,
useAccessGroups: overrides?.useAccessGroups ?? true,
channelsConfig: overrides?.channelsConfig ?? {},
channelsConfigKeys: Object.keys(overrides?.channelsConfig ?? {}),
defaultRequireMention: true,
@@ -459,6 +466,9 @@ describe("registerSlackInteractionEvents", () => {
conversationId: "C1",
interactionId: "U123:C1:100.200:123.trigger:codex:approve:thread-1",
threadId: "100.100",
auth: expect.objectContaining({
isAuthorizedSender: true,
}),
interaction: expect.objectContaining({
actionId: "codex",
value: "approve:thread-1",
@@ -472,6 +482,150 @@ describe("registerSlackInteractionEvents", () => {
expect(app.client.chat.update).not.toHaveBeenCalled();
});
it("passes false command auth to Slack plugin interactions for non-allowlisted senders", async () => {
dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({
matched: true,
handled: true,
duplicate: false,
});
const { ctx, getHandler } = createContext({
cfg: {
commands: {
allowFrom: {
slack: ["U_OWNER"],
},
},
},
});
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
body: {
user: { id: "U_ALLOWED" },
channel: { id: "C1" },
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
message: {
ts: "100.200",
text: "fallback",
blocks: [
{
type: "actions",
block_id: "codex_actions",
elements: [{ type: "button", action_id: "codex" }],
},
],
},
},
action: {
type: "button",
action_id: "codex",
block_id: "codex_actions",
value: "approve:thread-1",
},
});
const dispatchCall = dispatchPluginInteractiveHandlerMock.mock.calls[0]?.[0] as
| {
invoke?: (params: {
registration: { handler: (ctx: unknown) => unknown };
namespace: string;
payload: string;
}) => Promise<unknown>;
}
| undefined;
const registrationHandler = vi.fn();
await dispatchCall?.invoke?.({
registration: { handler: registrationHandler },
namespace: "codex",
payload: "approve:thread-1",
});
expect(registrationHandler).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
isAuthorizedSender: false,
}),
}),
);
});
it("passes true command auth to Slack plugin interactions for allowlisted senders", async () => {
dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({
matched: true,
handled: true,
duplicate: false,
});
const { ctx, getHandler } = createContext({
cfg: {
commands: {
allowFrom: {
slack: ["U_OWNER"],
},
},
},
});
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
body: {
user: { id: "U_OWNER" },
channel: { id: "C1" },
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
message: {
ts: "100.200",
text: "fallback",
blocks: [
{
type: "actions",
block_id: "codex_actions",
elements: [{ type: "button", action_id: "codex" }],
},
],
},
},
action: {
type: "button",
action_id: "codex",
block_id: "codex_actions",
value: "approve:thread-1",
},
});
const dispatchCall = dispatchPluginInteractiveHandlerMock.mock.calls[0]?.[0] as
| {
invoke?: (params: {
registration: { handler: (ctx: unknown) => unknown };
namespace: string;
payload: string;
}) => Promise<unknown>;
}
| undefined;
const registrationHandler = vi.fn();
await dispatchCall?.invoke?.({
registration: { handler: registrationHandler },
namespace: "codex",
payload: "approve:thread-1",
});
expect(registrationHandler).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
isAuthorizedSender: true,
}),
}),
);
});
it("treats Slack reply buttons as plain interaction events instead of plugin dispatch", async () => {
const { ctx, app, getHandler } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });

View File

@@ -820,14 +820,14 @@ export const registerTelegramHandlers = ({
return { allowed: true };
};
const isTelegramModelCallbackAuthorized = (params: {
const isTelegramModelCallbackAuthorized = async (params: {
chatId: number;
isGroup: boolean;
senderId: string;
senderUsername: string;
context: TelegramEventAuthorizationContext;
cfg: OpenClawConfig;
}): boolean => {
}): Promise<boolean> => {
const { chatId, isGroup, senderId, senderUsername, context, cfg } = params;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const dmAllowFrom = context.groupAllowOverride ?? allowFrom;
@@ -855,8 +855,14 @@ export const registerTelegramHandlers = ({
}).isAuthorizedSender;
}
const dmAllow = normalizeDmAllowFromWithStore({
const expandedDmAllowFrom = await expandTelegramAllowFromWithAccessGroups({
cfg,
allowFrom: dmAllowFrom,
accountId,
senderId,
});
const dmAllow = normalizeDmAllowFromWithStore({
allowFrom: expandedDmAllowFrom,
storeAllowFrom: isGroup ? [] : context.storeAllowFrom,
dmPolicy: context.dmPolicy,
});
@@ -1410,6 +1416,7 @@ export const registerTelegramHandlers = ({
await replyToCallbackChat(buildPluginBindingResolvedText(resolved));
return;
}
const runtimeCfg = telegramDeps.getRuntimeConfig();
const pluginCallback = await dispatchTelegramPluginInteractiveHandler({
data,
callbackId: callback.id,
@@ -1424,7 +1431,14 @@ export const registerTelegramHandlers = ({
isGroup,
isForum,
auth: {
isAuthorizedSender: true,
isAuthorizedSender: await isTelegramModelCallbackAuthorized({
chatId,
isGroup,
senderId,
senderUsername,
context: eventAuthContext,
cfg: runtimeCfg,
}),
},
callbackMessage: {
messageId: callbackMessage.message_id,
@@ -1460,7 +1474,6 @@ export const registerTelegramHandlers = ({
return;
}
const runtimeCfg = telegramDeps.getRuntimeConfig();
if (approvalCallback) {
const isPluginApproval = approvalCallback.approvalId.startsWith("plugin:");
const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({
@@ -1564,14 +1577,14 @@ export const registerTelegramHandlers = ({
const modelCallback = parseModelCallbackData(data);
if (modelCallback) {
if (
!isTelegramModelCallbackAuthorized({
!(await isTelegramModelCallbackAuthorized({
chatId,
isGroup,
senderId,
senderUsername,
context: eventAuthContext,
cfg: runtimeCfg,
})
}))
) {
logVerbose(
`Blocked telegram model callback from ${senderId || "unknown"} (not authorized for /models)`,

View File

@@ -2185,6 +2185,171 @@ describe("createTelegramBot", () => {
expect(replySpy).not.toHaveBeenCalled();
});
it("passes false command auth to Telegram plugin callbacks for non-allowlisted group senders", async () => {
onSpy.mockClear();
let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined;
const handler = vi.fn(async ({ auth }: TelegramInteractiveHandlerContext) => {
observedAuth = auth;
return { handled: true };
});
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: handler as never,
});
const config = {
commands: {
allowFrom: {
telegram: ["111111111"],
},
},
channels: {
telegram: {
dmPolicy: "open",
capabilities: { inlineButtons: "group" },
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-auth-false",
data: "codexapp:resume:thread-1",
from: { id: 999999999, first_name: "Mallory", username: "mallory" },
message: {
chat: { id: -100999, type: "supergroup", title: "Test Group" },
date: 1736380800,
message_id: 22,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(handler).toHaveBeenCalledOnce();
expect(observedAuth?.isAuthorizedSender).toBe(false);
});
it("passes true command auth to Telegram plugin callbacks for allowlisted group senders", async () => {
onSpy.mockClear();
let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined;
const handler = vi.fn(async ({ auth }: TelegramInteractiveHandlerContext) => {
observedAuth = auth;
return { handled: true };
});
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: handler as never,
});
const config = {
commands: {
allowFrom: {
telegram: ["111111111"],
},
},
channels: {
telegram: {
dmPolicy: "open",
capabilities: { inlineButtons: "group" },
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-auth-true",
data: "codexapp:resume:thread-1",
from: { id: 111111111, first_name: "Ada", username: "ada" },
message: {
chat: { id: -100999, type: "supergroup", title: "Test Group" },
date: 1736380800,
message_id: 23,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(handler).toHaveBeenCalledOnce();
expect(observedAuth?.isAuthorizedSender).toBe(true);
});
it("passes true command auth to Telegram plugin callbacks for access-group DM senders", async () => {
onSpy.mockClear();
let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined;
const handler = vi.fn(async ({ auth }: TelegramInteractiveHandlerContext) => {
observedAuth = auth;
return { handled: true };
});
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codexapp",
handler: handler as never,
});
const config = {
accessGroups: {
operators: {
type: "message.senders",
members: { telegram: ["123456789"] },
},
},
channels: {
telegram: {
dmPolicy: "allowlist",
allowFrom: ["accessGroup:operators"],
capabilities: { inlineButtons: "dm" },
},
},
} satisfies NonNullable<Parameters<typeof createTelegramBot>[0]["config"]>;
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
const callbackHandler = getOnHandler("callback_query") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await callbackHandler({
callbackQuery: {
id: "cbq-plugin-access-group-auth",
data: "codexapp:resume:thread-1",
from: { id: 123456789, first_name: "Ada", username: "ada" },
message: {
chat: { id: 123456789, type: "private" },
date: 1736380800,
message_id: 24,
text: "Select a thread",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(handler).toHaveBeenCalledOnce();
expect(observedAuth?.isAuthorizedSender).toBe(true);
});
it("routes Telegram #General callback payloads as topic 1 when Telegram omits topic metadata", async () => {
onSpy.mockClear();
getChatSpy.mockResolvedValue({ id: -100123456789, type: "supergroup", is_forum: true });