diff --git a/CHANGELOG.md b/CHANGELOG.md index e422d7639a8..166d7cf22b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to `allowlist` (instead of inheriting `channels.defaults.groupPolicy`) when `channels.` is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3. - CLI/Sessions: pass the configured sessions directory when resolving transcript paths in `agentCommand`, so custom `session.store` locations resume sessions reliably. Thanks @davidrudduck. - Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including `chat.inject`) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber. - Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 6cdd3aa410c..334c6d78ee5 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -425,7 +425,7 @@ Example: } ``` - If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs). + If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs), even if `channels.defaults.groupPolicy` is `open`. diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 6bd278846c5..00118c546b5 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -190,6 +190,7 @@ Notes: - Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`). - Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. - Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked. +- Runtime safety: when a provider block is completely missing (`channels.` absent), group policy falls back to a fail-closed mode (typically `allowlist`) instead of inheriting `channels.defaults.groupPolicy`. Quick mental model (evaluation order for group messages): diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index d7a1b633597..5720da1714a 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -158,6 +158,7 @@ imsg send "test" Group sender allowlist: `channels.imessage.groupAllowFrom`. Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available. + Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Mention gating for groups: diff --git a/docs/channels/line.md b/docs/channels/line.md index d32e683fbeb..b87cbd3f5fb 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -118,6 +118,7 @@ Allowlists and policies: - `channels.line.groupPolicy`: `allowlist | open | disabled` - `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups - Per-group overrides: `channels.line.groups..allowFrom` +- Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). LINE IDs are case-sensitive. Valid IDs look like: diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 04205d94971..9bb56d1ddb7 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -195,6 +195,7 @@ Notes: ## Rooms (groups) - Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. +- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set). - Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match): ```json5 diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index fa0d9393e0f..350fa8429c4 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -103,6 +103,7 @@ Notes: - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). - Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). +- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## Targets for outbound delivery diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 60bb5f7ce92..b216af120ce 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -195,6 +195,7 @@ Groups: - `channels.signal.groupPolicy = open | allowlist | disabled`. - `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. +- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## How it works (behavior) diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 13c53b02459..4a1bda6990b 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -165,7 +165,7 @@ For actions/directory reads, user token can be preferred when configured. For wr Channel allowlist lives under `channels.slack.channels`. - Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="allowlist"` and logs a warning. + Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Name/ID resolution: diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 3867224fc7a..138b2b255d8 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -148,6 +148,7 @@ curl "https://api.telegram.org/bot/getUpdates" `groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`. `groupAllowFrom` entries must be numeric Telegram user IDs. + Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set). Example: allow any member in one specific group: diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index a6fb427bdc2..d92dfda9c75 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -171,7 +171,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available - sender allowlists are evaluated before mention/reply activation - Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is effectively `open`. + Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is `allowlist` (with a warning log), even if `channels.defaults.groupPolicy` is set. diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 7556f14e154..9922062c4c4 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -22,6 +22,7 @@ import { resolveDefaultDiscordAccountId, resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -131,7 +132,13 @@ export const discordPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.discord !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); const guildEntries = account.config.guilds ?? {}; const guildsConfigured = Object.keys(guildEntries).length > 0; const channelAllowlistConfigured = guildsConfigured; diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 14d9219193a..7922997c7d5 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -2,10 +2,11 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; import { buildAgentMediaPayload, buildPendingHistoryContextFromMap, - recordPendingHistoryEntryIfEnabled, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, + recordPendingHistoryEntryIfEnabled, + resolveRuntimeGroupPolicy, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; @@ -77,6 +78,7 @@ const senderNameCache = new Map(); // Key: appId or "default", Value: timestamp of last notification const permissionErrorNotifiedAt = new Map(); const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes +const groupPolicyFallbackWarningShown = new Set(); type SenderNameResult = { name?: string; @@ -563,7 +565,20 @@ export async function handleFeishuMessage(params: { const useAccessGroups = cfg.commands?.useAccessGroups !== false; if (isGroup) { - const groupPolicy = feishuCfg?.groupPolicy ?? "open"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.feishu !== undefined, + groupPolicy: feishuCfg?.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !groupPolicyFallbackWarningShown.has(account.accountId)) { + groupPolicyFallbackWarningShown.add(account.accountId); + log( + 'feishu: channels.feishu is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } const groupAllowFrom = feishuCfg?.groupAllowFrom ?? []; // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 98a622cdf46..c1f29be85e5 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -4,6 +4,7 @@ import { createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, + resolveRuntimeGroupPolicy, } from "openclaw/plugin-sdk"; import { resolveFeishuAccount, @@ -227,7 +228,13 @@ export const feishuPlugin: ChannelPlugin = { const defaultGroupPolicy = ( cfg.channels as Record | undefined )?.defaults?.groupPolicy; - const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.feishu !== undefined, + groupPolicy: feishuCfg?.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") return []; return [ `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 8022add55ca..9cd9bd182aa 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -11,6 +11,7 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveGoogleChatGroupRequireMention, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelDock, type ChannelMessageActionAdapter, @@ -199,7 +200,13 @@ export const googlechatPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.googlechat !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy === "open") { warnings.push( `- Google Chat spaces: groupPolicy="open" allows any space to trigger (mention-gated). Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups.`, diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index cee54005886..8889ec8d5f5 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -5,6 +5,7 @@ import { readJsonBodyWithLimit, registerWebhookTarget, rejectNonPostWebhookRequest, + resolveRuntimeGroupPolicy, resolveSingleWebhookTargetAsync, resolveWebhookPath, resolveWebhookTargets, @@ -67,6 +68,7 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, } const warnedDeprecatedUsersEmailAllowFrom = new Set(); +const warnedMissingProviderGroupPolicy = new Set(); function warnDeprecatedUsersEmailEntries( core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, @@ -427,7 +429,21 @@ async function processMessageWithPipeline(params: { } const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.googlechat !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !warnedMissingProviderGroupPolicy.has(account.accountId)) { + warnedMissingProviderGroupPolicy.add(account.accountId); + logVerbose( + core, + runtime, + 'googlechat: channels.googlechat is missing; defaulting groupPolicy to "allowlist" (space messages blocked until explicitly configured).', + ); + } const groupConfigResolved = resolveGroupConfig({ groupId: spaceId, groupName: space.displayName ?? null, diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 00696414f23..aacc3246d25 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -18,6 +18,7 @@ import { resolveIMessageAccount, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type ResolvedIMessageAccount, @@ -98,7 +99,13 @@ export const imessagePlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.imessage !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 024f379c3d0..18bcece05ad 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -4,6 +4,7 @@ import { formatPairingApproveHint, getChatChannelMeta, PAIRING_APPROVED_MESSAGE, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, deleteAccountFromConfigSection, type ChannelPlugin, @@ -135,7 +136,13 @@ export const ircPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.irc !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy === "open") { warnings.push( '- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.', diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index abd523ed17c..eb6daeff611 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -2,6 +2,7 @@ import { createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, + resolveRuntimeGroupPolicy, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -19,6 +20,7 @@ import { sendMessageIrc } from "./send.js"; import type { CoreConfig, IrcInboundMessage } from "./types.js"; const CHANNEL_ID = "irc" as const; +const warnedMissingProviderGroupPolicy = new Set(); const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -85,7 +87,19 @@ export async function handleIrcInbound(params: { const dmPolicy = account.config.dmPolicy ?? "pairing"; const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.irc !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !warnedMissingProviderGroupPolicy.has(account.accountId)) { + warnedMissingProviderGroupPolicy.add(account.accountId); + runtime.log?.( + 'irc: channels.irc is missing; defaulting groupPolicy to "allowlist" (channel messages blocked until explicitly configured).', + ); + } const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index cc30264e1e1..f5c72cf81b4 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, + resolveRuntimeGroupPolicy, type ChannelPlugin, type ChannelStatusIssue, type OpenClawConfig, @@ -163,7 +164,13 @@ export const linePlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined) ?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.line !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 3cd699f252c..75e4b464660 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -6,6 +6,7 @@ import { formatPairingApproveHint, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk"; @@ -170,7 +171,13 @@ export const matrixPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index df6d87fad48..91648498936 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,5 +1,10 @@ import { format } from "node:util"; -import { mergeAllowlist, summarizeMapping, type RuntimeEnv } from "openclaw/plugin-sdk"; +import { + mergeAllowlist, + resolveRuntimeGroupPolicy, + summarizeMapping, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; import { resolveMatrixTargets } from "../../resolve-targets.js"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig, ReplyToMode } from "../../types.js"; @@ -243,7 +248,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicyRaw = accountConfig.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy( + { + providerConfigPresent: cfg.channels?.matrix !== undefined, + groupPolicy: accountConfig.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }, + ); + if (providerMissingFallbackApplied) { + logVerboseMessage( + 'matrix: channels.matrix is missing; defaulting groupPolicy to "allowlist" (room messages blocked until explicitly configured).', + ); + } const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off"; const threadReplies = accountConfig.threadReplies ?? "inbound"; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 3935d5f205e..55e189b55de 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -6,6 +6,7 @@ import { formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelMessageActionAdapter, type ChannelMessageActionName, @@ -229,7 +230,13 @@ export const mattermostPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index b2c921b155d..81777f213e4 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -16,6 +16,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, resolveControlCommandGate, + resolveRuntimeGroupPolicy, resolveChannelMediaMaxBytes, type HistoryEntry, } from "openclaw/plugin-sdk"; @@ -242,6 +243,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, ); const channelHistories = new Map(); + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.mattermost !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied) { + logVerboseMessage( + 'mattermost: channels.mattermost is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } const fetchWithAuth: FetchLike = (input, init) => { const headers = new Headers(init?.headers); @@ -375,8 +389,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} senderId; const rawText = post.message?.trim() || ""; const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); const storeAllowFrom = normalizeAllowList( @@ -887,8 +899,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } } } else if (kind) { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy === "disabled") { logVerboseMessage(`mattermost: drop reaction (groupPolicy=disabled channel=${channelId})`); return; diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d7e9b3088e8..9e35450d77a 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -6,6 +6,7 @@ import { DEFAULT_ACCOUNT_ID, MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, + resolveRuntimeGroupPolicy, } from "openclaw/plugin-sdk"; import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js"; import { msteamsOnboardingAdapter } from "./onboarding.js"; @@ -128,7 +129,13 @@ export const msteamsPlugin: ChannelPlugin = { security: { collectWarnings: ({ cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.msteams !== undefined, + groupPolicy: cfg.channels?.msteams?.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 7471d70dab0..3b7769013f8 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -5,6 +5,7 @@ import { deleteAccountFromConfigSection, formatPairingApproveHint, normalizeAccountId, + resolveRuntimeGroupPolicy, setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, @@ -129,7 +130,14 @@ export const nextcloudTalkPlugin: ChannelPlugin = }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: + (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 642e010b06d..149bff15818 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -2,6 +2,7 @@ import { createReplyPrefixOptions, logInboundDrop, resolveControlCommandGate, + resolveRuntimeGroupPolicy, type OpenClawConfig, type RuntimeEnv, } from "openclaw/plugin-sdk"; @@ -20,6 +21,7 @@ import { sendMessageNextcloudTalk } from "./send.js"; import type { CoreConfig, GroupPolicy, NextcloudTalkInboundMessage } from "./types.js"; const CHANNEL_ID = "nextcloud-talk" as const; +const warnedMissingProviderGroupPolicy = new Set(); async function deliverNextcloudTalkReply(params: { payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; @@ -84,12 +86,26 @@ export async function handleNextcloudTalkInbound(params: { statusSink?.({ lastInboundAt: message.timestamp }); const dmPolicy = account.config.dmPolicy ?? "pairing"; - const defaultGroupPolicy = (config.channels as Record | undefined)?.defaults as - | { groupPolicy?: string } - | undefined; - const groupPolicy = (account.config.groupPolicy ?? - defaultGroupPolicy?.groupPolicy ?? - "allowlist") as GroupPolicy; + const defaultGroupPolicy = ( + (config.channels as Record | undefined)?.defaults as + | { groupPolicy?: string } + | undefined + )?.groupPolicy as GroupPolicy | undefined; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: + ((config.channels as Record | undefined)?.["nextcloud-talk"] ?? + undefined) !== undefined, + groupPolicy: account.config.groupPolicy as GroupPolicy | undefined, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !warnedMissingProviderGroupPolicy.has(account.accountId)) { + warnedMissingProviderGroupPolicy.add(account.accountId); + runtime.log?.( + 'nextcloud-talk: channels.nextcloud-talk is missing; defaulting groupPolicy to "allowlist" (room messages blocked until explicitly configured).', + ); + } const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom); const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 2d627eeb9a6..db309b5a09d 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -17,6 +17,7 @@ import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, resolveDefaultSignalAccountId, + resolveRuntimeGroupPolicy, resolveSignalAccount, setAccountEnabledInConfigSection, signalOnboardingAdapter, @@ -124,7 +125,13 @@ export const signalPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.signal !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 891dd6a590c..8eda437cfed 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -19,6 +19,7 @@ import { resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, + resolveRuntimeGroupPolicy, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, @@ -151,7 +152,13 @@ export const slackPlugin: ChannelPlugin = { collectWarnings: ({ account, cfg }) => { const warnings: string[] = []; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.slack !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); const channelAllowlistConfigured = Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a26dd956a6a..858e6405e55 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -17,6 +17,7 @@ import { parseTelegramReplyToMessageId, parseTelegramThreadId, resolveDefaultTelegramAccountId, + resolveRuntimeGroupPolicy, resolveTelegramAccount, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, @@ -196,7 +197,13 @@ export const telegramPlugin: ChannelPlugin { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.telegram !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d19359630b1..8796dcc14b6 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -19,6 +19,7 @@ import { readStringParam, resolveDefaultWhatsAppAccountId, resolveWhatsAppOutboundTarget, + resolveRuntimeGroupPolicy, resolveWhatsAppAccount, resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, @@ -143,7 +144,13 @@ export const whatsappPlugin: ChannelPlugin = { }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + groupPolicy: account.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); if (groupPolicy !== "open") { return []; } diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index c55a76a147d..6d723e0513b 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plu import { createReplyPrefixOptions, mergeAllowlist, + resolveRuntimeGroupPolicy, resolveSenderCommandAuthorization, summarizeMapping, } from "openclaw/plugin-sdk"; @@ -178,7 +179,20 @@ async function processMessage( const chatId = threadId; const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: config.channels?.zalouser !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied) { + logVerbose( + core, + runtime, + 'zalouser: channels.zalouser is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } const groups = account.config.groups ?? {}; if (isGroup) { if (groupPolicy === "disabled") { diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index 03750e1101e..23df4ce42de 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -447,7 +447,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { const accessConfig = await promptChannelAccessConfig({ prompter, label: "Zalo groups", - currentPolicy: account.config.groupPolicy ?? "open", + currentPolicy: account.config.groupPolicy ?? "allowlist", currentEntries: Object.keys(account.config.groups ?? {}), placeholder: "Family, Work, 123456789", updatePrompt: Boolean(account.config.groups), diff --git a/src/discord/monitor/message-handler.ts b/src/discord/monitor/message-handler.ts index aceae950d70..8beae2e6277 100644 --- a/src/discord/monitor/message-handler.ts +++ b/src/discord/monitor/message-handler.ts @@ -4,6 +4,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { danger } from "../../globals.js"; import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; @@ -23,7 +24,13 @@ type DiscordMessageHandlerParams = Omit< export function createDiscordMessageHandler( params: DiscordMessageHandlerParams, ): DiscordMessageHandler { - const groupPolicy = params.discordConfig?.groupPolicy ?? "open"; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: params.cfg.channels?.discord !== undefined, + groupPolicy: params.discordConfig?.groupPolicy, + defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); const ackReactionScope = params.cfg.messages?.ackReactionScope ?? "group-mentions"; const debounceMs = resolveInboundDebounceMs({ cfg: params.cfg, channel: "discord" }); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index cc45838c3c9..9ab2c5c3a4c 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -39,6 +39,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import type { OpenClawConfig, loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -1329,8 +1330,15 @@ async function dispatchDiscordCommandInteraction(params: { const channelAllowlistConfigured = Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; const channelAllowed = channelConfig?.allowed !== false; + const { groupPolicy } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.discord !== undefined, + groupPolicy: discordConfig?.groupPolicy, + defaultGroupPolicy: cfg.channels?.defaults?.groupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); const allowByPolicy = isDiscordGroupAllowedByPolicy({ - groupPolicy: discordConfig?.groupPolicy ?? "open", + groupPolicy, guildAllowlisted: Boolean(guildInfo), channelAllowlistConfigured, channelAllowed, diff --git a/src/discord/monitor/provider.group-policy.test.ts b/src/discord/monitor/provider.group-policy.test.ts index 50a3377f806..48d4f67614a 100644 --- a/src/discord/monitor/provider.group-policy.test.ts +++ b/src/discord/monitor/provider.group-policy.test.ts @@ -26,4 +26,13 @@ describe("resolveDiscordRuntimeGroupPolicy", () => { expect(resolved.groupPolicy).toBe("disabled"); expect(resolved.providerMissingFallbackApplied).toBe(false); }); + + it("ignores explicit global defaults when provider config is missing", () => { + const resolved = __testing.resolveDiscordRuntimeGroupPolicy({ + providerConfigPresent: false, + defaultGroupPolicy: "open", + }); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); + }); }); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index bfe8880098d..cea9303f0da 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -21,6 +21,7 @@ import { } from "../../config/commands.js"; import type { OpenClawConfig, ReplyToMode } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import type { GroupPolicy } from "../../config/types.base.js"; import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -179,15 +180,13 @@ function resolveDiscordRuntimeGroupPolicy(params: { groupPolicy: GroupPolicy; providerMissingFallbackApplied: boolean; } { - const groupPolicy = - params.groupPolicy ?? - params.defaultGroupPolicy ?? - (params.providerConfigPresent ? "open" : "allowlist"); - const providerMissingFallbackApplied = - !params.providerConfigPresent && - params.groupPolicy === undefined && - params.defaultGroupPolicy === undefined; - return { groupPolicy, providerMissingFallbackApplied }; + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); } async function deployDiscordCommands(params: { @@ -265,20 +264,22 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); - const discordCfg = account.config; + const rawDiscordCfg = account.config; const discordRootThreadBindings = cfg.channels?.discord?.threadBindings; const discordAccountThreadBindings = cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings; - const discordRestFetch = resolveDiscordRestFetch(discordCfg.proxy, runtime); - const dmConfig = discordCfg.dm; - let guildEntries = discordCfg.guilds; + const discordRestFetch = resolveDiscordRestFetch(rawDiscordCfg.proxy, runtime); + const dmConfig = rawDiscordCfg.dm; + let guildEntries = rawDiscordCfg.guilds; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const providerConfigPresent = cfg.channels?.discord !== undefined; const { groupPolicy, providerMissingFallbackApplied } = resolveDiscordRuntimeGroupPolicy({ providerConfigPresent, - groupPolicy: discordCfg.groupPolicy, + groupPolicy: rawDiscordCfg.groupPolicy, defaultGroupPolicy, }); + const discordCfg = + rawDiscordCfg.groupPolicy === groupPolicy ? rawDiscordCfg : { ...rawDiscordCfg, groupPolicy }; if (providerMissingFallbackApplied) { runtime.log?.( warn( diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 375ada6ac4b..2a114e8465e 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -16,8 +16,10 @@ import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.j import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; +import type { GroupPolicy } from "../../config/types.base.js"; +import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js"; import { normalizeScpRemoteHost } from "../../infra/scp-host.js"; import { waitForTransportReady } from "../../infra/transport-ready.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -120,6 +122,23 @@ class SentMessageCache { } } +function resolveIMessageRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: GroupPolicy; + defaultGroupPolicy?: GroupPolicy; +}): { + groupPolicy: GroupPolicy; + providerMissingFallbackApplied: boolean; +} { + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); +} + export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { const runtime = resolveRuntime(opts); const cfg = opts.config ?? loadConfig(); @@ -144,7 +163,18 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P (imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0 ? imessageCfg.allowFrom : []), ); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = imessageCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy, providerMissingFallbackApplied } = resolveIMessageRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.imessage !== undefined, + groupPolicy: imessageCfg.groupPolicy, + defaultGroupPolicy, + }); + if (providerMissingFallbackApplied) { + runtime.log?.( + warn( + 'imessage: channels.imessage is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ), + ); + } const dmPolicy = imessageCfg.dmPolicy ?? "pairing"; const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false; const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024; @@ -508,3 +538,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P await client.stop(); } } + +export const __testing = { + resolveIMessageRuntimeGroupPolicy, +}; diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index 206a4d185cb..096d7fcc188 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -8,6 +8,7 @@ import type { PostbackEvent, } from "@line/bot-sdk"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import { danger, logVerbose } from "../globals.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; import { buildPairingReply } from "../pairing/pairing-messages.js"; @@ -40,6 +41,8 @@ export interface LineHandlerContext { processMessage: (ctx: LineInboundContext) => Promise; } +let lineGroupPolicyFallbackWarned = false; + function resolveLineGroupConfig(params: { config: ResolvedLineAccount["config"]; groupId?: string; @@ -133,7 +136,19 @@ async function shouldProcessLineEvent( dmPolicy, }); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.line !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied && !lineGroupPolicyFallbackWarned) { + lineGroupPolicyFallbackWarned = true; + logVerbose( + 'line: channels.line is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } if (isGroup) { if (groupConfig?.enabled === false) { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a3f58c034cc..07e3c63d7f6 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -132,6 +132,10 @@ export type { MSTeamsReplyStyle, MSTeamsTeamConfig, } from "../config/types.js"; +export { + resolveRuntimeGroupPolicy, + type RuntimeGroupPolicyResolution, +} from "../config/runtime-group-policy.js"; export { DiscordConfigSchema, GoogleChatConfigSchema, diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 0d4d72ee58e..c9bc8dcb219 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -3,6 +3,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "../auto-reply/re import type { ReplyPayload } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import type { SignalReactionNotificationMode } from "../config/types.js"; import { waitForTransportReady } from "../infra/transport-ready.js"; import { saveMediaBuffer } from "../media/store.js"; @@ -345,7 +346,18 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi : []), ); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = accountInfo.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const { groupPolicy, providerMissingFallbackApplied } = resolveRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.signal !== undefined, + groupPolicy: accountInfo.config.groupPolicy, + defaultGroupPolicy, + configuredFallbackPolicy: "allowlist", + missingProviderFallbackPolicy: "allowlist", + }); + if (providerMissingFallbackApplied) { + runtime.log?.( + 'signal: channels.signal is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } const reactionMode = accountInfo.config.reactionNotifications ?? "own"; const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist); const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024; diff --git a/src/slack/monitor/provider.group-policy.test.ts b/src/slack/monitor/provider.group-policy.test.ts index 43bc8dfec54..29478d13e7a 100644 --- a/src/slack/monitor/provider.group-policy.test.ts +++ b/src/slack/monitor/provider.group-policy.test.ts @@ -18,12 +18,12 @@ describe("resolveSlackRuntimeGroupPolicy", () => { expect(resolved.providerMissingFallbackApplied).toBe(false); }); - it("respects explicit global defaults", () => { + it("ignores explicit global defaults when provider config is missing", () => { const resolved = __testing.resolveSlackRuntimeGroupPolicy({ providerConfigPresent: false, defaultGroupPolicy: "open", }); - expect(resolved.groupPolicy).toBe("open"); - expect(resolved.providerMissingFallbackApplied).toBe(false); + expect(resolved.groupPolicy).toBe("allowlist"); + expect(resolved.providerMissingFallbackApplied).toBe(true); }); }); diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 4d9d50331a9..1d52d561036 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -10,6 +10,7 @@ import { summarizeMapping, } from "../../channels/allowlists/resolve-utils.js"; import { loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import type { SessionScope } from "../../config/sessions.js"; import type { GroupPolicy } from "../../config/types.base.js"; import { warn } from "../../globals.js"; @@ -50,15 +51,13 @@ function resolveSlackRuntimeGroupPolicy(params: { groupPolicy: GroupPolicy; providerMissingFallbackApplied: boolean; } { - const groupPolicy = - params.groupPolicy ?? - params.defaultGroupPolicy ?? - (params.providerConfigPresent ? "open" : "allowlist"); - const providerMissingFallbackApplied = - !params.providerConfigPresent && - params.groupPolicy === undefined && - params.defaultGroupPolicy === undefined; - return { groupPolicy, providerMissingFallbackApplied }; + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); } function parseApiAppIdFromAppToken(raw?: string) { diff --git a/src/telegram/group-access.ts b/src/telegram/group-access.ts index 02375218171..571457d3b65 100644 --- a/src/telegram/group-access.ts +++ b/src/telegram/group-access.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; +import { resolveRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import type { TelegramAccountConfig, TelegramGroupConfig, @@ -72,6 +73,19 @@ export type TelegramGroupPolicyAccessResult = groupPolicy: "open" | "disabled" | "allowlist"; }; +export const resolveTelegramRuntimeGroupPolicy = (params: { + providerConfigPresent: boolean; + groupPolicy?: TelegramAccountConfig["groupPolicy"]; + defaultGroupPolicy?: TelegramAccountConfig["groupPolicy"]; +}) => + resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); + export const evaluateTelegramGroupPolicyAccess = (params: { isGroup: boolean; chatId: string | number; @@ -90,20 +104,21 @@ export const evaluateTelegramGroupPolicyAccess = (params: { requireSenderForAllowlistAuthorization: boolean; checkChatAllowlist: boolean; }): TelegramGroupPolicyAccessResult => { + const { groupPolicy: runtimeFallbackPolicy } = resolveTelegramRuntimeGroupPolicy({ + providerConfigPresent: params.cfg.channels?.telegram !== undefined, + groupPolicy: params.telegramCfg.groupPolicy, + defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy, + }); const fallbackPolicy = - firstDefined( - params.telegramCfg.groupPolicy, - params.cfg.channels?.defaults?.groupPolicy, - "open", - ) ?? "open"; + firstDefined(params.telegramCfg.groupPolicy, params.cfg.channels?.defaults?.groupPolicy) ?? + runtimeFallbackPolicy; const groupPolicy = params.useTopicAndGroupOverrides ? (firstDefined( params.topicConfig?.groupPolicy, params.groupConfig?.groupPolicy, params.telegramCfg.groupPolicy, params.cfg.channels?.defaults?.groupPolicy, - "open", - ) ?? "open") + ) ?? runtimeFallbackPolicy) : fallbackPolicy; if (!params.isGroup || !params.enforcePolicy) { diff --git a/src/web/inbound/access-control.ts b/src/web/inbound/access-control.ts index a7c2601e2b3..5f5737f3a2b 100644 --- a/src/web/inbound/access-control.ts +++ b/src/web/inbound/access-control.ts @@ -1,4 +1,5 @@ import { loadConfig } from "../../config/config.js"; +import { resolveRuntimeGroupPolicy } from "../../config/runtime-group-policy.js"; import { logVerbose } from "../../globals.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { @@ -17,6 +18,23 @@ export type InboundAccessControlResult = { const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000; +function resolveWhatsAppRuntimeGroupPolicy(params: { + providerConfigPresent: boolean; + groupPolicy?: "open" | "allowlist" | "disabled"; + defaultGroupPolicy?: "open" | "allowlist" | "disabled"; +}): { + groupPolicy: "open" | "allowlist" | "disabled"; + providerMissingFallbackApplied: boolean; +} { + return resolveRuntimeGroupPolicy({ + providerConfigPresent: params.providerConfigPresent, + groupPolicy: params.groupPolicy, + defaultGroupPolicy: params.defaultGroupPolicy, + configuredFallbackPolicy: "open", + missingProviderFallbackPolicy: "allowlist", + }); +} + export async function checkInboundAccessControl(params: { accountId: string; from: string; @@ -82,7 +100,16 @@ export async function checkInboundAccessControl(params: { // - "disabled": block all group messages entirely // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = account.groupPolicy ?? defaultGroupPolicy ?? "open"; + const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + groupPolicy: account.groupPolicy, + defaultGroupPolicy, + }); + if (providerMissingFallbackApplied) { + logVerbose( + 'whatsapp: channels.whatsapp is missing; defaulting groupPolicy to "allowlist" (group messages blocked until explicitly configured).', + ); + } if (params.group && groupPolicy === "disabled") { logVerbose("Blocked group message (groupPolicy: disabled)"); return { @@ -191,3 +218,7 @@ export async function checkInboundAccessControl(params: { resolvedAccountId: account.accountId, }; } + +export const __testing = { + resolveWhatsAppRuntimeGroupPolicy, +};