feat: generalize message access groups (#75813)

This commit is contained in:
Peter Steinberger
2026-05-01 23:17:14 +01:00
parent b217cd0972
commit 20945b84b4
30 changed files with 886 additions and 93 deletions

View File

@@ -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:<name>` across channel auth paths. (#75813) Thanks @clawsweeper.
### Fixes

View File

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

View File

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

View File

@@ -452,6 +452,29 @@ Example:
<Tab title="DM access groups">
Discord DMs can use dynamic `accessGroup:<name>` 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.

View File

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

View File

@@ -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<string[]> {
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,
}),
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

View File

@@ -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({

View File

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

View File

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

View File

@@ -59,6 +59,29 @@ async function checkCommandAuthorizedForDm(params: {
});
}
async function checkCommandAuthorizedForGroup(params: {
cfg: Record<string, unknown>;
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: {

View File

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

View File

@@ -469,6 +469,8 @@ async function authorizeZaloMessage(
configuredGroupAllowFrom: groupAllowFrom,
senderId,
isSenderAllowed: isZaloSenderAllowed,
channel: "zalo",
accountId: account.accountId,
readAllowFromStore: pairing.readAllowFromStore,
runtime: core.channel.commands,
});

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -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,
},
],
},
},

View File

@@ -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:<name>.
*/
type: "message.senders";
/** Sender entries by channel id, plus optional "*" entries shared by all channels. */
members: Record<string, string[]>;
};
export type AccessGroupConfig = DiscordChannelAudienceAccessGroup | MessageSendersAccessGroup;
export type AccessGroupsConfig = Record<string, AccessGroupConfig>;

View File

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

View File

@@ -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<boolean>;
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<string | number> | null | undefined;
channel: ChannelId;
accountId: string;
senderId: string;
isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean;
resolveMembership?: AccessGroupMembershipResolver;
}): Promise<string[]> {
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<string | number> | null | undefined;
channel: ChannelId;
accountId: string;
senderId: string;
senderAllowEntry?: string;
isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean;
resolveMembership?: AccessGroupMembershipResolver;
}): Promise<string[]> {
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]));
}

View File

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

View File

@@ -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<string[]>;
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;

View File

@@ -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<string[]>;
@@ -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),
});

View File

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

View File

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

View File

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