Skip to content

Commit 355abe5

Browse files
Discord: enforce approver checks for text approvals (#56015)
* Discord: gate text approvals by approver policy * Discord: require approvers for plugin text approvals * Discord: preserve legacy text approval fallback
1 parent be00fcf commit 355abe5

File tree

3 files changed

+227
-0
lines changed

3 files changed

+227
-0
lines changed

extensions/discord/src/exec-approvals.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ export function isDiscordExecApprovalClientEnabled(params: {
1111
return Boolean(config?.enabled && (config.approvers?.length ?? 0) > 0);
1212
}
1313

14+
export function isDiscordExecApprovalApprover(params: {
15+
cfg: OpenClawConfig;
16+
accountId?: string | null;
17+
senderId?: string | null;
18+
}): boolean {
19+
const senderId = params.senderId?.trim();
20+
if (!senderId) {
21+
return false;
22+
}
23+
const approvers = resolveDiscordAccount(params).config.execApprovals?.approvers ?? [];
24+
return approvers.some((approverId) => String(approverId) === senderId);
25+
}
26+
1427
export function shouldSuppressLocalDiscordExecApprovalPrompt(params: {
1528
cfg: OpenClawConfig;
1629
accountId?: string | null;

src/auto-reply/reply/commands-approve.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
isDiscordExecApprovalApprover,
3+
isDiscordExecApprovalClientEnabled,
4+
} from "../../../extensions/discord/api.js";
15
import { callGateway } from "../../gateway/call.js";
26
import { ErrorCodes } from "../../gateway/protocol/index.js";
37
import { logVerbose } from "../../globals.js";
@@ -126,6 +130,8 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
126130
return { shouldContinue: false, reply: { text: parsed.error } };
127131
}
128132
const isPluginId = parsed.id.startsWith("plugin:");
133+
let discordExecApprovalDeniedReply: { shouldContinue: false; reply: { text: string } } | null =
134+
null;
129135

130136
if (params.command.channel === "telegram") {
131137
const telegramApproverContext = {
@@ -162,6 +168,44 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
162168
}
163169
}
164170

171+
if (params.command.channel === "discord" && !isPluginId) {
172+
const discordApproverContext = {
173+
cfg: params.cfg,
174+
accountId: params.ctx.AccountId,
175+
senderId: params.command.senderId,
176+
};
177+
if (!isDiscordExecApprovalClientEnabled(discordApproverContext)) {
178+
discordExecApprovalDeniedReply = {
179+
shouldContinue: false,
180+
reply: { text: "❌ Discord exec approvals are not enabled for this bot account." },
181+
};
182+
}
183+
if (!discordExecApprovalDeniedReply && !isDiscordExecApprovalApprover(discordApproverContext)) {
184+
discordExecApprovalDeniedReply = {
185+
shouldContinue: false,
186+
reply: { text: "❌ You are not authorized to approve exec requests on Discord." },
187+
};
188+
}
189+
}
190+
191+
// Keep plugin-ID routing independent from exec approval client enablement so
192+
// forwarded plugin approvals remain resolvable, but still require explicit
193+
// Discord approver membership for security parity.
194+
if (
195+
params.command.channel === "discord" &&
196+
isPluginId &&
197+
!isDiscordExecApprovalApprover({
198+
cfg: params.cfg,
199+
accountId: params.ctx.AccountId,
200+
senderId: params.command.senderId,
201+
})
202+
) {
203+
return {
204+
shouldContinue: false,
205+
reply: { text: "❌ You are not authorized to approve plugin requests on Discord." },
206+
};
207+
}
208+
165209
const missingScope = requireGatewayClientScopeForInternalChannel(params, {
166210
label: "/approve",
167211
allowedScopes: ["operator.approvals", "operator.admin"],
@@ -194,6 +238,25 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
194238
};
195239
}
196240
} else {
241+
if (discordExecApprovalDeniedReply) {
242+
// Preserve the legacy unprefixed plugin fallback on Discord even when
243+
// exec approvals are unavailable to this sender.
244+
try {
245+
await callApprovalMethod("plugin.approval.resolve");
246+
} catch (pluginErr) {
247+
if (isApprovalNotFoundError(pluginErr)) {
248+
return discordExecApprovalDeniedReply;
249+
}
250+
return {
251+
shouldContinue: false,
252+
reply: { text: `❌ Failed to submit approval: ${String(pluginErr)}` },
253+
};
254+
}
255+
return {
256+
shouldContinue: false,
257+
reply: { text: `✅ Approval ${parsed.decision} submitted for ${parsed.id}.` },
258+
};
259+
}
197260
try {
198261
await callApprovalMethod("exec.approval.resolve");
199262
} catch (err) {

src/auto-reply/reply/commands.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,24 @@ describe("/approve command", () => {
361361
} as OpenClawConfig;
362362
}
363363

364+
function createDiscordApproveCfg(
365+
execApprovals: {
366+
enabled: boolean;
367+
approvers: string[];
368+
target: "dm" | "channel" | "both";
369+
} | null = { enabled: true, approvers: ["123"], target: "channel" },
370+
): OpenClawConfig {
371+
return {
372+
commands: { text: true },
373+
channels: {
374+
discord: {
375+
allowFrom: ["*"],
376+
...(execApprovals ? { execApprovals } : {}),
377+
},
378+
},
379+
} as OpenClawConfig;
380+
}
381+
364382
it("rejects invalid usage", async () => {
365383
const cfg = {
366384
commands: { text: true },
@@ -414,6 +432,139 @@ describe("/approve command", () => {
414432
);
415433
});
416434

435+
it("requires configured Discord approvers for exec approvals", async () => {
436+
for (const testCase of [
437+
{
438+
name: "discord approvals disabled",
439+
cfg: createDiscordApproveCfg(null),
440+
senderId: "123",
441+
expectedText: "Discord exec approvals are not enabled",
442+
setup: () =>
443+
callGatewayMock.mockRejectedValue(
444+
gatewayError("unknown or expired approval id", "APPROVAL_NOT_FOUND"),
445+
),
446+
expectedGatewayCalls: 1,
447+
},
448+
{
449+
name: "discord non approver",
450+
cfg: createDiscordApproveCfg({ enabled: true, approvers: ["999"], target: "channel" }),
451+
senderId: "123",
452+
expectedText: "not authorized to approve",
453+
setup: () =>
454+
callGatewayMock.mockRejectedValue(
455+
gatewayError("unknown or expired approval id", "APPROVAL_NOT_FOUND"),
456+
),
457+
expectedGatewayCalls: 1,
458+
},
459+
{
460+
name: "discord approver",
461+
cfg: createDiscordApproveCfg({ enabled: true, approvers: ["123"], target: "channel" }),
462+
senderId: "123",
463+
expectedText: "Approval allow-once submitted",
464+
setup: () => callGatewayMock.mockResolvedValue({ ok: true }),
465+
expectedGatewayCalls: 1,
466+
},
467+
] as const) {
468+
callGatewayMock.mockReset();
469+
testCase.setup();
470+
const params = buildParams("/approve abc12345 allow-once", testCase.cfg, {
471+
Provider: "discord",
472+
Surface: "discord",
473+
SenderId: testCase.senderId,
474+
});
475+
476+
const result = await handleCommands(params);
477+
expect(result.shouldContinue, testCase.name).toBe(false);
478+
expect(result.reply?.text, testCase.name).toContain(testCase.expectedText);
479+
expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedGatewayCalls);
480+
if (testCase.expectedGatewayCalls > 0) {
481+
expect(callGatewayMock, testCase.name).toHaveBeenCalledWith(
482+
expect.objectContaining({
483+
method:
484+
testCase.name === "discord approver"
485+
? "exec.approval.resolve"
486+
: "plugin.approval.resolve",
487+
params: { id: "abc12345", decision: "allow-once" },
488+
}),
489+
);
490+
}
491+
}
492+
});
493+
494+
it("preserves legacy unprefixed plugin approval fallback on Discord", async () => {
495+
for (const testCase of [
496+
{
497+
name: "discord legacy plugin approval with exec approvals disabled",
498+
cfg: createDiscordApproveCfg(null),
499+
senderId: "123",
500+
},
501+
{
502+
name: "discord legacy plugin approval for non approver",
503+
cfg: createDiscordApproveCfg({ enabled: true, approvers: ["999"], target: "channel" }),
504+
senderId: "123",
505+
},
506+
] as const) {
507+
callGatewayMock.mockReset();
508+
callGatewayMock.mockResolvedValue({ ok: true });
509+
const params = buildParams("/approve legacy-plugin-123 allow-once", testCase.cfg, {
510+
Provider: "discord",
511+
Surface: "discord",
512+
SenderId: testCase.senderId,
513+
});
514+
515+
const result = await handleCommands(params);
516+
expect(result.shouldContinue, testCase.name).toBe(false);
517+
expect(result.reply?.text, testCase.name).toContain("Approval allow-once submitted");
518+
expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(1);
519+
expect(callGatewayMock, testCase.name).toHaveBeenCalledWith(
520+
expect.objectContaining({
521+
method: "plugin.approval.resolve",
522+
params: { id: "legacy-plugin-123", decision: "allow-once" },
523+
}),
524+
);
525+
}
526+
});
527+
528+
it("requires configured Discord approvers for plugin approvals", async () => {
529+
for (const testCase of [
530+
{
531+
name: "discord plugin non approver",
532+
cfg: createDiscordApproveCfg({ enabled: false, approvers: ["999"], target: "channel" }),
533+
senderId: "123",
534+
expectedText: "not authorized to approve plugin requests",
535+
expectedGatewayCalls: 0,
536+
},
537+
{
538+
name: "discord plugin approver",
539+
cfg: createDiscordApproveCfg({ enabled: false, approvers: ["123"], target: "channel" }),
540+
senderId: "123",
541+
expectedText: "Approval allow-once submitted",
542+
expectedGatewayCalls: 1,
543+
},
544+
] as const) {
545+
callGatewayMock.mockReset();
546+
callGatewayMock.mockResolvedValue({ ok: true });
547+
const params = buildParams("/approve plugin:abc123 allow-once", testCase.cfg, {
548+
Provider: "discord",
549+
Surface: "discord",
550+
SenderId: testCase.senderId,
551+
});
552+
553+
const result = await handleCommands(params);
554+
expect(result.shouldContinue, testCase.name).toBe(false);
555+
expect(result.reply?.text, testCase.name).toContain(testCase.expectedText);
556+
expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedGatewayCalls);
557+
if (testCase.expectedGatewayCalls > 0) {
558+
expect(callGatewayMock, testCase.name).toHaveBeenCalledWith(
559+
expect.objectContaining({
560+
method: "plugin.approval.resolve",
561+
params: { id: "plugin:abc123", decision: "allow-once" },
562+
}),
563+
);
564+
}
565+
}
566+
});
567+
417568
it("rejects unauthorized or invalid Telegram /approve variants", async () => {
418569
for (const testCase of [
419570
{

0 commit comments

Comments
 (0)