mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-01 12:21:25 +00:00
refactor: unify approval command authorization
This commit is contained in:
@@ -401,16 +401,16 @@ originating chat can already send commands and receive replies, approval request
|
||||
separate channel-specific approval client just to stay pending.
|
||||
|
||||
Discord and Telegram also support same-chat `/approve`, but those channels still use their
|
||||
resolved approver list for authorization even when the richer approval client is disabled.
|
||||
resolved approver list for authorization even when native approval delivery is disabled.
|
||||
|
||||
### Rich approval clients
|
||||
### Native approval delivery
|
||||
|
||||
Discord and Telegram can also act as richer exec approval clients with channel-specific config.
|
||||
Discord and Telegram can also act as native approval-delivery adapters with channel-specific config.
|
||||
|
||||
- Discord: `channels.discord.execApprovals.*`
|
||||
- Telegram: `channels.telegram.execApprovals.*`
|
||||
|
||||
These richer clients are opt-in. They add native DM routing, channel fanout, and interactive UI on
|
||||
These native delivery adapters are opt-in. They add DM routing, channel fanout, and interactive UI on
|
||||
top of the shared same-chat `/approve` flow.
|
||||
|
||||
Shared behavior:
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "./directory-config.js";
|
||||
import {
|
||||
getDiscordExecApprovalApprovers,
|
||||
isDiscordExecApprovalApprover,
|
||||
isDiscordExecApprovalClientEnabled,
|
||||
shouldSuppressLocalDiscordExecApprovalPrompt,
|
||||
} from "./exec-approvals.js";
|
||||
@@ -492,6 +493,16 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
},
|
||||
},
|
||||
execApprovals: {
|
||||
authorizeCommand: ({ cfg, accountId, senderId, kind }) =>
|
||||
isDiscordExecApprovalApprover({ cfg, accountId, senderId })
|
||||
? { authorized: true }
|
||||
: {
|
||||
authorized: false,
|
||||
reason:
|
||||
kind === "plugin"
|
||||
? "❌ You are not authorized to approve plugin requests on Discord."
|
||||
: "❌ You are not authorized to approve exec requests on Discord.",
|
||||
},
|
||||
getInitiatingSurfaceState: ({ cfg, accountId }) =>
|
||||
getDiscordExecApprovalApprovers({ cfg, accountId }).length > 0
|
||||
? { kind: "enabled" }
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
} from "./exec-approval-forwarding.js";
|
||||
import {
|
||||
getTelegramExecApprovalApprovers,
|
||||
isTelegramExecApprovalApprover,
|
||||
isTelegramExecApprovalAuthorizedSender,
|
||||
isTelegramExecApprovalClientEnabled,
|
||||
resolveTelegramExecApprovalTarget,
|
||||
} from "./exec-approvals.js";
|
||||
@@ -458,6 +460,22 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
},
|
||||
},
|
||||
execApprovals: {
|
||||
authorizeCommand: ({ cfg, accountId, senderId, kind }) => {
|
||||
const params = { cfg, accountId, senderId };
|
||||
const authorized =
|
||||
kind === "plugin"
|
||||
? isTelegramExecApprovalApprover(params)
|
||||
: isTelegramExecApprovalAuthorizedSender(params);
|
||||
return authorized
|
||||
? { authorized: true }
|
||||
: {
|
||||
authorized: false,
|
||||
reason:
|
||||
kind === "plugin"
|
||||
? "❌ You are not authorized to approve plugin requests on Telegram."
|
||||
: "❌ You are not authorized to approve exec requests on Telegram.",
|
||||
};
|
||||
},
|
||||
getInitiatingSurfaceState: ({ cfg, accountId }) =>
|
||||
getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0
|
||||
? { kind: "enabled" }
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { ErrorCodes } from "../../gateway/protocol/index.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { isDiscordExecApprovalApprover } from "../../plugin-sdk/discord-surface.js";
|
||||
import {
|
||||
isTelegramExecApprovalAuthorizedSender,
|
||||
isTelegramExecApprovalApprover,
|
||||
} from "../../plugin-sdk/telegram-runtime.js";
|
||||
import { resolveApprovalCommandAuthorization } from "../../infra/channel-approval-auth.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
@@ -127,61 +123,19 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
||||
return { shouldContinue: false, reply: { text: parsed.error } };
|
||||
}
|
||||
const isPluginId = parsed.id.startsWith("plugin:");
|
||||
let isTelegramExplicitApprover = false;
|
||||
|
||||
if (params.command.channel === "telegram") {
|
||||
const telegramApproverContext = {
|
||||
cfg: params.cfg,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
};
|
||||
isTelegramExplicitApprover = isTelegramExecApprovalApprover(telegramApproverContext);
|
||||
|
||||
if (!isPluginId && !isTelegramExecApprovalAuthorizedSender(telegramApproverContext)) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ You are not authorized to approve exec requests on Telegram." },
|
||||
};
|
||||
}
|
||||
|
||||
if (isPluginId && !isTelegramExplicitApprover) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ You are not authorized to approve plugin requests on Telegram." },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (params.command.channel === "discord" && !isPluginId) {
|
||||
if (
|
||||
!isDiscordExecApprovalApprover({
|
||||
cfg: params.cfg,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ You are not authorized to approve exec requests on Discord." },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Keep plugin-ID routing independent from exec approval client enablement so
|
||||
// forwarded plugin approvals remain resolvable, but still require explicit
|
||||
// Discord approver membership for security parity.
|
||||
if (
|
||||
params.command.channel === "discord" &&
|
||||
isPluginId &&
|
||||
!isDiscordExecApprovalApprover({
|
||||
cfg: params.cfg,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
})
|
||||
) {
|
||||
const approvalAuthorization = resolveApprovalCommandAuthorization({
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
kind: isPluginId ? "plugin" : "exec",
|
||||
});
|
||||
if (!approvalAuthorization.authorized) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ You are not authorized to approve plugin requests on Discord." },
|
||||
reply: {
|
||||
text: approvalAuthorization.reason ?? "❌ You are not authorized to approve this request.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,7 +175,14 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
||||
await callApprovalMethod("exec.approval.resolve");
|
||||
} catch (err) {
|
||||
if (isApprovalNotFoundError(err)) {
|
||||
if (params.command.channel === "telegram" && !isTelegramExplicitApprover) {
|
||||
const pluginFallbackAuthorization = resolveApprovalCommandAuthorization({
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
kind: "plugin",
|
||||
});
|
||||
if (!pluginFallbackAuthorization.authorized) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
|
||||
|
||||
@@ -466,6 +466,15 @@ export type ChannelLifecycleAdapter = {
|
||||
};
|
||||
|
||||
export type ChannelExecApprovalAdapter = {
|
||||
authorizeCommand?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
kind: "exec" | "plugin";
|
||||
}) => {
|
||||
authorized: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
getInitiatingSurfaceState?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
|
||||
55
src/infra/channel-approval-auth.test.ts
Normal file
55
src/infra/channel-approval-auth.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getChannelPluginMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
|
||||
}));
|
||||
|
||||
import { resolveApprovalCommandAuthorization } from "./channel-approval-auth.js";
|
||||
|
||||
describe("resolveApprovalCommandAuthorization", () => {
|
||||
beforeEach(() => {
|
||||
getChannelPluginMock.mockReset();
|
||||
});
|
||||
|
||||
it("allows commands by default when the channel has no approval override", () => {
|
||||
expect(
|
||||
resolveApprovalCommandAuthorization({
|
||||
cfg: {} as never,
|
||||
channel: "slack",
|
||||
senderId: "U123",
|
||||
kind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
});
|
||||
|
||||
it("delegates to the channel approval override when present", () => {
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
execApprovals: {
|
||||
authorizeCommand: ({ kind }: { kind: "exec" | "plugin" }) =>
|
||||
kind === "plugin" ? { authorized: false, reason: "plugin denied" } : { authorized: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveApprovalCommandAuthorization({
|
||||
cfg: {} as never,
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
senderId: "123",
|
||||
kind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
|
||||
expect(
|
||||
resolveApprovalCommandAuthorization({
|
||||
cfg: {} as never,
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
senderId: "123",
|
||||
kind: "plugin",
|
||||
}),
|
||||
).toEqual({ authorized: false, reason: "plugin denied" });
|
||||
});
|
||||
});
|
||||
24
src/infra/channel-approval-auth.ts
Normal file
24
src/infra/channel-approval-auth.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
|
||||
export function resolveApprovalCommandAuthorization(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string | null;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
kind: "exec" | "plugin";
|
||||
}): { authorized: boolean; reason?: string } {
|
||||
const channel = normalizeMessageChannel(params.channel);
|
||||
if (!channel) {
|
||||
return { authorized: true };
|
||||
}
|
||||
return (
|
||||
getChannelPlugin(channel)?.execApprovals?.authorizeCommand?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
kind: params.kind,
|
||||
}) ?? { authorized: true }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user