From 0932adf361b18e1e449efece42ffb3487e53574d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 20:30:04 +0100 Subject: [PATCH] fix(config): fail closed allowlist-only group policy Co-authored-by: etereo --- CHANGELOG.md | 1 + src/config/group-policy.test.ts | 92 +++++++++++++++++++++++++++++++++ src/config/group-policy.ts | 34 +++++++++++- 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 src/config/group-policy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 820d45272..1b1c2b0d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan. - Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan. +- Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo. - Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake. - Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting. - Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756) diff --git a/src/config/group-policy.test.ts b/src/config/group-policy.test.ts new file mode 100644 index 000000000..6aa584d69 --- /dev/null +++ b/src/config/group-policy.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "./config.js"; +import { resolveChannelGroupPolicy } from "./group-policy.js"; + +describe("resolveChannelGroupPolicy", () => { + it("fails closed when groupPolicy=allowlist and groups are missing", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "allowlist", + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + groupId: "123@g.us", + }); + + expect(policy.allowlistEnabled).toBe(true); + expect(policy.allowed).toBe(false); + }); + + it("allows configured groups when groupPolicy=allowlist", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "allowlist", + groups: { + "123@g.us": { requireMention: true }, + }, + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + groupId: "123@g.us", + }); + + expect(policy.allowlistEnabled).toBe(true); + expect(policy.allowed).toBe(true); + }); + + it("blocks all groups when groupPolicy=disabled", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "disabled", + groups: { + "*": { requireMention: false }, + }, + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + groupId: "123@g.us", + }); + + expect(policy.allowed).toBe(false); + }); + + it("respects account-scoped groupPolicy overrides", () => { + const cfg = { + channels: { + whatsapp: { + groupPolicy: "open", + accounts: { + work: { + groupPolicy: "allowlist", + }, + }, + }, + }, + } as OpenClawConfig; + + const policy = resolveChannelGroupPolicy({ + cfg, + channel: "whatsapp", + accountId: "work", + groupId: "123@g.us", + }); + + expect(policy.allowlistEnabled).toBe(true); + expect(policy.allowed).toBe(false); + }); +}); diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index 9082e74aa..a188a824b 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -143,6 +143,33 @@ function resolveChannelGroups( return accountGroups ?? channelConfig.groups; } +type ChannelGroupPolicyMode = "open" | "allowlist" | "disabled"; + +function resolveChannelGroupPolicyMode( + cfg: OpenClawConfig, + channel: GroupPolicyChannel, + accountId?: string | null, +): ChannelGroupPolicyMode | undefined { + const normalizedAccountId = normalizeAccountId(accountId); + const channelConfig = cfg.channels?.[channel] as + | { + groupPolicy?: ChannelGroupPolicyMode; + accounts?: Record; + } + | undefined; + if (!channelConfig) { + return undefined; + } + const accountPolicy = + channelConfig.accounts?.[normalizedAccountId]?.groupPolicy ?? + channelConfig.accounts?.[ + Object.keys(channelConfig.accounts ?? {}).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ) ?? "" + ]?.groupPolicy; + return accountPolicy ?? channelConfig.groupPolicy; +} + export function resolveChannelGroupPolicy(params: { cfg: OpenClawConfig; channel: GroupPolicyChannel; @@ -152,14 +179,17 @@ export function resolveChannelGroupPolicy(params: { }): ChannelGroupPolicy { const { cfg, channel } = params; const groups = resolveChannelGroups(cfg, channel, params.accountId); - const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0); + const groupPolicy = resolveChannelGroupPolicyMode(cfg, channel, params.accountId); + const hasGroups = Boolean(groups && Object.keys(groups).length > 0); + const allowlistEnabled = groupPolicy === "allowlist" || hasGroups; const normalizedId = params.groupId?.trim(); const groupConfig = normalizedId ? resolveChannelGroupConfig(groups, normalizedId, params.groupIdCaseInsensitive) : undefined; const defaultConfig = groups?.["*"]; const allowAll = allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*")); - const allowed = !allowlistEnabled || allowAll || Boolean(groupConfig); + const allowed = + groupPolicy === "disabled" ? false : !allowlistEnabled || allowAll || Boolean(groupConfig); return { allowlistEnabled, allowed,