From c7365fd5836d1f273955e1e36714c5d640a3f9ac Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 18:15:30 -0400 Subject: [PATCH] fix: add channel read-only security adapters --- extensions/discord/src/security.ts | 52 +++++++++++++++++++++++++++++ extensions/slack/src/security.ts | 43 ++++++++++++++++++++++++ extensions/telegram/src/security.ts | 36 ++++++++++++++++++++ src/channels/plugins/read-only.ts | 28 ++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 extensions/discord/src/security.ts create mode 100644 extensions/slack/src/security.ts create mode 100644 extensions/telegram/src/security.ts create mode 100644 src/channels/plugins/read-only.ts diff --git a/extensions/discord/src/security.ts b/extensions/discord/src/security.ts new file mode 100644 index 00000000000..586c9f26820 --- /dev/null +++ b/extensions/discord/src/security.ts @@ -0,0 +1,52 @@ +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import type { ResolvedDiscordAccount } from "./accounts.js"; +import type { ChannelPlugin } from "./channel-api.js"; + +const resolveDiscordDmPolicy = createScopedDmSecurityResolver({ + channelKey: "discord", + resolvePolicy: (account) => account.config.dm?.policy, + resolveAllowFrom: (account) => account.config.dm?.allowFrom, + allowFromPathSuffix: "dm.", + normalizeEntry: (raw) => + raw + .trim() + .replace(/^(discord|user):/i, "") + .replace(/^<@!?(\d+)>$/, "$1"), +}); + +const collectDiscordSecurityWarnings = + createOpenProviderConfiguredRouteWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.discord !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Object.keys(account.config.guilds ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Discord guilds", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.discord.groupPolicy", + routeAllowlistPath: "channels.discord.guilds..channels", + }, + missingRouteAllowlist: { + surface: "Discord guilds", + openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels', + }, + }); + +let discordSecurityAuditModulePromise: + | Promise + | undefined; + +async function loadDiscordSecurityAuditModule() { + discordSecurityAuditModulePromise ??= import("./security-audit.runtime.js"); + return await discordSecurityAuditModulePromise; +} + +export const discordSecurityAdapter = { + resolveDmPolicy: resolveDiscordDmPolicy, + collectWarnings: collectDiscordSecurityWarnings, + collectAuditFindings: async (params) => + (await loadDiscordSecurityAuditModule()).collectDiscordSecurityAuditFindings(params), +} satisfies NonNullable["security"]>; diff --git a/extensions/slack/src/security.ts b/extensions/slack/src/security.ts new file mode 100644 index 00000000000..6360fc8d74e --- /dev/null +++ b/extensions/slack/src/security.ts @@ -0,0 +1,43 @@ +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import type { ResolvedSlackAccount } from "./accounts.js"; +import type { ChannelPlugin } from "./channel-api.js"; +import { collectSlackSecurityAuditFindings } from "./security-audit.js"; + +const resolveSlackDmPolicy = createScopedDmSecurityResolver({ + channelKey: "slack", + resolvePolicy: (account) => account.dm?.policy, + resolveAllowFrom: (account) => account.dm?.allowFrom, + allowFromPathSuffix: "dm.", + normalizeEntry: (raw) => + raw + .trim() + .replace(/^(slack|user):/i, "") + .trim(), +}); + +const collectSlackSecurityWarnings = + createOpenProviderConfiguredRouteWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Slack channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.slack.groupPolicy", + routeAllowlistPath: "channels.slack.channels", + }, + missingRouteAllowlist: { + surface: "Slack channels", + openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels', + }, + }); + +export const slackSecurityAdapter = { + resolveDmPolicy: resolveSlackDmPolicy, + collectWarnings: collectSlackSecurityWarnings, + collectAuditFindings: collectSlackSecurityAuditFindings, +} satisfies NonNullable["security"]>; diff --git a/extensions/telegram/src/security.ts b/extensions/telegram/src/security.ts new file mode 100644 index 00000000000..818cbd1f667 --- /dev/null +++ b/extensions/telegram/src/security.ts @@ -0,0 +1,36 @@ +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import type { ResolvedTelegramAccount } from "./accounts.js"; +import { collectTelegramSecurityAuditFindings } from "./security-audit.js"; + +const collectTelegramSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.telegram !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.groups) && Object.keys(account.config.groups ?? {}).length > 0, + restrictSenders: { + surface: "Telegram groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.telegram.groupPolicy", + groupAllowFromPath: "channels.telegram.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Telegram groups", + routeAllowlistPath: "channels.telegram.groups", + routeScope: "group", + groupPolicyPath: "channels.telegram.groupPolicy", + groupAllowFromPath: "channels.telegram.groupAllowFrom", + }, + }); + +export const telegramSecurityAdapter = { + dm: { + channelKey: "telegram", + resolvePolicy: (account: ResolvedTelegramAccount) => account.config.dmPolicy, + resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw: string) => raw.replace(/^(telegram|tg):/i, ""), + }, + collectWarnings: collectTelegramSecurityWarnings, + collectAuditFindings: collectTelegramSecurityAuditFindings, +}; diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts new file mode 100644 index 00000000000..0997a8a38e0 --- /dev/null +++ b/src/channels/plugins/read-only.ts @@ -0,0 +1,28 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { listPotentialConfiguredChannelIds } from "../config-presence.js"; +import { getBundledChannelSetupPlugin } from "./bundled.js"; +import { listChannelPlugins } from "./registry.js"; +import type { ChannelPlugin } from "./types.plugin.js"; + +export function listReadOnlyChannelPluginsForConfig( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): ChannelPlugin[] { + const byId = new Map(); + + for (const plugin of listChannelPlugins()) { + byId.set(plugin.id, plugin); + } + + for (const channelId of listPotentialConfiguredChannelIds(cfg, env)) { + if (byId.has(channelId)) { + continue; + } + const setupPlugin = getBundledChannelSetupPlugin(channelId); + if (setupPlugin) { + byId.set(setupPlugin.id, setupPlugin); + } + } + + return [...byId.values()]; +}