refactor: unify approval command authorization

This commit is contained in:
Peter Steinberger
2026-03-30 07:05:54 +09:00
parent 6ca81f8ec7
commit 6d9a7224aa
7 changed files with 141 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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