diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 936308d8b9e..52426b443c5 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -502,6 +502,7 @@ export async function processMessage( const dmPolicy = account.config.dmPolicy ?? "pairing"; const groupPolicy = account.config.groupPolicy ?? "allowlist"; + const configuredAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); const storeAllowFrom = await readStoreAllowFromForDmPolicy({ provider: "bluebubbles", dmPolicy, @@ -511,7 +512,7 @@ export async function processMessage( isGroup, dmPolicy, groupPolicy, - allowFrom: account.config.allowFrom, + allowFrom: configuredAllowFrom, groupAllowFrom: account.config.groupAllowFrom, storeAllowFrom, isSenderAllowed: (allowFrom) => @@ -666,10 +667,11 @@ export async function processMessage( // Command gating (parity with iMessage/WhatsApp) const useAccessGroups = config.commands?.useAccessGroups !== false; const hasControlCmd = core.channel.text.hasControlCommand(messageText, config); + const commandDmAllowFrom = isGroup ? configuredAllowFrom : effectiveAllowFrom; const ownerAllowedForCommands = - effectiveAllowFrom.length > 0 + commandDmAllowFrom.length > 0 ? isAllowedBlueBubblesSender({ - allowFrom: effectiveAllowFrom, + allowFrom: commandDmAllowFrom, sender: message.senderId, chatId: message.chatId ?? undefined, chatGuid: message.chatGuid ?? undefined, @@ -690,7 +692,7 @@ export async function processMessage( const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true, diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index c7529489695..8756f36e24d 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -15,6 +15,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, requestBodyErrorToText, resolveMentionGatingWithBypass, + resolveDmGroupAccessWithLists, } from "openclaw/plugin-sdk"; import { type ResolvedGoogleChatAccount } from "./accounts.js"; import { @@ -503,14 +504,33 @@ async function processMessageWithPipeline(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 senderGroupPolicy = + groupPolicy === "disabled" + ? "disabled" + : normalizedGroupUsers.length > 0 + ? "allowlist" + : "open"; const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); const storeAllowFrom = !isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeAuth) ? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => []) : []; - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; + const access = resolveDmGroupAccessWithLists({ + isGroup, + dmPolicy, + groupPolicy: senderGroupPolicy, + allowFrom: configAllowFrom, + groupAllowFrom: normalizedGroupUsers, + storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching), + }); + const effectiveAllowFrom = access.effectiveAllowFrom; + const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; warnDeprecatedUsersEmailEntries(core, runtime, effectiveAllowFrom); - const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; + const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom; const useAccessGroups = config.commands?.useAccessGroups !== false; const senderAllowedForCommands = isSenderAllowed( senderId, @@ -553,47 +573,53 @@ async function processMessageWithPipeline(params: { } } + if (isGroup && access.decision !== "allow") { + logVerbose( + core, + runtime, + `drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`, + ); + return; + } + if (!isGroup) { - if (dmPolicy === "disabled" || account.config.dm?.enabled === false) { + if (account.config.dm?.enabled === false) { logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`); return; } - if (dmPolicy !== "open") { - const allowed = senderAllowedForCommands; - if (!allowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "googlechat", - id: senderId, - meta: { name: senderName || undefined, email: senderEmail }, - }); - if (created) { - logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); - try { - await sendGoogleChatMessage({ - account, - space: spaceId, - text: core.channel.pairing.buildPairingReply({ - channel: "googlechat", - idLine: `Your Google Chat user id: ${senderId}`, - code, - }), - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); - } + if (access.decision !== "allow") { + if (access.decision === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "googlechat", + id: senderId, + meta: { name: senderName || undefined, email: senderEmail }, + }); + if (created) { + logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); + try { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: core.channel.pairing.buildPairingReply({ + channel: "googlechat", + idLine: `Your Google Chat user id: ${senderId}`, + code, + }), + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); } - } else { - logVerbose( - core, - runtime, - `Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`, - ); } - return; + } else { + logVerbose( + core, + runtime, + `Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`, + ); } + return; } } diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 0d864850775..8bcc0e0169e 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -7,6 +7,7 @@ import { logTypingFailure, readStoreAllowFromForDmPolicy, resolveControlCommandGate, + resolveDmGroupAccessWithLists, type PluginRuntime, type RuntimeEnv, type RuntimeLogger, @@ -214,62 +215,83 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const senderName = await getMemberDisplayName(roomId, senderId); - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "matrix", - dmPolicy, - readStore: (provider) => core.channel.pairing.readAllowFromStore(provider), - }); - const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); + const storeAllowFrom = + isDirectMessage + ? await readStoreAllowFromForDmPolicy({ + provider: "matrix", + dmPolicy, + readStore: (provider) => core.channel.pairing.readAllowFromStore(provider), + }) + : []; const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; - const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); + const normalizedGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); + const senderGroupPolicy = + groupPolicy === "disabled" + ? "disabled" + : normalizedGroupAllowFrom.length > 0 + ? "allowlist" + : "open"; + const access = resolveDmGroupAccessWithLists({ + isGroup: isRoom, + dmPolicy, + groupPolicy: senderGroupPolicy, + allowFrom, + groupAllowFrom: normalizedGroupAllowFrom, + storeAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + resolveMatrixAllowListMatches({ + allowList: normalizeMatrixAllowList(allowFrom), + userId: senderId, + }), + }); + const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom); + const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom); const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; if (isDirectMessage) { - if (!dmEnabled || dmPolicy === "disabled") { + if (!dmEnabled) { return; } - if (dmPolicy !== "open") { + if (access.decision !== "allow") { const allowMatch = resolveMatrixAllowListMatch({ allowList: effectiveAllowFrom, userId: senderId, }); const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); - if (!allowMatch.allowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "matrix", - id: senderId, - meta: { name: senderName }, - }); - if (created) { - logVerboseMessage( - `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, + if (access.decision === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "matrix", + id: senderId, + meta: { name: senderName }, + }); + if (created) { + logVerboseMessage( + `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, + ); + try { + await sendMessageMatrix( + `room:${roomId}`, + [ + "OpenClaw: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "openclaw pairing approve matrix ", + ].join("\n"), + { client }, ); - try { - await sendMessageMatrix( - `room:${roomId}`, - [ - "OpenClaw: access not configured.", - "", - `Pairing code: ${code}`, - "", - "Ask the bot owner to approve with:", - "openclaw pairing approve matrix ", - ].join("\n"), - { client }, - ); - } catch (err) { - logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); - } + } catch (err) { + logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); } } - if (dmPolicy !== "pairing") { - logVerboseMessage( - `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, - ); - } - return; + } else { + logVerboseMessage( + `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + ); } + return; } } @@ -288,7 +310,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } } - if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { + if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") { const groupAllowMatch = resolveMatrixAllowListMatch({ allowList: effectiveGroupAllowFrom, userId: senderId, diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 0c6b9f6febc..f88a7d9c595 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -390,10 +390,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); const isControlCommand = allowTextCommands && hasControlCommand; const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : normalizedAllowFrom; const senderAllowedForCommands = isMattermostSenderAllowed({ senderId, senderName, - allowFrom: effectiveAllowFrom, + allowFrom: commandDmAllowFrom, allowNameMatching, }); const groupAllowedForCommands = isMattermostSenderAllowed({ @@ -405,7 +406,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands, diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index e420892b564..f3f517cd478 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -11,6 +11,7 @@ import { resolveMentionGating, formatAllowlistMatchMeta, resolveEffectiveAllowFromLists, + resolveDmGroupAccessWithLists, type HistoryEntry, } from "openclaw/plugin-sdk"; import { @@ -146,53 +147,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { storeAllowFrom: storedAllowFrom, dmPolicy, }); - const effectiveDmAllowFrom = resolvedAllowFromLists.effectiveAllowFrom; - if (isDirectMessage && msteamsCfg) { - if (dmPolicy === "disabled") { - log.debug?.("dropping dm (dms disabled)"); - return; - } - - if (dmPolicy !== "open") { - const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); - const allowMatch = resolveMSTeamsAllowlistMatch({ - allowFrom: effectiveDmAllowFrom, - senderId, - senderName, - allowNameMatching, - }); - - if (!allowMatch.allowed) { - if (dmPolicy === "pairing") { - const request = await core.channel.pairing.upsertPairingRequest({ - channel: "msteams", - id: senderId, - meta: { name: senderName }, - }); - if (request) { - log.info("msteams pairing request created", { - sender: senderId, - label: senderName, - }); - } - } - log.debug?.("dropping dm (not allowlisted)", { - sender: senderId, - label: senderName, - allowlistMatch: formatAllowlistMatchMeta(allowMatch), - }); - return; - } - } - } const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist") : "disabled"; - const effectiveGroupAllowFrom = - !isDirectMessage && msteamsCfg ? resolvedAllowFromLists.effectiveGroupAllowFrom : []; + const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom; const teamId = activity.channelData?.team?.id; const teamName = activity.channelData?.team?.name; const channelName = activity.channelData?.channel?.name; @@ -203,6 +164,61 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { conversationId, channelName, }); + const senderGroupPolicy = + groupPolicy === "disabled" + ? "disabled" + : effectiveGroupAllowFrom.length > 0 + ? "allowlist" + : "open"; + const access = resolveDmGroupAccessWithLists({ + isGroup: !isDirectMessage, + dmPolicy, + groupPolicy: senderGroupPolicy, + allowFrom: configuredDmAllowFrom, + groupAllowFrom, + storeAllowFrom: storedAllowFrom, + groupAllowFromFallbackToAllowFrom: false, + isSenderAllowed: (allowFrom) => + resolveMSTeamsAllowlistMatch({ + allowFrom, + senderId, + senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), + }).allowed, + }); + const effectiveDmAllowFrom = access.effectiveAllowFrom; + + if (isDirectMessage && msteamsCfg && access.decision !== "allow") { + if (access.reason === "dmPolicy=disabled") { + log.debug?.("dropping dm (dms disabled)"); + return; + } + const allowMatch = resolveMSTeamsAllowlistMatch({ + allowFrom: effectiveDmAllowFrom, + senderId, + senderName, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), + }); + if (access.decision === "pairing") { + const request = await core.channel.pairing.upsertPairingRequest({ + channel: "msteams", + id: senderId, + meta: { name: senderName }, + }); + if (request) { + log.info("msteams pairing request created", { + sender: senderId, + label: senderName, + }); + } + } + log.debug?.("dropping dm (not allowlisted)", { + sender: senderId, + label: senderName, + allowlistMatch: formatAllowlistMatchMeta(allowMatch), + }); + return; + } if (!isDirectMessage && msteamsCfg) { if (groupPolicy === "disabled") { @@ -229,13 +245,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); return; } - if (effectiveGroupAllowFrom.length > 0) { - const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); + if (effectiveGroupAllowFrom.length > 0 && access.decision !== "allow") { const allowMatch = resolveMSTeamsAllowlistMatch({ allowFrom: effectiveGroupAllowFrom, senderId, senderName, - allowNameMatching, + allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg), }); if (!allowMatch.allowed) { log.debug?.("dropping group message (not in groupAllowFrom)", { diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index a1f4acef109..006bc4cffc9 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -5,7 +5,7 @@ import { formatTextWithAttachmentLinks, logInboundDrop, readStoreAllowFromForDmPolicy, - resolveControlCommandGate, + resolveDmGroupAccessWithCommandGate, resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -120,11 +120,6 @@ export async function handleNextcloudTalkInbound(params: { } const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom); - const baseGroupAllowFrom = - configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom; - - const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); - const effectiveGroupAllowFrom = [...baseGroupAllowFrom].filter(Boolean); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, @@ -132,25 +127,33 @@ export async function handleNextcloudTalkInbound(params: { }); const useAccessGroups = (config.commands as Record | undefined)?.useAccessGroups !== false; - const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({ - allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, - senderId, - }).allowed; const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { - configured: (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0, - allowed: senderAllowedForCommands, - }, - ], - allowTextCommands, - hasControlCommand, + const access = resolveDmGroupAccessWithCommandGate({ + isGroup, + dmPolicy, + groupPolicy, + allowFrom: configAllowFrom, + groupAllowFrom: configGroupAllowFrom, + storeAllowFrom: storeAllowList, + isSenderAllowed: (allowFrom) => + resolveNextcloudTalkAllowlistMatch({ + allowFrom, + senderId, + }).allowed, + command: { + useAccessGroups, + allowTextCommands, + hasControlCommand, + }, }); - const commandAuthorized = commandGate.commandAuthorized; + const commandAuthorized = access.commandAuthorized; + const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; if (isGroup) { + if (access.decision !== "allow") { + runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`); + return; + } const groupAllow = resolveNextcloudTalkGroupAllow({ groupPolicy, outerAllowFrom: effectiveGroupAllowFrom, @@ -162,48 +165,36 @@ export async function handleNextcloudTalkInbound(params: { return; } } else { - if (dmPolicy === "disabled") { - runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`); - return; - } - if (dmPolicy !== "open") { - const dmAllowed = resolveNextcloudTalkAllowlistMatch({ - allowFrom: effectiveAllowFrom, - senderId, - }).allowed; - if (!dmAllowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: CHANNEL_ID, - id: senderId, - meta: { name: senderName || undefined }, - }); - if (created) { - try { - await sendMessageNextcloudTalk( - roomToken, - core.channel.pairing.buildPairingReply({ - channel: CHANNEL_ID, - idLine: `Your Nextcloud user id: ${senderId}`, - code, - }), - { accountId: account.accountId }, - ); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.( - `nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`, - ); - } + if (access.decision !== "allow") { + if (access.decision === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: CHANNEL_ID, + id: senderId, + meta: { name: senderName || undefined }, + }); + if (created) { + try { + await sendMessageNextcloudTalk( + roomToken, + core.channel.pairing.buildPairingReply({ + channel: CHANNEL_ID, + idLine: `Your Nextcloud user id: ${senderId}`, + code, + }), + { accountId: account.accountId }, + ); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`); } } - runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`); - return; } + runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`); + return; } } - if (isGroup && commandGate.shouldBlock) { + if (access.shouldBlockControlCommand) { logInboundDrop({ log: (message) => runtime.log?.(message), channel: CHANNEL_ID, diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 76e656af7de..d1d5a91de9c 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -355,6 +355,7 @@ async function processMessageWithPipeline(params: { isGroup, dmPolicy, configuredAllowFrom: configAllowFrom, + configuredGroupAllowFrom: groupAllowFrom, senderId, isSenderAllowed: isZaloSenderAllowed, readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"), diff --git a/src/imessage/monitor/inbound-processing.ts b/src/imessage/monitor/inbound-processing.ts index 02917a3e5cb..863d469e6c7 100644 --- a/src/imessage/monitor/inbound-processing.ts +++ b/src/imessage/monitor/inbound-processing.ts @@ -256,10 +256,11 @@ export function resolveIMessageInboundDecision(params: { const canDetectMention = mentionRegexes.length > 0; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; + const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom; const ownerAllowedForCommands = - effectiveDmAllowFrom.length > 0 + commandDmAllowFrom.length > 0 ? isAllowedIMessageSender({ - allowFrom: effectiveDmAllowFrom, + allowFrom: commandDmAllowFrom, sender, chatId, chatGuid, @@ -280,7 +281,7 @@ export function resolveIMessageInboundDecision(params: { const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true, diff --git a/src/plugin-sdk/command-auth.test.ts b/src/plugin-sdk/command-auth.test.ts new file mode 100644 index 00000000000..c3ba8c2e8ca --- /dev/null +++ b/src/plugin-sdk/command-auth.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveSenderCommandAuthorization } from "./command-auth.js"; + +const baseCfg = { + commands: { useAccessGroups: true }, +} as unknown as OpenClawConfig; + +describe("plugin-sdk/command-auth", () => { + it("authorizes group commands from explicit group allowlist", async () => { + const result = await resolveSenderCommandAuthorization({ + cfg: baseCfg, + rawBody: "/status", + isGroup: true, + dmPolicy: "pairing", + configuredAllowFrom: ["dm-owner"], + configuredGroupAllowFrom: ["group-owner"], + senderId: "group-owner", + isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId), + readAllowFromStore: async () => ["paired-user"], + shouldComputeCommandAuthorized: () => true, + resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) => + useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed), + }); + expect(result.commandAuthorized).toBe(true); + expect(result.senderAllowedForCommands).toBe(true); + expect(result.effectiveAllowFrom).toEqual(["dm-owner"]); + expect(result.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); + + it("keeps pairing-store identities DM-only for group command auth", async () => { + const result = await resolveSenderCommandAuthorization({ + cfg: baseCfg, + rawBody: "/status", + isGroup: true, + dmPolicy: "pairing", + configuredAllowFrom: ["dm-owner"], + configuredGroupAllowFrom: ["group-owner"], + senderId: "paired-user", + isSenderAllowed: (senderId, allowFrom) => allowFrom.includes(senderId), + readAllowFromStore: async () => ["paired-user"], + shouldComputeCommandAuthorized: () => true, + resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) => + useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed), + }); + expect(result.commandAuthorized).toBe(false); + expect(result.senderAllowedForCommands).toBe(false); + expect(result.effectiveAllowFrom).toEqual(["dm-owner"]); + expect(result.effectiveGroupAllowFrom).toEqual(["group-owner"]); + }); +}); diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 287f1398da4..cc7d9d2207a 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; +import { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; export type ResolveSenderCommandAuthorizationParams = { cfg: OpenClawConfig; @@ -6,6 +7,7 @@ export type ResolveSenderCommandAuthorizationParams = { isGroup: boolean; dmPolicy: string; configuredAllowFrom: string[]; + configuredGroupAllowFrom?: string[]; senderId: string; isSenderAllowed: (senderId: string, allowFrom: string[]) => boolean; readAllowFromStore: () => Promise; @@ -21,6 +23,7 @@ export async function resolveSenderCommandAuthorization( ): Promise<{ shouldComputeAuth: boolean; effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; senderAllowedForCommands: boolean; commandAuthorized: boolean | undefined; }> { @@ -31,14 +34,30 @@ export async function resolveSenderCommandAuthorization( (params.dmPolicy !== "open" || shouldComputeAuth) ? await params.readAllowFromStore().catch(() => []) : []; - const effectiveAllowFrom = [...params.configuredAllowFrom, ...storeAllowFrom]; + const access = resolveDmGroupAccessWithLists({ + isGroup: params.isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: "allowlist", + allowFrom: params.configuredAllowFrom, + groupAllowFrom: params.configuredGroupAllowFrom ?? [], + storeAllowFrom, + isSenderAllowed: (allowFrom) => params.isSenderAllowed(params.senderId, allowFrom), + }); + const effectiveAllowFrom = access.effectiveAllowFrom; + const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; - const senderAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveAllowFrom); + const senderAllowedForCommands = params.isSenderAllowed( + params.senderId, + params.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, + ); + const ownerAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveAllowFrom); + const groupAllowedForCommands = params.isSenderAllowed(params.senderId, effectiveGroupAllowFrom); const commandAuthorized = shouldComputeAuth ? params.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ], }) : undefined; @@ -46,6 +65,7 @@ export async function resolveSenderCommandAuthorization( return { shouldComputeAuth, effectiveAllowFrom, + effectiveGroupAllowFrom, senderAllowedForCommands, commandAuthorized, }; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 7036e71c2df..6dcff06a9e2 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -413,6 +413,7 @@ export { readStoreAllowFromForDmPolicy, resolveDmAllowState, resolveDmGroupAccessDecision, + resolveDmGroupAccessWithCommandGate, resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, } from "../security/dm-policy-shared.js"; diff --git a/src/security/dm-policy-shared.test.ts b/src/security/dm-policy-shared.test.ts index 2be2e9d46b5..cba513856df 100644 --- a/src/security/dm-policy-shared.test.ts +++ b/src/security/dm-policy-shared.test.ts @@ -3,6 +3,7 @@ import { DM_GROUP_ACCESS_REASON, readStoreAllowFromForDmPolicy, resolveDmAllowState, + resolveDmGroupAccessWithCommandGate, resolveDmGroupAccessDecision, resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, @@ -134,6 +135,66 @@ describe("security/dm-policy-shared", () => { expect(resolved.effectiveGroupAllowFrom).toEqual(["group:room"]); }); + it("resolves command gate with dm/group parity for groups", () => { + const resolved = resolveDmGroupAccessWithCommandGate({ + isGroup: true, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner"], + groupAllowFrom: ["group-owner"], + storeAllowFrom: ["paired-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), + command: { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }, + }); + expect(resolved.decision).toBe("block"); + expect(resolved.reason).toBe("groupPolicy=allowlist (not allowlisted)"); + expect(resolved.commandAuthorized).toBe(false); + expect(resolved.shouldBlockControlCommand).toBe(true); + }); + + it("keeps configured dm allowlist usable for group command auth", () => { + const resolved = resolveDmGroupAccessWithCommandGate({ + isGroup: true, + dmPolicy: "pairing", + groupPolicy: "open", + allowFrom: ["owner"], + groupAllowFrom: [], + storeAllowFrom: ["paired-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("owner"), + command: { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }, + }); + expect(resolved.commandAuthorized).toBe(true); + expect(resolved.shouldBlockControlCommand).toBe(false); + }); + + it("treats dm command authorization as dm access result", () => { + const resolved = resolveDmGroupAccessWithCommandGate({ + isGroup: false, + dmPolicy: "pairing", + groupPolicy: "allowlist", + allowFrom: ["owner"], + groupAllowFrom: ["group-owner"], + storeAllowFrom: ["paired-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"), + command: { + useAccessGroups: true, + allowTextCommands: true, + hasControlCommand: true, + }, + }); + expect(resolved.decision).toBe("allow"); + expect(resolved.commandAuthorized).toBe(true); + expect(resolved.shouldBlockControlCommand).toBe(false); + }); + it("keeps allowlist mode strict in shared resolver (no pairing-store fallback)", () => { const resolved = resolveDmGroupAccessWithLists({ isGroup: false, diff --git a/src/security/dm-policy-shared.ts b/src/security/dm-policy-shared.ts index e5a80451868..cc5a9acabd2 100644 --- a/src/security/dm-policy-shared.ts +++ b/src/security/dm-policy-shared.ts @@ -1,4 +1,5 @@ import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js"; +import { resolveControlCommandGate } from "../channels/command-gating.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; @@ -182,6 +183,79 @@ export function resolveDmGroupAccessWithLists(params: { }; } +export function resolveDmGroupAccessWithCommandGate(params: { + isGroup: boolean; + dmPolicy?: string | null; + groupPolicy?: string | null; + allowFrom?: Array | null; + groupAllowFrom?: Array | null; + storeAllowFrom?: Array | null; + groupAllowFromFallbackToAllowFrom?: boolean | null; + isSenderAllowed: (allowFrom: string[]) => boolean; + command?: { + useAccessGroups: boolean; + allowTextCommands: boolean; + hasControlCommand: boolean; + }; +}): { + decision: DmGroupAccessDecision; + reason: string; + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; + commandAuthorized: boolean; + shouldBlockControlCommand: boolean; +} { + const access = resolveDmGroupAccessWithLists({ + isGroup: params.isGroup, + dmPolicy: params.dmPolicy, + groupPolicy: params.groupPolicy, + allowFrom: params.allowFrom, + groupAllowFrom: params.groupAllowFrom, + storeAllowFrom: params.storeAllowFrom, + groupAllowFromFallbackToAllowFrom: params.groupAllowFromFallbackToAllowFrom, + isSenderAllowed: params.isSenderAllowed, + }); + + const configuredAllowFrom = normalizeStringEntries(params.allowFrom ?? []); + const configuredGroupAllowFrom = normalizeStringEntries( + resolveGroupAllowFromSources({ + allowFrom: configuredAllowFrom, + groupAllowFrom: normalizeStringEntries(params.groupAllowFrom ?? []), + fallbackToAllowFrom: params.groupAllowFromFallbackToAllowFrom ?? undefined, + }), + ); + // Group command authorization must not inherit DM pairing-store approvals. + const commandDmAllowFrom = params.isGroup ? configuredAllowFrom : access.effectiveAllowFrom; + const commandGroupAllowFrom = params.isGroup + ? configuredGroupAllowFrom + : access.effectiveGroupAllowFrom; + const ownerAllowedForCommands = params.isSenderAllowed(commandDmAllowFrom); + const groupAllowedForCommands = params.isSenderAllowed(commandGroupAllowFrom); + const commandGate = params.command + ? resolveControlCommandGate({ + useAccessGroups: params.command.useAccessGroups, + authorizers: [ + { + configured: commandDmAllowFrom.length > 0, + allowed: ownerAllowedForCommands, + }, + { + configured: commandGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + allowTextCommands: params.command.allowTextCommands, + hasControlCommand: params.command.hasControlCommand, + }) + : { commandAuthorized: false, shouldBlock: false }; + + return { + ...access, + commandAuthorized: params.isGroup ? commandGate.commandAuthorized : access.decision === "allow", + shouldBlockControlCommand: params.isGroup && commandGate.shouldBlock, + }; +} + export async function resolveDmAllowState(params: { provider: ChannelId; allowFrom?: Array | null; diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index c275d090e71..e71b68e2eca 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -560,13 +560,14 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { } const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false; - const ownerAllowedForCommands = isSignalSenderAllowed(sender, effectiveDmAllow); + const commandDmAllow = isGroup ? deps.allowFrom : effectiveDmAllow; + const ownerAllowedForCommands = isSignalSenderAllowed(sender, commandDmAllow); const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow); const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg); const commandGate = resolveControlCommandGate({ useAccessGroups, authorizers: [ - { configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands }, + { configured: commandDmAllow.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands }, ], allowTextCommands: true, diff --git a/src/slack/monitor/auth.ts b/src/slack/monitor/auth.ts index 9521ca3c007..9cd150e1287 100644 --- a/src/slack/monitor/auth.ts +++ b/src/slack/monitor/auth.ts @@ -9,12 +9,19 @@ import { import { resolveSlackChannelConfig } from "./channel-config.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js"; -export async function resolveSlackEffectiveAllowFrom(ctx: SlackMonitorContext) { - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "slack", - dmPolicy: ctx.dmPolicy, - readStore: (provider) => readChannelAllowFromStore(provider), - }); +export async function resolveSlackEffectiveAllowFrom( + ctx: SlackMonitorContext, + options?: { includePairingStore?: boolean }, +) { + const includePairingStore = options?.includePairingStore === true; + const storeAllowFrom = + includePairingStore + ? await readStoreAllowFromForDmPolicy({ + provider: "slack", + dmPolicy: ctx.dmPolicy, + readStore: (provider) => readChannelAllowFromStore(provider), + }) + : []; const allowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]); const allowFromLower = normalizeAllowListLower(allowFrom); return { allowFrom, allowFromLower }; @@ -99,15 +106,15 @@ export async function authorizeSlackSystemEventSender(params: { .catch(() => ({})); const senderName = senderInfo.name; - const resolveAllowFromLower = async () => - (await resolveSlackEffectiveAllowFrom(params.ctx)).allowFromLower; + const resolveAllowFromLower = async (includePairingStore = false) => + (await resolveSlackEffectiveAllowFrom(params.ctx, { includePairingStore })).allowFromLower; if (channelType === "im") { if (!params.ctx.dmEnabled || params.ctx.dmPolicy === "disabled") { return { allowed: false, reason: "dm-disabled", channelType, channelName }; } if (params.ctx.dmPolicy !== "open") { - const allowFromLower = await resolveAllowFromLower(); + const allowFromLower = await resolveAllowFromLower(true); const senderAllowListed = isSlackSenderAllowListed({ allowListLower: allowFromLower, senderId, @@ -126,7 +133,7 @@ export async function authorizeSlackSystemEventSender(params: { } else if (!channelId) { // No channel context. Apply allowFrom if configured so we fail closed // for privileged interactive events when owner allowlist is present. - const allowFromLower = await resolveAllowFromLower(); + const allowFromLower = await resolveAllowFromLower(false); if (allowFromLower.length > 0) { const senderAllowListed = isSlackSenderAllowListed({ allowListLower: allowFromLower, diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 6a0121d996e..2cc26b41ff3 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -127,7 +127,9 @@ export async function prepareSlackMessage(params: { return null; } - const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx); + const { allowFromLower } = await resolveSlackEffectiveAllowFrom(ctx, { + includePairingStore: isDirectMessage, + }); if (isDirectMessage) { const directUserId = message.user; diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index e65fbf62c41..c653d4a0b68 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -336,11 +336,14 @@ export async function registerSlackMonitorSlashCommands(params: { return; } - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "slack", - dmPolicy: ctx.dmPolicy, - readStore: (provider) => readChannelAllowFromStore(provider), - }); + const storeAllowFrom = + isDirectMessage + ? await readStoreAllowFromForDmPolicy({ + provider: "slack", + dmPolicy: ctx.dmPolicy, + readStore: (provider) => readChannelAllowFromStore(provider), + }) + : []; const effectiveAllowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]); const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 556cca57d77..246732a6d1e 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -253,7 +253,7 @@ async function resolveTelegramCommandAuth(params: { const dmAllow = normalizeDmAllowFromWithStore({ allowFrom: allowFrom, - storeAllowFrom, + storeAllowFrom: isGroup ? [] : storeAllowFrom, dmPolicy: telegramCfg.dmPolicy ?? "pairing", }); const senderAllowed = isSenderAllowed({ diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 56ca1b7aa8b..f1ed93d33fa 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -27,7 +27,10 @@ import type { getChildLogger } from "../../../logging.js"; import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; import type { resolveAgentRoute } from "../../../routing/resolve-route.js"; -import { readStoreAllowFromForDmPolicy } from "../../../security/dm-policy-shared.js"; +import { + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithCommandGate, +} from "../../../security/dm-policy-shared.js"; import { jidToE164, normalizeE164 } from "../../../utils.js"; import { resolveWhatsAppAccount } from "../../accounts.js"; import { newConnectionId } from "../../reconnect.js"; @@ -49,15 +52,6 @@ export type GroupHistoryEntry = { senderJid?: string; }; -function normalizeAllowFromE164(values: Array | undefined): string[] { - const list = Array.isArray(values) ? values : []; - return list - .map((entry) => String(entry).trim()) - .filter((entry) => entry && entry !== "*") - .map((entry) => normalizeE164(entry)) - .filter((entry): entry is string => Boolean(entry)); -} - async function resolveWhatsAppCommandAuthorized(params: { cfg: ReturnType; msg: WebInboundMsg; @@ -77,38 +71,49 @@ async function resolveWhatsAppCommandAuthorized(params: { const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId }); const dmPolicy = account.dmPolicy ?? "pairing"; + const groupPolicy = account.groupPolicy ?? "allowlist"; const configuredAllowFrom = account.allowFrom ?? []; const configuredGroupAllowFrom = account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); - if (isGroup) { - if (!configuredGroupAllowFrom || configuredGroupAllowFrom.length === 0) { - return false; - } - if (configuredGroupAllowFrom.some((v) => String(v).trim() === "*")) { - return true; - } - return normalizeAllowFromE164(configuredGroupAllowFrom).includes(senderE164); - } - - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ - provider: "whatsapp", - dmPolicy, - readStore: (provider) => readChannelAllowFromStore(provider, process.env, params.msg.accountId), - }); - const combinedAllowFrom = Array.from( - new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]), - ); - const allowFrom = - combinedAllowFrom.length > 0 - ? combinedAllowFrom + const storeAllowFrom = + isGroup + ? [] + : await readStoreAllowFromForDmPolicy({ + provider: "whatsapp", + dmPolicy, + readStore: (provider) => + readChannelAllowFromStore(provider, process.env, params.msg.accountId), + }); + const dmAllowFrom = + configuredAllowFrom.length > 0 + ? configuredAllowFrom : params.msg.selfE164 ? [params.msg.selfE164] : []; - if (allowFrom.some((v) => String(v).trim() === "*")) { - return true; - } - return normalizeAllowFromE164(allowFrom).includes(senderE164); + const access = resolveDmGroupAccessWithCommandGate({ + isGroup, + dmPolicy, + groupPolicy, + allowFrom: dmAllowFrom, + groupAllowFrom: configuredGroupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowEntries) => { + if (allowEntries.includes("*")) { + return true; + } + const normalizedEntries = allowEntries + .map((entry) => normalizeE164(String(entry))) + .filter((entry): entry is string => Boolean(entry)); + return normalizedEntries.includes(senderE164); + }, + command: { + useAccessGroups, + allowTextCommands: true, + hasControlCommand: true, + }, + }); + return access.commandAuthorized; } export async function processMessage(params: { diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index 439bc534d62..bb160403e8b 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -10,7 +10,10 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; -import { readStoreAllowFromForDmPolicy } from "../../security/dm-policy-shared.js"; +import { + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, +} from "../../security/dm-policy-shared.js"; import { isSelfChatMode, normalizeE164 } from "../../utils.js"; import { resolveWhatsAppAccount } from "../accounts.js"; @@ -60,22 +63,18 @@ export async function checkInboundAccessControl(params: { accountId: params.accountId, }); const dmPolicy = account.dmPolicy ?? "pairing"; - const configuredAllowFrom = account.allowFrom; + const configuredAllowFrom = account.allowFrom ?? []; const storeAllowFrom = await readStoreAllowFromForDmPolicy({ provider: "whatsapp", dmPolicy, readStore: (provider) => readChannelAllowFromStore(provider, process.env, account.accountId), }); // Without user config, default to self-only DM access so the owner can talk to themselves. - const combinedAllowFrom = Array.from( - new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]), - ); const defaultAllowFrom = - combinedAllowFrom.length === 0 && params.selfE164 ? [params.selfE164] : undefined; - const allowFrom = combinedAllowFrom.length > 0 ? combinedAllowFrom : defaultAllowFrom; + configuredAllowFrom.length === 0 && params.selfE164 ? [params.selfE164] : []; + const dmAllowFrom = configuredAllowFrom.length > 0 ? configuredAllowFrom : defaultAllowFrom; const groupAllowFrom = - account.groupAllowFrom ?? - (configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); + account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined); const isSamePhone = params.from === params.selfE164; const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom); const pairingGraceMs = @@ -87,18 +86,6 @@ export async function checkInboundAccessControl(params: { typeof params.messageTimestampMs === "number" && params.messageTimestampMs < params.connectedAtMs - pairingGraceMs; - // Pre-compute normalized allowlists for filtering. - const dmHasWildcard = allowFrom?.includes("*") ?? false; - const normalizedAllowFrom = - allowFrom && allowFrom.length > 0 - ? allowFrom.filter((entry) => entry !== "*").map(normalizeE164) - : []; - const groupHasWildcard = groupAllowFrom?.includes("*") ?? false; - const normalizedGroupAllowFrom = - groupAllowFrom && groupAllowFrom.length > 0 - ? groupAllowFrom.filter((entry) => entry !== "*").map(normalizeE164) - : []; - // Group policy filtering: // - "open": groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely @@ -115,8 +102,45 @@ export async function checkInboundAccessControl(params: { accountId: account.accountId, log: (message) => logVerbose(message), }); - if (params.group && groupPolicy === "disabled") { - logVerbose("Blocked group message (groupPolicy: disabled)"); + const normalizedDmSender = normalizeE164(params.from); + const normalizedGroupSender = + typeof params.senderE164 === "string" ? normalizeE164(params.senderE164) : null; + const access = resolveDmGroupAccessWithLists({ + isGroup: params.group, + dmPolicy, + groupPolicy, + // Groups intentionally fall back to configured allowFrom only (not DM self-chat fallback). + allowFrom: params.group ? configuredAllowFrom : dmAllowFrom, + groupAllowFrom, + storeAllowFrom, + isSenderAllowed: (allowEntries) => { + const hasWildcard = allowEntries.includes("*"); + if (hasWildcard) { + return true; + } + const normalizedEntrySet = new Set( + allowEntries + .map((entry) => normalizeE164(String(entry))) + .filter((entry): entry is string => Boolean(entry)), + ); + if (!params.group && isSamePhone) { + return true; + } + return params.group + ? Boolean(normalizedGroupSender && normalizedEntrySet.has(normalizedGroupSender)) + : normalizedEntrySet.has(normalizedDmSender); + }, + }); + if (params.group && access.decision !== "allow") { + if (access.reason === "groupPolicy=disabled") { + logVerbose("Blocked group message (groupPolicy: disabled)"); + } else if (access.reason === "groupPolicy=allowlist (empty allowlist)") { + logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)"); + } else { + logVerbose( + `Blocked group message from ${params.senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`, + ); + } return { allowed: false, shouldMarkRead: false, @@ -124,31 +148,6 @@ export async function checkInboundAccessControl(params: { resolvedAccountId: account.accountId, }; } - if (params.group && groupPolicy === "allowlist") { - if (!groupAllowFrom || groupAllowFrom.length === 0) { - logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)"); - return { - allowed: false, - shouldMarkRead: false, - isSelfChat, - resolvedAccountId: account.accountId, - }; - } - const senderAllowed = - groupHasWildcard || - (params.senderE164 != null && normalizedGroupAllowFrom.includes(params.senderE164)); - if (!senderAllowed) { - logVerbose( - `Blocked group message from ${params.senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`, - ); - return { - allowed: false, - shouldMarkRead: false, - isSelfChat, - resolvedAccountId: account.accountId, - }; - } - } // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled". if (!params.group) { @@ -161,7 +160,7 @@ export async function checkInboundAccessControl(params: { resolvedAccountId: account.accountId, }; } - if (dmPolicy === "disabled") { + if (access.decision === "block" && access.reason === "dmPolicy=disabled") { logVerbose("Blocked dm (dmPolicy: disabled)"); return { allowed: false, @@ -170,49 +169,49 @@ export async function checkInboundAccessControl(params: { resolvedAccountId: account.accountId, }; } - if (dmPolicy !== "open" && !isSamePhone) { + if (access.decision === "pairing" && !isSamePhone) { const candidate = params.from; - const allowed = - dmHasWildcard || - (normalizedAllowFrom.length > 0 && normalizedAllowFrom.includes(candidate)); - if (!allowed) { - if (dmPolicy === "pairing") { - if (suppressPairingReply) { - logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`); - } else { - const { code, created } = await upsertChannelPairingRequest({ - channel: "whatsapp", - id: candidate, - accountId: account.accountId, - meta: { name: (params.pushName ?? "").trim() || undefined }, + if (suppressPairingReply) { + logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`); + } else { + const { code, created } = await upsertChannelPairingRequest({ + channel: "whatsapp", + id: candidate, + accountId: account.accountId, + meta: { name: (params.pushName ?? "").trim() || undefined }, + }); + if (created) { + logVerbose( + `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`, + ); + try { + await params.sock.sendMessage(params.remoteJid, { + text: buildPairingReply({ + channel: "whatsapp", + idLine: `Your WhatsApp phone number: ${candidate}`, + code, + }), }); - if (created) { - logVerbose( - `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`, - ); - try { - await params.sock.sendMessage(params.remoteJid, { - text: buildPairingReply({ - channel: "whatsapp", - idLine: `Your WhatsApp phone number: ${candidate}`, - code, - }), - }); - } catch (err) { - logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`); - } - } + } catch (err) { + logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`); } - } else { - logVerbose(`Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`); } - return { - allowed: false, - shouldMarkRead: false, - isSelfChat, - resolvedAccountId: account.accountId, - }; } + return { + allowed: false, + shouldMarkRead: false, + isSelfChat, + resolvedAccountId: account.accountId, + }; + } + if (access.decision !== "allow") { + logVerbose(`Blocked unauthorized sender ${params.from} (dmPolicy=${dmPolicy})`); + return { + allowed: false, + shouldMarkRead: false, + isSelfChat, + resolvedAccountId: account.accountId, + }; } }