From 20945b84b44a62d634967073ed1de743f05fe2fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 23:17:14 +0100 Subject: [PATCH] feat: generalize message access groups (#75813) --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/channels/discord.md | 23 ++++ docs/channels/pairing.md | 26 ++++ .../discord/src/monitor/access-groups.ts | 97 +++++++------- .../src/monitor/agent-components-dm-auth.ts | 2 + .../src/monitor/dm-command-auth.test.ts | 27 ++++ .../discord/src/monitor/dm-command-auth.ts | 26 +++- extensions/discord/src/send.permissions.ts | 8 +- .../send.sends-basic-channel-messages.test.ts | 38 ++++++ .../googlechat/src/monitor-access.test.ts | 83 ++++++++++++ extensions/googlechat/src/monitor-access.ts | 42 +++++- extensions/whatsapp/src/inbound-policy.ts | 39 +++++- .../src/inbound/access-control.test.ts | 111 +++++++++++++++ .../whatsapp/src/inbound/access-control.ts | 55 ++++++-- extensions/zalo/src/monitor.ts | 2 + extensions/zalouser/src/monitor.ts | 2 + src/cli/plugins-cli-test-helpers.ts | 38 +++++- src/config/config-misc.test.ts | 23 ++++ src/config/schema.base.generated.ts | 25 ++++ src/config/types.access-groups.ts | 12 +- src/config/zod-schema.ts | 6 + src/plugin-sdk/access-groups.ts | 126 ++++++++++++++++++ src/plugin-sdk/command-auth.test.ts | 29 +++- src/plugin-sdk/command-auth.ts | 59 +++++++- src/plugin-sdk/direct-dm-access.ts | 30 ++++- src/plugin-sdk/direct-dm.test.ts | 33 +++++ src/plugin-sdk/direct-dm.ts | 1 + src/plugin-sdk/security-runtime.ts | 7 + 30 files changed, 886 insertions(+), 93 deletions(-) create mode 100644 src/plugin-sdk/access-groups.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb0e1e001f..36729d654d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R. - Dependencies: refresh workspace dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, and Marked 18.0.3. Thanks @mariozechner, @aws, and @microsoft. +- Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference `accessGroup:` across channel auth paths. (#75813) Thanks @clawsweeper. ### Fixes diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index d1f18f40238..45a5c39e2ea 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -e14ddc6b9859128d4c5561cf80f322b7b24e0f87dac5bff170afbf2d6a9c3711 config-baseline.json -2b1eac57f1b08b461e4cb9931a766f472c668e18aedd78e2af89541d8b476933 config-baseline.core.json +74530fefef9ed55cab302802bc0be413ec56929e73c12d4bf4f1e4d290813adc config-baseline.json +21db87c2ebec8844e20bf66ea474c08f3adab842234ff334870fe3e8d87995b4 config-baseline.core.json c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json 7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 609ac4e66a4..c4fc063ec91 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -edf54c8ce4c65d44ade9953509b1c3264f4ed12c8bf8eb0a13703a76d185f744 plugin-sdk-api-baseline.json -2418f2484d2d5b40ec8c9b3b92562c76abae43845bb18af0d59706848422555c plugin-sdk-api-baseline.jsonl +0f9284c6349bf03d3d89c1d25031031840dae4ade032622ca212240ed19829f6 plugin-sdk-api-baseline.json +33706cf425386717973cc87357ae5e0df432dd5a519b4faea8b38e21d7daae78 plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 8d68daf8f3d..8a74aa83f0d 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -452,6 +452,29 @@ Example: Discord DMs can use dynamic `accessGroup:` entries in `channels.discord.allowFrom`. + Access group names are shared across message channels. Use `type: "message.senders"` for a static group whose members are expressed in each channel's normal `allowFrom` syntax, or `type: "discord.channelAudience"` when a Discord channel's current `ViewChannel` audience should define membership dynamically. + +```json5 +{ + accessGroups: { + operators: { + type: "message.senders", + members: { + "*": ["global-owner-id"], + discord: ["discord:123456789012345678"], + telegram: ["987654321"], + }, + }, + }, + channels: { + discord: { + dmPolicy: "allowlist", + allowFrom: ["accessGroup:operators"], + }, + }, +} +``` + A Discord text channel has no separate member list. `type: "discord.channelAudience"` models membership as: the DM sender is a member of the configured guild and currently has effective `ViewChannel` permission on the configured channel after role and channel overwrites are applied. Example: allow anyone who can see `#maintainers` to DM the bot, while keeping DMs closed to everyone else. diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 46ad9057bb3..76b2eb0bdee 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -47,6 +47,32 @@ access; they do not add more owners. Supported channels: `bluebubbles`, `discord`, `feishu`, `googlechat`, `imessage`, `irc`, `line`, `matrix`, `mattermost`, `msteams`, `nextcloud-talk`, `nostr`, `openclaw-weixin`, `signal`, `slack`, `synology-chat`, `telegram`, `twitch`, `whatsapp`, `zalo`, `zalouser`. +### Reusable sender groups + +Use top-level `accessGroups` when the same trusted sender set should apply to multiple message channels or to both DM and group allowlists. Static sender groups use `type: "message.senders"` and list members in each channel's normal `allowFrom` syntax. + +```json5 +{ + accessGroups: { + operators: { + type: "message.senders", + members: { + "*": ["global-owner-id"], + discord: ["discord:123456789012345678"], + telegram: ["987654321"], + whatsapp: ["+15551234567"], + }, + }, + }, + channels: { + telegram: { dmPolicy: "allowlist", allowFrom: ["accessGroup:operators"] }, + whatsapp: { groupPolicy: "allowlist", groupAllowFrom: ["accessGroup:operators"] }, + }, +} +``` + +The `"*"` member list is shared by all message channels. Channel-specific lists are checked with that channel's own sender matching rules. + ### Where the state lives Stored under `~/.openclaw/credentials/`: diff --git a/extensions/discord/src/monitor/access-groups.ts b/extensions/discord/src/monitor/access-groups.ts index d2af85ae925..47372f6593e 100644 --- a/extensions/discord/src/monitor/access-groups.ts +++ b/extensions/discord/src/monitor/access-groups.ts @@ -1,17 +1,42 @@ +import { + ACCESS_GROUP_ALLOW_FROM_PREFIX, + parseAccessGroupAllowFromEntry, + resolveAccessGroupAllowFromMatches, + type AccessGroupMembershipResolver, +} from "openclaw/plugin-sdk/command-auth"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { RequestClient } from "../internal/discord.js"; import { canViewDiscordGuildChannel } from "../send.permissions.js"; -export const DISCORD_ACCESS_GROUP_PREFIX = "accessGroup:"; +export const DISCORD_ACCESS_GROUP_PREFIX = ACCESS_GROUP_ALLOW_FROM_PREFIX; export function parseDiscordAccessGroupEntry(entry: string): string | null { - const trimmed = entry.trim(); - if (!trimmed.startsWith(DISCORD_ACCESS_GROUP_PREFIX)) { - return null; - } - const name = trimmed.slice(DISCORD_ACCESS_GROUP_PREFIX.length).trim(); - return name.length > 0 ? name : null; + return parseAccessGroupAllowFromEntry(entry); +} + +export function createDiscordAccessGroupMembershipResolver(params: { + token?: string; + rest?: RequestClient; +}): AccessGroupMembershipResolver { + return async ({ cfg, name, group, accountId, senderId }) => { + if (group.type !== "discord.channelAudience") { + return false; + } + const membership = group.membership ?? "canViewChannel"; + if (membership !== "canViewChannel") { + return false; + } + return await canViewDiscordGuildChannel(group.guildId, group.channelId, senderId, { + cfg, + accountId, + token: params.token, + rest: params.rest, + }).catch((err) => { + logVerbose(`discord: accessGroup:${name} lookup failed for user ${senderId}: ${String(err)}`); + return false; + }); + }; } export async function resolveDiscordDmAccessGroupEntries(params: { @@ -21,50 +46,18 @@ export async function resolveDiscordDmAccessGroupEntries(params: { accountId: string; token?: string; rest?: RequestClient; + isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean; }): Promise { - const names = Array.from( - new Set( - params.allowFrom - .map((entry) => parseDiscordAccessGroupEntry(entry)) - .filter((entry): entry is string => entry != null), - ), - ); - if (names.length === 0 || !params.cfg?.accessGroups) { - return []; - } - - const matched: string[] = []; - for (const name of names) { - const group = params.cfg.accessGroups[name]; - if (!group) { - continue; - } - if (group.type !== "discord.channelAudience") { - continue; - } - const membership = group.membership ?? "canViewChannel"; - if (membership !== "canViewChannel") { - continue; - } - const allowed = await canViewDiscordGuildChannel( - group.guildId, - group.channelId, - params.sender.id, - { - cfg: params.cfg, - accountId: params.accountId, - token: params.token, - rest: params.rest, - }, - ).catch((err) => { - logVerbose( - `discord: accessGroup:${name} lookup failed for user ${params.sender.id}: ${String(err)}`, - ); - return false; - }); - if (allowed) { - matched.push(`${DISCORD_ACCESS_GROUP_PREFIX}${name}`); - } - } - return matched; + return await resolveAccessGroupAllowFromMatches({ + cfg: params.cfg, + allowFrom: params.allowFrom, + channel: "discord", + accountId: params.accountId, + senderId: params.sender.id, + isSenderAllowed: params.isSenderAllowed, + resolveMembership: createDiscordAccessGroupMembershipResolver({ + token: params.token, + rest: params.rest, + }), + }); } diff --git a/extensions/discord/src/monitor/agent-components-dm-auth.ts b/extensions/discord/src/monitor/agent-components-dm-auth.ts index 4e36769b371..2479f94f003 100644 --- a/extensions/discord/src/monitor/agent-components-dm-auth.ts +++ b/extensions/discord/src/monitor/agent-components-dm-auth.ts @@ -57,6 +57,8 @@ async function ensureDmComponentAuthorized(params: { sender: { id: user.id }, accountId: ctx.accountId, token: ctx.token, + isSenderAllowed: (senderId, allowFrom) => + resolveAllowMatch(allowFrom).allowed || allowFrom.includes(senderId), }); return matchedGroups.length > 0 ? resolveAllowMatch([...entries, `discord:${user.id}`]) diff --git a/extensions/discord/src/monitor/dm-command-auth.test.ts b/extensions/discord/src/monitor/dm-command-auth.test.ts index 0c9711153dc..cdcbd98a600 100644 --- a/extensions/discord/src/monitor/dm-command-auth.test.ts +++ b/extensions/discord/src/monitor/dm-command-auth.test.ts @@ -127,6 +127,33 @@ describe("resolveDiscordDmCommandAccess", () => { expect(result.commandAuthorized).toBe(true); }); + it("authorizes allowlist DMs from a generic message sender access group", async () => { + const result = await resolveDiscordDmCommandAccess({ + accountId: "default", + dmPolicy: "allowlist", + configuredAllowFrom: ["accessGroup:owners"], + sender, + allowNameMatching: false, + useAccessGroups: true, + cfg: { + accessGroups: { + owners: { + type: "message.senders", + members: { + discord: ["discord:123"], + telegram: ["987"], + }, + }, + }, + }, + readStoreAllowFrom: async () => [], + }); + + expect(canViewDiscordGuildChannelMock).not.toHaveBeenCalled(); + expect(result.decision).toBe("allow"); + expect(result.commandAuthorized).toBe(true); + }); + it("fails closed when a Discord channel audience access group lookup rejects", async () => { canViewDiscordGuildChannelMock.mockRejectedValueOnce(new Error("missing intent")); diff --git a/extensions/discord/src/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts index 189ebc6683c..16d82879151 100644 --- a/extensions/discord/src/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -1,3 +1,4 @@ +import { expandAllowFromWithAccessGroups } from "openclaw/plugin-sdk/command-auth"; import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { @@ -6,7 +7,7 @@ import { type DmGroupAccessDecision, } from "openclaw/plugin-sdk/security-runtime"; import type { RequestClient } from "../internal/discord.js"; -import { resolveDiscordDmAccessGroupEntries } from "./access-groups.js"; +import { createDiscordAccessGroupMembershipResolver } from "./access-groups.js"; import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js"; const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"]; @@ -50,11 +51,24 @@ async function expandAllowFromWithDiscordAccessGroups(params: { token?: string; rest?: RequestClient; }) { - const matchedGroups = await resolveDiscordDmAccessGroupEntries(params); - if (matchedGroups.length === 0) { - return params.allowFrom; - } - return [...params.allowFrom, `discord:${params.sender.id}`]; + return await expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: params.allowFrom, + channel: "discord", + accountId: params.accountId, + senderId: params.sender.id, + senderAllowEntry: `discord:${params.sender.id}`, + isSenderAllowed: (senderId, allowFrom) => + resolveSenderAllowMatch({ + allowEntries: allowFrom, + sender: { id: senderId }, + allowNameMatching: false, + }).allowed, + resolveMembership: createDiscordAccessGroupMembershipResolver({ + token: params.token, + rest: params.rest, + }), + }); } export async function resolveDiscordDmCommandAccess(params: { diff --git a/extensions/discord/src/send.permissions.ts b/extensions/discord/src/send.permissions.ts index a6ab93ca87f..4a826fcfa38 100644 --- a/extensions/discord/src/send.permissions.ts +++ b/extensions/discord/src/send.permissions.ts @@ -105,12 +105,16 @@ function resolveMemberChannelPermissionBits(params: { permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); } } + let roleDeny = 0n; + let roleAllow = 0n; for (const overwrite of overwrites) { if (params.member.roles?.includes(overwrite.id)) { - permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); - permissions = addPermissionBits(permissions, overwrite.allow ?? "0"); + roleDeny = addPermissionBits(roleDeny, overwrite.deny ?? "0"); + roleAllow = addPermissionBits(roleAllow, overwrite.allow ?? "0"); } } + permissions = permissions & ~roleDeny; + permissions = permissions | roleAllow; for (const overwrite of overwrites) { if (overwrite.id === params.userId) { permissions = removePermissionBits(permissions, overwrite.deny ?? "0"); diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index 77efc7e6e3a..91d6865ed9f 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -735,6 +735,44 @@ describe("fetchChannelPermissionsDiscord", () => { ).resolves.toBe(true); }); + it("aggregates conflicting role overwrites before applying allows", async () => { + const { rest, getMock } = makeDiscordRest(); + getMock + .mockResolvedValueOnce({ + id: "chan1", + guild_id: "guild1", + permission_overwrites: [ + { + id: "role-allow", + deny: "0", + allow: PermissionFlagsBits.ViewChannel.toString(), + }, + { + id: "role-deny", + deny: PermissionFlagsBits.ViewChannel.toString(), + allow: "0", + }, + ], + }) + .mockResolvedValueOnce({ + id: "guild1", + roles: [ + { id: "guild1", permissions: "0" }, + { id: "role-allow", permissions: "0" }, + { id: "role-deny", permissions: "0" }, + ], + }) + .mockResolvedValueOnce({ roles: ["role-allow", "role-deny"] }); + + await expect( + canViewDiscordGuildChannel("guild1", "chan1", "user1", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }), + ).resolves.toBe(true); + }); + it("fails closed when the channel belongs to a different guild", async () => { const { rest, getMock } = makeDiscordRest(); getMock.mockResolvedValueOnce({ diff --git a/extensions/googlechat/src/monitor-access.test.ts b/extensions/googlechat/src/monitor-access.test.ts index 042e44f7585..a3902694f03 100644 --- a/extensions/googlechat/src/monitor-access.test.ts +++ b/extensions/googlechat/src/monitor-access.test.ts @@ -216,6 +216,89 @@ describe("googlechat inbound access policy", () => { }); }); + it("allows group traffic from generic message sender access groups", async () => { + primeCommonDefaults(); + allowInboundGroupTraffic(); + + await expect( + applyInboundAccessPolicy({ + config: { + ...baseAccessConfig, + accessGroups: { + operators: { + type: "message.senders", + members: { + googlechat: ["users/alice"], + }, + }, + }, + } as never, + account: { + accountId: "default", + config: { + groups: { + "spaces/AAA": { + users: ["accessGroup:operators"], + requireMention: false, + }, + }, + }, + } as never, + }), + ).resolves.toMatchObject({ + ok: true, + }); + }); + + it("expands generic message sender access groups before DM access checks", async () => { + primeCommonDefaults(); + const readAllowFromStore = vi.fn(async () => []); + createChannelPairingController.mockReturnValue({ + readAllowFromStore, + issueChallenge: vi.fn(), + }); + resolveDmGroupAccessWithLists.mockReturnValue({ + decision: "allow", + effectiveAllowFrom: ["accessGroup:operators", "users/alice"], + effectiveGroupAllowFrom: [], + }); + + await expect( + applyInboundAccessPolicy({ + isGroup: false, + config: { + ...baseAccessConfig, + accessGroups: { + operators: { + type: "message.senders", + members: { + googlechat: ["users/alice"], + }, + }, + }, + } as never, + account: { + accountId: "default", + config: { + dm: { + policy: "allowlist", + allowFrom: ["accessGroup:operators"], + }, + }, + } as never, + }), + ).resolves.toMatchObject({ + ok: true, + }); + + expect(resolveDmGroupAccessWithLists).toHaveBeenCalledWith( + expect.objectContaining({ + allowFrom: ["accessGroup:operators", "users/alice"], + }), + ); + expect(readAllowFromStore).not.toHaveBeenCalled(); + }); + it("preserves allowlist group policy when a routed space has no sender allowlist", async () => { primeCommonDefaults(); allowInboundGroupTraffic({ diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index 6d44ec7345c..79311168707 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -1,4 +1,5 @@ import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound"; +import { expandAllowFromWithAccessGroups } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -204,6 +205,16 @@ export async function applyGoogleChatInboundAccessPolicy(params: { }); const groupEntry = groupConfigResolved.entry; const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? []; + const isGoogleChatSenderAllowed = (_senderId: string, allowFrom: string[]) => + isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching); + const expandedGroupUsers = await expandAllowFromWithAccessGroups({ + cfg: config, + allowFrom: groupUsers, + channel: "googlechat", + accountId: account.accountId, + senderId, + isSenderAllowed: isGoogleChatSenderAllowed, + }); let effectiveWasMentioned: boolean | undefined; if (isGroup) { @@ -231,10 +242,9 @@ export async function applyGoogleChatInboundAccessPolicy(params: { return { ok: false }; } - if (groupUsers.length > 0) { - const normalizedGroupUsers = groupUsers.map((v) => String(v)); - warnDeprecatedUsersEmailEntries(logVerbose, normalizedGroupUsers); - const ok = isSenderAllowed(senderId, senderEmail, normalizedGroupUsers, allowNameMatching); + if (expandedGroupUsers.length > 0) { + warnDeprecatedUsersEmailEntries(logVerbose, expandedGroupUsers); + const ok = isSenderAllowed(senderId, senderEmail, expandedGroupUsers, allowNameMatching); if (!ok) { logVerbose(`drop group message (sender not allowed, ${senderId})`); return { ok: false }; @@ -243,8 +253,8 @@ export async function applyGoogleChatInboundAccessPolicy(params: { } const dmPolicy = account.config.dm?.policy ?? "pairing"; - const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); - const normalizedGroupUsers = groupUsers.map((v) => String(v)); + const rawConfigAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); + const normalizedGroupUsers = expandedGroupUsers; const senderGroupPolicy = groupConfigResolved.allowlistConfigured && normalizedGroupUsers.length === 0 ? groupPolicy @@ -257,13 +267,31 @@ export async function applyGoogleChatInboundAccessPolicy(params: { !isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open" ? await pairing.readAllowFromStore().catch(() => []) : []; + const [configAllowFrom, effectiveStoreAllowFrom] = await Promise.all([ + expandAllowFromWithAccessGroups({ + cfg: config, + allowFrom: rawConfigAllowFrom, + channel: "googlechat", + accountId: account.accountId, + senderId, + isSenderAllowed: isGoogleChatSenderAllowed, + }), + expandAllowFromWithAccessGroups({ + cfg: config, + allowFrom: storeAllowFrom, + channel: "googlechat", + accountId: account.accountId, + senderId, + isSenderAllowed: isGoogleChatSenderAllowed, + }), + ]); const access = resolveDmGroupAccessWithLists({ isGroup, dmPolicy, groupPolicy: senderGroupPolicy, allowFrom: configAllowFrom, groupAllowFrom: normalizedGroupUsers, - storeAllowFrom, + storeAllowFrom: effectiveStoreAllowFrom, groupAllowFromFallbackToAllowFrom: false, isSenderAllowed: (allowFrom) => isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching), diff --git a/extensions/whatsapp/src/inbound-policy.ts b/extensions/whatsapp/src/inbound-policy.ts index 9601fcabdd8..acc166e4d94 100644 --- a/extensions/whatsapp/src/inbound-policy.ts +++ b/extensions/whatsapp/src/inbound-policy.ts @@ -10,6 +10,7 @@ import type { } from "openclaw/plugin-sdk/config-types"; import { resolveDefaultGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; import { + expandAllowFromWithAccessGroups, readStoreAllowFromForDmPolicy, resolveEffectiveAllowFromLists, resolveDmGroupAccessWithCommandGate, @@ -177,13 +178,45 @@ export async function resolveWhatsAppCommandAuthorized(params: { dmPolicy: policy.dmPolicy, shouldRead: policy.shouldReadStorePairingApprovals, }); + const isSenderAllowed = (senderId: string, allowEntries: string[]) => + isGroup + ? policy.isGroupSenderAllowed(allowEntries, senderId) + : policy.isDmSenderAllowed(allowEntries, senderId); + const [allowFrom, groupAllowFrom] = await Promise.all([ + expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: policy.dmAllowFrom, + channel: "whatsapp", + accountId: policy.account.accountId, + senderId: normalizedSender, + isSenderAllowed, + }), + expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: policy.groupAllowFrom, + channel: "whatsapp", + accountId: policy.account.accountId, + senderId: normalizedSender, + isSenderAllowed, + }), + ]); + const dmStoreAllowFrom = isGroup + ? [] + : await expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: storeAllowFrom, + channel: "whatsapp", + accountId: policy.account.accountId, + senderId: normalizedSender, + isSenderAllowed, + }); const access = resolveDmGroupAccessWithCommandGate({ isGroup, dmPolicy: policy.dmPolicy, groupPolicy: policy.groupPolicy, - allowFrom: policy.dmAllowFrom, - groupAllowFrom: policy.groupAllowFrom, - storeAllowFrom, + allowFrom, + groupAllowFrom, + storeAllowFrom: dmStoreAllowFrom, isSenderAllowed: (allowEntries) => isGroup ? policy.isGroupSenderAllowed(allowEntries, groupSender) diff --git a/extensions/whatsapp/src/inbound/access-control.test.ts b/extensions/whatsapp/src/inbound/access-control.test.ts index 376b3ec2e9a..d3a9ed7cbab 100644 --- a/extensions/whatsapp/src/inbound/access-control.test.ts +++ b/extensions/whatsapp/src/inbound/access-control.test.ts @@ -59,6 +59,29 @@ async function checkCommandAuthorizedForDm(params: { }); } +async function checkCommandAuthorizedForGroup(params: { + cfg: Record; + accountId?: string; + from?: string; + senderE164?: string; + selfE164?: string; +}) { + return await resolveWhatsAppCommandAuthorized({ + cfg: params.cfg as never, + msg: { + accountId: params.accountId ?? "work", + chatType: "group", + from: params.from ?? "120363401234567890@g.us", + conversationId: params.from ?? "120363401234567890@g.us", + chatId: params.from ?? "120363401234567890@g.us", + senderE164: params.senderE164 ?? "+15550001111", + selfE164: params.selfE164 ?? "+15550009999", + body: "/status", + to: params.selfE164 ?? "+15550009999", + } as never, + }); +} + describe("checkInboundAccessControl pairing grace", () => { async function runPairingGraceCase(messageTimestampMs: number) { const connectedAtMs = 1_000_000; @@ -206,6 +229,94 @@ describe("WhatsApp dmPolicy precedence", () => { expect(sendMessageMock).not.toHaveBeenCalled(); }); + it("allows DMs from generic message sender access groups", async () => { + const cfg = { + accessGroups: { + owners: { + type: "message.senders", + members: { + whatsapp: ["+15550001111"], + }, + }, + }, + channels: { + whatsapp: { + dmPolicy: "allowlist", + accounts: { + work: { + allowFrom: ["accessGroup:owners"], + }, + }, + }, + }, + }; + setAccessControlTestConfig(cfg); + + const result = await checkInboundAccessControl({ + cfg: getAccessControlTestConfig() as never, + accountId: "work", + from: "+15550001111", + selfE164: "+15550009999", + senderE164: "+15550001111", + group: false, + pushName: "Sam", + isFromMe: false, + sock: { sendMessage: sendMessageMock }, + remoteJid: "15550001111@s.whatsapp.net", + }); + const commandAuthorized = await checkCommandAuthorizedForDm({ cfg }); + + expect(result.allowed).toBe(true); + expect(commandAuthorized).toBe(true); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + + it("allows group messages from generic message sender access groups", async () => { + const cfg = { + accessGroups: { + operators: { + type: "message.senders", + members: { + whatsapp: ["+15550001111"], + }, + }, + }, + channels: { + whatsapp: { + dmPolicy: "allowlist", + groupPolicy: "allowlist", + groupAllowFrom: ["accessGroup:operators"], + accounts: { + work: { + allowFrom: ["+15559999999"], + }, + }, + }, + }, + }; + setAccessControlTestConfig(cfg); + + const result = await checkInboundAccessControl({ + cfg: getAccessControlTestConfig() as never, + accountId: "work", + from: "120363401234567890@g.us", + selfE164: "+15550009999", + senderE164: "+15550001111", + group: true, + pushName: "Sam", + isFromMe: false, + sock: { sendMessage: sendMessageMock }, + remoteJid: "120363401234567890@g.us", + }); + const commandAuthorized = await checkCommandAuthorizedForGroup({ cfg }); + + expect(result.allowed).toBe(true); + expect(commandAuthorized).toBe(true); + expect(upsertPairingRequestMock).not.toHaveBeenCalled(); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + it("does not broaden self-chat mode to every paired DM when allowFrom is empty", async () => { const cfg = { channels: { diff --git a/extensions/whatsapp/src/inbound/access-control.ts b/extensions/whatsapp/src/inbound/access-control.ts index 22e56def137..1a06341c34e 100644 --- a/extensions/whatsapp/src/inbound/access-control.ts +++ b/extensions/whatsapp/src/inbound/access-control.ts @@ -4,6 +4,7 @@ import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-ru import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env"; import { warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy"; import { + expandAllowFromWithAccessGroups, readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, } from "openclaw/plugin-sdk/security-runtime"; @@ -48,12 +49,14 @@ export async function checkInboundAccessControl(params: { accountId: params.accountId, selfE164: params.selfE164, }); - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "whatsapp", - accountId: policy.account.accountId, - dmPolicy: policy.dmPolicy, - shouldRead: policy.shouldReadStorePairingApprovals, - }); + const storeAllowFrom = params.group + ? [] + : await readStoreAllowFromForDmPolicy({ + provider: "whatsapp", + accountId: policy.account.accountId, + dmPolicy: policy.dmPolicy, + shouldRead: policy.shouldReadStorePairingApprovals, + }); const pairingGraceMs = typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0 ? params.pairingGraceMs @@ -73,13 +76,47 @@ export async function checkInboundAccessControl(params: { accountId: policy.account.accountId, log: (message) => logWhatsAppVerbose(params.verbose, message), }); + const accessGroupSenderId = params.group ? (params.senderE164 ?? params.from) : params.from; + const isAccessGroupSenderAllowed = (senderId: string, allowEntries: string[]) => { + return params.group + ? policy.isGroupSenderAllowed(allowEntries, senderId) + : policy.isDmSenderAllowed(allowEntries, senderId); + }; + const [allowFrom, groupAllowFrom] = await Promise.all([ + expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: params.group ? policy.configuredAllowFrom : policy.dmAllowFrom, + channel: "whatsapp", + accountId: policy.account.accountId, + senderId: accessGroupSenderId, + isSenderAllowed: isAccessGroupSenderAllowed, + }), + expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: policy.groupAllowFrom, + channel: "whatsapp", + accountId: policy.account.accountId, + senderId: accessGroupSenderId, + isSenderAllowed: isAccessGroupSenderAllowed, + }), + ]); + const dmStoreAllowFrom = params.group + ? [] + : await expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: storeAllowFrom, + channel: "whatsapp", + accountId: policy.account.accountId, + senderId: accessGroupSenderId, + isSenderAllowed: isAccessGroupSenderAllowed, + }); const access = resolveDmGroupAccessWithLists({ isGroup: params.group, dmPolicy: policy.dmPolicy, groupPolicy: policy.groupPolicy, - allowFrom: params.group ? policy.configuredAllowFrom : policy.dmAllowFrom, - groupAllowFrom: policy.groupAllowFrom, - storeAllowFrom, + allowFrom, + groupAllowFrom, + storeAllowFrom: dmStoreAllowFrom, isSenderAllowed: (allowEntries) => { return params.group ? policy.isGroupSenderAllowed(allowEntries, params.senderE164) diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 2bcf0236813..8c4c112e926 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -469,6 +469,8 @@ async function authorizeZaloMessage( configuredGroupAllowFrom: groupAllowFrom, senderId, isSenderAllowed: isZaloSenderAllowed, + channel: "zalo", + accountId: account.accountId, readAllowFromStore: pairing.readAllowFromStore, runtime: core.channel.commands, }); diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 878f4d0e124..af4770168b8 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -436,6 +436,8 @@ async function processMessage( configuredGroupAllowFrom: configGroupAllowFrom, senderId, isSenderAllowed, + channel: "zalouser", + accountId: account.accountId, readAllowFromStore: async () => storeAllowFrom, shouldComputeCommandAuthorized: (body, cfg) => core.channel.commands.shouldComputeCommandAuthorized(body, cfg), diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index a0bbb9f6c9d..8341acfc58d 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -5,7 +5,7 @@ import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { createEmptyUninstallActions } from "../plugins/uninstall.js"; -import { createCliRuntimeCapture } from "./test-runtime-capture.js"; +import type { CliMockOutputRuntime } from "./test-runtime-capture.js"; type UnknownMock = Mock<(...args: unknown[]) => unknown>; type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise>; @@ -81,8 +81,40 @@ export const installHooksFromNpmSpec: AsyncUnknownMock = vi.fn(); export const installHooksFromPath: AsyncUnknownMock = vi.fn(); export const recordHookInstall: UnknownMock = vi.fn(); -const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = - createCliRuntimeCapture(); +const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = vi.hoisted(() => { + const runtimeLogs: string[] = []; + const runtimeErrors: string[] = []; + const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" "); + const normalizeStdout = (value: string) => (value.endsWith("\n") ? value.slice(0, -1) : value); + const stringifyJson = (value: unknown, space = 2) => + JSON.stringify(value, null, space > 0 ? space : undefined); + const defaultRuntime = { + log: vi.fn((...args: unknown[]) => { + runtimeLogs.push(stringifyArgs(args)); + }), + error: vi.fn((...args: unknown[]) => { + runtimeErrors.push(stringifyArgs(args)); + }), + writeStdout: vi.fn((value: string) => { + defaultRuntime.log(normalizeStdout(value)); + }), + writeJson: vi.fn((value: unknown, space = 2) => { + defaultRuntime.log(stringifyJson(value, space)); + }), + exit: vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); + }), + } as CliMockOutputRuntime; + return { + defaultRuntime, + runtimeLogs, + runtimeErrors, + resetRuntimeCapture: () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + }, + }; +}); export { runtimeErrors, runtimeLogs }; diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index e3554faf37a..cbaf13d53c9 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -88,6 +88,29 @@ describe("accessGroups config", () => { expect(result.success).toBe(false); }); + + it("accepts message sender access groups for any channel", () => { + const result = OpenClawSchema.safeParse({ + accessGroups: { + owners: { + type: "message.senders", + members: { + "*": ["global-owner"], + telegram: ["12345"], + discord: ["discord:67890"], + }, + }, + }, + channels: { + telegram: { + dmPolicy: "allowlist", + allowFrom: ["accessGroup:owners"], + }, + }, + }); + + expect(result.success).toBe(true); + }); }); describe("plugins.slots.contextEngine", () => { diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index e436c30b8cd..c131b83e7b2 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -1310,6 +1310,31 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { required: ["type", "guildId", "channelId"], additionalProperties: false, }, + { + type: "object", + properties: { + type: { + type: "string", + const: "message.senders", + }, + members: { + type: "object", + propertyNames: { + type: "string", + minLength: 1, + }, + additionalProperties: { + type: "array", + items: { + type: "string", + minLength: 1, + }, + }, + }, + }, + required: ["type", "members"], + additionalProperties: false, + }, ], }, }, diff --git a/src/config/types.access-groups.ts b/src/config/types.access-groups.ts index c0ec64331e3..f31e1221ea2 100644 --- a/src/config/types.access-groups.ts +++ b/src/config/types.access-groups.ts @@ -12,6 +12,16 @@ export type DiscordChannelAudienceAccessGroup = { membership?: "canViewChannel"; }; -export type AccessGroupConfig = DiscordChannelAudienceAccessGroup; +export type MessageSendersAccessGroup = { + /** + * Static sender allowlists that can be referenced by any message channel via + * accessGroup:. + */ + type: "message.senders"; + /** Sender entries by channel id, plus optional "*" entries shared by all channels. */ + members: Record; +}; + +export type AccessGroupConfig = DiscordChannelAudienceAccessGroup | MessageSendersAccessGroup; export type AccessGroupsConfig = Record; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 25e2e1b908b..d22aafd9ef7 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -61,6 +61,12 @@ const AccessGroupsSchema = z membership: z.literal("canViewChannel").optional(), }) .strict(), + z + .object({ + type: z.literal("message.senders"), + members: z.record(z.string().min(1), z.array(z.string().min(1))), + }) + .strict(), ]), ) .optional(); diff --git a/src/plugin-sdk/access-groups.ts b/src/plugin-sdk/access-groups.ts new file mode 100644 index 00000000000..984251f0c0f --- /dev/null +++ b/src/plugin-sdk/access-groups.ts @@ -0,0 +1,126 @@ +import type { ChannelId } from "../channels/plugins/types.public.js"; +import type { AccessGroupConfig } from "../config/types.access-groups.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:"; + +export type AccessGroupMembershipResolver = (params: { + cfg: OpenClawConfig; + name: string; + group: AccessGroupConfig; + channel: ChannelId; + accountId: string; + senderId: string; +}) => boolean | Promise; + +export function parseAccessGroupAllowFromEntry(entry: string): string | null { + const trimmed = entry.trim(); + if (!trimmed.startsWith(ACCESS_GROUP_ALLOW_FROM_PREFIX)) { + return null; + } + const name = trimmed.slice(ACCESS_GROUP_ALLOW_FROM_PREFIX.length).trim(); + return name.length > 0 ? name : null; +} + +function resolveMessageSenderGroupEntries(params: { + group: AccessGroupConfig; + channel: ChannelId; +}): string[] { + if (params.group.type !== "message.senders") { + return []; + } + return [...(params.group.members["*"] ?? []), ...(params.group.members[params.channel] ?? [])]; +} + +export async function resolveAccessGroupAllowFromMatches(params: { + cfg?: OpenClawConfig; + allowFrom: Array | null | undefined; + channel: ChannelId; + accountId: string; + senderId: string; + isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean; + resolveMembership?: AccessGroupMembershipResolver; +}): Promise { + const cfg = params.cfg; + const groups = cfg?.accessGroups; + if (!groups) { + return []; + } + + const names = Array.from( + new Set( + (params.allowFrom ?? []) + .map((entry) => parseAccessGroupAllowFromEntry(String(entry))) + .filter((entry): entry is string => entry != null), + ), + ); + if (names.length === 0) { + return []; + } + + const matched: string[] = []; + for (const name of names) { + const group = groups[name]; + if (!group) { + continue; + } + + const senderEntries = resolveMessageSenderGroupEntries({ + group, + channel: params.channel, + }); + if ( + senderEntries.length > 0 && + params.isSenderAllowed?.(params.senderId, senderEntries) === true + ) { + matched.push(`${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`); + continue; + } + + let allowed = false; + try { + allowed = + (await params.resolveMembership?.({ + cfg, + name, + group, + channel: params.channel, + accountId: params.accountId, + senderId: params.senderId, + })) === true; + } catch { + allowed = false; + } + if (allowed) { + matched.push(`${ACCESS_GROUP_ALLOW_FROM_PREFIX}${name}`); + } + } + return matched; +} + +export async function expandAllowFromWithAccessGroups(params: { + cfg?: OpenClawConfig; + allowFrom: Array | null | undefined; + channel: ChannelId; + accountId: string; + senderId: string; + senderAllowEntry?: string; + isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean; + resolveMembership?: AccessGroupMembershipResolver; +}): Promise { + const allowFrom = (params.allowFrom ?? []).map(String); + const matched = await resolveAccessGroupAllowFromMatches({ + cfg: params.cfg, + allowFrom, + channel: params.channel, + accountId: params.accountId, + senderId: params.senderId, + isSenderAllowed: params.isSenderAllowed, + resolveMembership: params.resolveMembership, + }); + if (matched.length === 0) { + return allowFrom; + } + const senderEntry = params.senderAllowEntry ?? params.senderId; + return Array.from(new Set([...allowFrom, senderEntry])); +} diff --git a/src/plugin-sdk/command-auth.test.ts b/src/plugin-sdk/command-auth.test.ts index c1fce2b6d54..e40fee0271a 100644 --- a/src/plugin-sdk/command-auth.test.ts +++ b/src/plugin-sdk/command-auth.test.ts @@ -15,9 +15,10 @@ async function resolveAuthorization(params: { senderId: string; configuredAllowFrom?: string[]; configuredGroupAllowFrom?: string[]; + cfg?: OpenClawConfig; }) { return resolveSenderCommandAuthorization({ - cfg: baseCfg, + cfg: params.cfg ?? baseCfg, rawBody: "/status", isGroup: true, dmPolicy: "pairing", @@ -25,6 +26,8 @@ async function resolveAuthorization(params: { configuredGroupAllowFrom: params.configuredGroupAllowFrom ?? ["group-owner"], senderId: params.senderId, isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId), + channel: "zalouser", + accountId: "default", readAllowFromStore: async () => ["paired-user"], shouldComputeCommandAuthorized: () => true, resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) => @@ -92,6 +95,30 @@ describe("plugin-sdk/command-auth", () => { expect(result.commandAuthorized).toBeUndefined(); }); + it("resolves generic message sender access groups for group command authorization", async () => { + const result = await resolveAuthorization({ + senderId: "group-admin", + configuredAllowFrom: [], + configuredGroupAllowFrom: ["accessGroup:admins"], + cfg: { + ...baseCfg, + accessGroups: { + admins: { + type: "message.senders", + members: { + zalouser: ["group-admin"], + telegram: ["12345"], + }, + }, + }, + } as OpenClawConfig, + }); + + expect(result.effectiveGroupAllowFrom).toEqual(["accessGroup:admins", "group-admin"]); + expect(result.senderAllowedForCommands).toBe(true); + expect(result.commandAuthorized).toBe(true); + }); + it("does not treat open DM policy as an allowlist bypass", async () => { const result = await resolveSenderCommandAuthorization({ cfg: baseCfg, diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index c2d45594046..626f101aae6 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -3,8 +3,20 @@ import { buildCommandsMessagePaginated as buildCommandsMessagePaginatedCompat, buildHelpMessage as buildHelpMessageCompat, } from "../auto-reply/command-status-builders.js"; +import type { ChannelId } from "../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; +import { + expandAllowFromWithAccessGroups, + type AccessGroupMembershipResolver, +} from "./access-groups.js"; +export { + ACCESS_GROUP_ALLOW_FROM_PREFIX, + expandAllowFromWithAccessGroups, + parseAccessGroupAllowFromEntry, + resolveAccessGroupAllowFromMatches, + type AccessGroupMembershipResolver, +} from "./access-groups.js"; export { buildCommandsPaginationKeyboard } from "./telegram-command-ui.js"; export { createPreCryptoDirectDmAuthorizer, @@ -97,6 +109,9 @@ export type ResolveSenderCommandAuthorizationParams = { configuredGroupAllowFrom?: string[]; senderId: string; isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean; + channel?: ChannelId; + accountId?: string; + resolveAccessGroupMembership?: AccessGroupMembershipResolver; readAllowFromStore: () => Promise; shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean; resolveCommandAuthorizedFromAuthorizers: (params: { @@ -164,13 +179,51 @@ export async function resolveSenderCommandAuthorization( !params.isGroup && params.dmPolicy !== "allowlist" && params.dmPolicy !== "open" ? await params.readAllowFromStore().catch(() => []) : []; + const channel = params.channel; + const accountId = params.accountId ?? "default"; + let configuredAllowFrom = params.configuredAllowFrom; + let configuredGroupAllowFrom = params.configuredGroupAllowFrom ?? []; + let dmStoreAllowFrom = storeAllowFrom; + if (channel) { + [configuredAllowFrom, configuredGroupAllowFrom] = await Promise.all([ + expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: params.configuredAllowFrom, + channel, + accountId, + senderId: params.senderId, + isSenderAllowed: params.isSenderAllowed, + resolveMembership: params.resolveAccessGroupMembership, + }), + expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: params.configuredGroupAllowFrom ?? [], + channel, + accountId, + senderId: params.senderId, + isSenderAllowed: params.isSenderAllowed, + resolveMembership: params.resolveAccessGroupMembership, + }), + ]); + if (!params.isGroup) { + dmStoreAllowFrom = await expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: storeAllowFrom, + channel, + accountId, + senderId: params.senderId, + isSenderAllowed: params.isSenderAllowed, + resolveMembership: params.resolveAccessGroupMembership, + }); + } + } const access = resolveDmGroupAccessWithLists({ isGroup: params.isGroup, dmPolicy: params.dmPolicy, groupPolicy: "allowlist", - allowFrom: params.configuredAllowFrom, - groupAllowFrom: params.configuredGroupAllowFrom ?? [], - storeAllowFrom, + allowFrom: configuredAllowFrom, + groupAllowFrom: configuredGroupAllowFrom, + storeAllowFrom: dmStoreAllowFrom, isSenderAllowed: (allowFrom) => params.isSenderAllowed(params.senderId, allowFrom), }); const effectiveAllowFrom = access.effectiveAllowFrom; diff --git a/src/plugin-sdk/direct-dm-access.ts b/src/plugin-sdk/direct-dm-access.ts index 63ce94bde0a..1495e26f836 100644 --- a/src/plugin-sdk/direct-dm-access.ts +++ b/src/plugin-sdk/direct-dm-access.ts @@ -5,6 +5,11 @@ import { resolveDmGroupAccessWithLists, type DmGroupAccessReasonCode, } from "../security/dm-policy-shared.js"; +import { + expandAllowFromWithAccessGroups, + type AccessGroupMembershipResolver, +} from "./access-groups.js"; +export type { AccessGroupMembershipResolver } from "./access-groups.js"; export type DirectDmCommandAuthorizationRuntime = { shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean; @@ -37,6 +42,7 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { senderId: string; rawBody: string; isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean; + resolveAccessGroupMembership?: AccessGroupMembershipResolver; runtime: DirectDmCommandAuthorizationRuntime; modeWhenAccessGroupsOff?: "allow" | "deny" | "configured"; readStoreAllowFrom?: (provider: ChannelId, accountId: string) => Promise; @@ -51,12 +57,32 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { readStore: params.readStoreAllowFrom, }) : []; + const [allowFrom, effectiveStoreAllowFrom] = await Promise.all([ + expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: params.allowFrom, + channel: params.channel, + accountId: params.accountId, + senderId: params.senderId, + isSenderAllowed: params.isSenderAllowed, + resolveMembership: params.resolveAccessGroupMembership, + }), + expandAllowFromWithAccessGroups({ + cfg: params.cfg, + allowFrom: storeAllowFrom, + channel: params.channel, + accountId: params.accountId, + senderId: params.senderId, + isSenderAllowed: params.isSenderAllowed, + resolveMembership: params.resolveAccessGroupMembership, + }), + ]); const access = resolveDmGroupAccessWithLists({ isGroup: false, dmPolicy, - allowFrom: params.allowFrom, - storeAllowFrom, + allowFrom, + storeAllowFrom: effectiveStoreAllowFrom, groupAllowFromFallbackToAllowFrom: false, isSenderAllowed: (allowEntries) => params.isSenderAllowed(params.senderId, allowEntries), }); diff --git a/src/plugin-sdk/direct-dm.test.ts b/src/plugin-sdk/direct-dm.test.ts index 21660a265ce..2967d5cae42 100644 --- a/src/plugin-sdk/direct-dm.test.ts +++ b/src/plugin-sdk/direct-dm.test.ts @@ -93,6 +93,39 @@ describe("plugin-sdk/direct-dm", () => { expect(result.commandAuthorized).toBeUndefined(); }); + it("resolves generic message sender access groups for direct DMs", async () => { + const result = await resolveInboundDirectDmAccessWithRuntime({ + cfg: { + ...baseCfg, + accessGroups: { + owners: { + type: "message.senders", + members: { + nostr: ["owner-pubkey"], + telegram: ["12345"], + }, + }, + }, + } as OpenClawConfig, + channel: "nostr", + accountId: "default", + dmPolicy: "allowlist", + allowFrom: ["accessGroup:owners"], + senderId: "owner-pubkey", + rawBody: "/status", + isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId), + runtime: { + shouldComputeCommandAuthorized: () => true, + resolveCommandAuthorizedFromAuthorizers: ({ authorizers }) => + authorizers.some((entry) => entry.configured && entry.allowed), + }, + }); + + expect(result.access.decision).toBe("allow"); + expect(result.access.effectiveAllowFrom).toEqual(["accessGroup:owners", "owner-pubkey"]); + expect(result.commandAuthorized).toBe(true); + }); + it("creates a pre-crypto authorizer that issues pairing and blocks unknown senders", async () => { const issuePairingChallenge = vi.fn(async () => {}); const onBlocked = vi.fn(); diff --git a/src/plugin-sdk/direct-dm.ts b/src/plugin-sdk/direct-dm.ts index 071a76b72ee..c7cf743e93b 100644 --- a/src/plugin-sdk/direct-dm.ts +++ b/src/plugin-sdk/direct-dm.ts @@ -7,6 +7,7 @@ import type { OutboundReplyPayload } from "./reply-payload.js"; export { createPreCryptoDirectDmAuthorizer, resolveInboundDirectDmAccessWithRuntime, + type AccessGroupMembershipResolver, type DirectDmCommandAuthorizationRuntime, type ResolvedInboundDirectDmAccess, } from "./direct-dm-access.js"; diff --git a/src/plugin-sdk/security-runtime.ts b/src/plugin-sdk/security-runtime.ts index 60ed1a9e432..8a79ef7281c 100644 --- a/src/plugin-sdk/security-runtime.ts +++ b/src/plugin-sdk/security-runtime.ts @@ -7,6 +7,13 @@ export type * from "../secrets/target-registry-types.js"; export * from "../security/channel-metadata.js"; export * from "../security/context-visibility.js"; export * from "../security/dm-policy-shared.js"; +export { + ACCESS_GROUP_ALLOW_FROM_PREFIX, + expandAllowFromWithAccessGroups, + parseAccessGroupAllowFromEntry, + resolveAccessGroupAllowFromMatches, + type AccessGroupMembershipResolver, +} from "./access-groups.js"; export * from "../security/external-content.js"; export * from "../security/safe-regex.js"; export {