Skip to content

Commit 50e2674

Browse files
committed
fix(discord): unify dm command auth gating
1 parent 577becf commit 50e2674

File tree

5 files changed

+263
-111
lines changed

5 files changed

+263
-111
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Docs: https://docs.openclaw.ai
9595
- Plugins/Discovery precedence: load bundled plugins before auto-discovered global extensions so bundled channel plugins win duplicate-ID resolution by default (explicit `plugins.load.paths` overrides remain highest precedence), with loader regression coverage. Landed from contributor PR #29710 by @Sid-Qin. Thanks @Sid-Qin.
9696
- Discord/Reconnect integrity: release Discord message listener lane immediately while preserving serialized handler execution, add HELLO-stall resume-first recovery with bounded fresh-identify fallback after repeated stalls, and extend lifecycle/listener regression coverage for forced reconnect scenarios. Landed from contributor PR #29508 by @cgdusek. Thanks @cgdusek.
9797
- Security/Skills: harden skill installer metadata parsing by rejecting unsafe installer specs (brew/node/go/uv/download) and constrain plugin-declared skill directories to the plugin root (including symlink-escape checks), with regression coverage.
98+
- Discord/DM command auth: unify DM allowlist + pairing-store authorization across message preflight and native command interactions so DM command gating is consistent for `open`/`pairing`/`allowlist` policies.
9899
- ACP/Harness thread spawn routing: force ACP harness thread creation through `sessions_spawn` (`runtime: "acp"`, `thread: true`) and explicitly forbid `message action=thread-create` for ACP harness requests, avoiding misrouted `Unknown channel` errors. (#30957) Thanks @dutifulbob.
99100
- Docs/ACP permissions: document the correct `permissionMode` default (`approve-reads`) and clarify non-interactive permission failure behavior/troubleshooting guidance. (#31044) Thanks @barronlroth.
100101
- Security/Logging utility hardening: remove `eval`-based command execution from `scripts/clawlog.sh`, switch to argv-safe command construction, and escape predicate literals for user-supplied search/category filters to block local command/predicate injection paths.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
3+
4+
describe("resolveDiscordDmCommandAccess", () => {
5+
const sender = {
6+
id: "123",
7+
name: "alice",
8+
tag: "alice#0001",
9+
};
10+
11+
it("allows open DMs and keeps command auth enabled without allowlist entries", async () => {
12+
const result = await resolveDiscordDmCommandAccess({
13+
accountId: "default",
14+
dmPolicy: "open",
15+
configuredAllowFrom: [],
16+
sender,
17+
allowNameMatching: false,
18+
useAccessGroups: true,
19+
readStoreAllowFrom: async () => [],
20+
});
21+
22+
expect(result.decision).toBe("allow");
23+
expect(result.commandAuthorized).toBe(true);
24+
});
25+
26+
it("marks command auth true when sender is allowlisted", async () => {
27+
const result = await resolveDiscordDmCommandAccess({
28+
accountId: "default",
29+
dmPolicy: "open",
30+
configuredAllowFrom: ["discord:123"],
31+
sender,
32+
allowNameMatching: false,
33+
useAccessGroups: true,
34+
readStoreAllowFrom: async () => [],
35+
});
36+
37+
expect(result.decision).toBe("allow");
38+
expect(result.commandAuthorized).toBe(true);
39+
});
40+
41+
it("returns pairing decision and unauthorized command auth for unknown senders", async () => {
42+
const result = await resolveDiscordDmCommandAccess({
43+
accountId: "default",
44+
dmPolicy: "pairing",
45+
configuredAllowFrom: ["discord:456"],
46+
sender,
47+
allowNameMatching: false,
48+
useAccessGroups: true,
49+
readStoreAllowFrom: async () => [],
50+
});
51+
52+
expect(result.decision).toBe("pairing");
53+
expect(result.commandAuthorized).toBe(false);
54+
});
55+
56+
it("authorizes sender from pairing-store allowlist entries", async () => {
57+
const result = await resolveDiscordDmCommandAccess({
58+
accountId: "default",
59+
dmPolicy: "pairing",
60+
configuredAllowFrom: [],
61+
sender,
62+
allowNameMatching: false,
63+
useAccessGroups: true,
64+
readStoreAllowFrom: async () => ["discord:123"],
65+
});
66+
67+
expect(result.decision).toBe("allow");
68+
expect(result.commandAuthorized).toBe(true);
69+
});
70+
71+
it("keeps open DM command auth true when access groups are disabled", async () => {
72+
const result = await resolveDiscordDmCommandAccess({
73+
accountId: "default",
74+
dmPolicy: "open",
75+
configuredAllowFrom: [],
76+
sender,
77+
allowNameMatching: false,
78+
useAccessGroups: false,
79+
readStoreAllowFrom: async () => [],
80+
});
81+
82+
expect(result.decision).toBe("allow");
83+
expect(result.commandAuthorized).toBe(true);
84+
});
85+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
2+
import {
3+
readStoreAllowFromForDmPolicy,
4+
resolveDmGroupAccessWithLists,
5+
type DmGroupAccessDecision,
6+
} from "../../security/dm-policy-shared.js";
7+
import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js";
8+
9+
const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"];
10+
11+
export type DiscordDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
12+
13+
export type DiscordDmCommandAccess = {
14+
decision: DmGroupAccessDecision;
15+
reason: string;
16+
commandAuthorized: boolean;
17+
allowMatch: ReturnType<typeof resolveDiscordAllowListMatch> | { allowed: false };
18+
};
19+
20+
export async function resolveDiscordDmCommandAccess(params: {
21+
accountId: string;
22+
dmPolicy: DiscordDmPolicy;
23+
configuredAllowFrom: string[];
24+
sender: { id: string; name?: string; tag?: string };
25+
allowNameMatching: boolean;
26+
useAccessGroups: boolean;
27+
readStoreAllowFrom?: () => Promise<string[]>;
28+
}): Promise<DiscordDmCommandAccess> {
29+
const storeAllowFrom = params.readStoreAllowFrom
30+
? await params.readStoreAllowFrom().catch(() => [])
31+
: await readStoreAllowFromForDmPolicy({
32+
provider: "discord",
33+
accountId: params.accountId,
34+
dmPolicy: params.dmPolicy,
35+
});
36+
37+
const access = resolveDmGroupAccessWithLists({
38+
isGroup: false,
39+
dmPolicy: params.dmPolicy,
40+
allowFrom: params.configuredAllowFrom,
41+
groupAllowFrom: [],
42+
storeAllowFrom,
43+
isSenderAllowed: (allowEntries) => {
44+
const allowList = normalizeDiscordAllowList(allowEntries, DISCORD_ALLOW_LIST_PREFIXES);
45+
const allowMatch = allowList
46+
? resolveDiscordAllowListMatch({
47+
allowList,
48+
candidate: params.sender,
49+
allowNameMatching: params.allowNameMatching,
50+
})
51+
: { allowed: false };
52+
return allowMatch.allowed;
53+
},
54+
});
55+
56+
const commandAllowList = normalizeDiscordAllowList(
57+
access.effectiveAllowFrom,
58+
DISCORD_ALLOW_LIST_PREFIXES,
59+
);
60+
const allowMatch = commandAllowList
61+
? resolveDiscordAllowListMatch({
62+
allowList: commandAllowList,
63+
candidate: params.sender,
64+
allowNameMatching: params.allowNameMatching,
65+
})
66+
: { allowed: false };
67+
68+
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
69+
useAccessGroups: params.useAccessGroups,
70+
authorizers: [
71+
{
72+
configured: access.effectiveAllowFrom.length > 0,
73+
allowed: allowMatch.allowed,
74+
},
75+
],
76+
modeWhenAccessGroupsOff: "configured",
77+
});
78+
const effectiveCommandAuthorized =
79+
access.decision === "allow" && params.dmPolicy === "open" ? true : commandAuthorized;
80+
81+
return {
82+
decision: access.decision,
83+
reason: access.reason,
84+
commandAuthorized: effectiveCommandAuthorized,
85+
allowMatch,
86+
};
87+
}

src/discord/monitor/message-handler.preflight.ts

Lines changed: 53 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,20 @@ import { buildPairingReply } from "../../pairing/pairing-messages.js";
2828
import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js";
2929
import { resolveAgentRoute } from "../../routing/resolve-route.js";
3030
import { DEFAULT_ACCOUNT_ID, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
31-
import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js";
3231
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
3332
import { sendMessageDiscord } from "../send.js";
3433
import {
3534
allowListMatches,
3635
isDiscordGroupAllowedByPolicy,
3736
normalizeDiscordAllowList,
3837
normalizeDiscordSlug,
39-
resolveDiscordAllowListMatch,
4038
resolveDiscordChannelConfigWithFallback,
4139
resolveDiscordGuildEntry,
4240
resolveDiscordMemberAccessState,
4341
resolveDiscordShouldRequireMention,
4442
resolveGroupDmAllow,
4543
} from "./allow-list.js";
44+
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
4645
import {
4746
formatDiscordUserTag,
4847
resolveDiscordSystemLocation,
@@ -174,76 +173,69 @@ export async function preflightDiscordMessage(
174173
}
175174

176175
const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing";
176+
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
177177
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
178178
let commandAuthorized = true;
179179
if (isDirectMessage) {
180180
if (dmPolicy === "disabled") {
181181
logVerbose("discord: drop dm (dmPolicy: disabled)");
182182
return null;
183183
}
184-
if (dmPolicy !== "open") {
185-
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
186-
provider: "discord",
187-
accountId: resolvedAccountId,
188-
dmPolicy,
189-
});
190-
const effectiveAllowFrom = [...(params.allowFrom ?? []), ...storeAllowFrom];
191-
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
192-
const allowMatch = allowList
193-
? resolveDiscordAllowListMatch({
194-
allowList,
195-
candidate: {
196-
id: sender.id,
197-
name: sender.name,
198-
tag: sender.tag,
199-
},
200-
allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
201-
})
202-
: { allowed: false };
203-
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
204-
const permitted = allowMatch.allowed;
205-
if (!permitted) {
206-
commandAuthorized = false;
207-
if (dmPolicy === "pairing") {
208-
const { code, created } = await upsertChannelPairingRequest({
209-
channel: "discord",
210-
id: author.id,
211-
accountId: resolvedAccountId,
212-
meta: {
213-
tag: formatDiscordUserTag(author),
214-
name: author.username ?? undefined,
215-
},
216-
});
217-
if (created) {
218-
logVerbose(
219-
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`,
220-
);
221-
try {
222-
await sendMessageDiscord(
223-
`user:${author.id}`,
224-
buildPairingReply({
225-
channel: "discord",
226-
idLine: `Your Discord user id: ${author.id}`,
227-
code,
228-
}),
229-
{
230-
token: params.token,
231-
rest: params.client.rest,
232-
accountId: params.accountId,
233-
},
234-
);
235-
} catch (err) {
236-
logVerbose(`discord pairing reply failed for ${author.id}: ${String(err)}`);
237-
}
238-
}
239-
} else {
184+
const dmAccess = await resolveDiscordDmCommandAccess({
185+
accountId: resolvedAccountId,
186+
dmPolicy,
187+
configuredAllowFrom: params.allowFrom ?? [],
188+
sender: {
189+
id: sender.id,
190+
name: sender.name,
191+
tag: sender.tag,
192+
},
193+
allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig),
194+
useAccessGroups,
195+
});
196+
commandAuthorized = dmAccess.commandAuthorized;
197+
if (dmAccess.decision !== "allow") {
198+
const allowMatchMeta = formatAllowlistMatchMeta(
199+
dmAccess.allowMatch.allowed ? dmAccess.allowMatch : undefined,
200+
);
201+
if (dmAccess.decision === "pairing") {
202+
const { code, created } = await upsertChannelPairingRequest({
203+
channel: "discord",
204+
id: author.id,
205+
accountId: resolvedAccountId,
206+
meta: {
207+
tag: formatDiscordUserTag(author),
208+
name: author.username ?? undefined,
209+
},
210+
});
211+
if (created) {
240212
logVerbose(
241-
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
213+
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`,
242214
);
215+
try {
216+
await sendMessageDiscord(
217+
`user:${author.id}`,
218+
buildPairingReply({
219+
channel: "discord",
220+
idLine: `Your Discord user id: ${author.id}`,
221+
code,
222+
}),
223+
{
224+
token: params.token,
225+
rest: params.client.rest,
226+
accountId: params.accountId,
227+
},
228+
);
229+
} catch (err) {
230+
logVerbose(`discord pairing reply failed for ${author.id}: ${String(err)}`);
231+
}
243232
}
244-
return null;
233+
} else {
234+
logVerbose(
235+
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
236+
);
245237
}
246-
commandAuthorized = true;
238+
return null;
247239
}
248240
}
249241

@@ -598,7 +590,6 @@ export async function preflightDiscordMessage(
598590
{ allowNameMatching: isDangerousNameMatchingEnabled(params.discordConfig) },
599591
)
600592
: false;
601-
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
602593
const commandGate = resolveControlCommandGate({
603594
useAccessGroups,
604595
authorizers: [

0 commit comments

Comments
 (0)