From d905ca0e02fc804148fd77dccbf3b1a21b123bc5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 11:09:15 +0000 Subject: [PATCH] fix: enforce explicit mention gating across channels --- CHANGELOG.md | 1 + src/auto-reply/reply/mentions.ts | 20 ++++++++++++ .../monitor/message-handler.preflight.ts | 31 +++++++++++++------ src/plugins/runtime/index.ts | 7 ++++- src/plugins/runtime/types.ts | 3 ++ src/slack/monitor/message-handler/prepare.ts | 21 ++++++++++--- src/telegram/bot-message-context.ts | 17 +++++++--- src/web/auto-reply/mentions.ts | 9 ++++-- 8 files changed, 87 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4048290ff..58a4ba70f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.clawd.bot - Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies. - Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. - Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. +- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). - Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. - Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index 50278c82b..a723a8fb9 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -75,6 +75,26 @@ export function matchesMentionPatterns(text: string, mentionRegexes: RegExp[]): return mentionRegexes.some((re) => re.test(cleaned)); } +export type ExplicitMentionSignal = { + hasAnyMention: boolean; + isExplicitlyMentioned: boolean; + canResolveExplicit: boolean; +}; + +export function matchesMentionWithExplicit(params: { + text: string; + mentionRegexes: RegExp[]; + explicit?: ExplicitMentionSignal; +}): boolean { + const cleaned = normalizeMentionText(params.text ?? ""); + const explicit = params.explicit?.isExplicitlyMentioned === true; + const explicitAvailable = params.explicit?.canResolveExplicit === true; + const hasAnyMention = params.explicit?.hasAnyMention === true; + if (hasAnyMention && explicitAvailable) return explicit; + if (!cleaned) return explicit; + return explicit || params.mentionRegexes.some((re) => re.test(cleaned)); +} + export function stripStructuralPrefixes(text: string): string { // Ignore wrapper labels, timestamps, and sender prefixes so directive-only // detection still works in group batches that include history/context. diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 5245fe253..098533aed 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -6,7 +6,10 @@ import { recordPendingHistoryEntryIfEnabled, type HistoryEntry, } from "../../auto-reply/reply/history.js"; -import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; +import { + buildMentionRegexes, + matchesMentionWithExplicit, +} from "../../auto-reply/reply/mentions.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { recordChannelActivity } from "../../infra/channel-activity.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; @@ -174,10 +177,26 @@ export async function preflightDiscordMessage( }, }); const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId); + const explicitlyMentioned = Boolean( + botId && message.mentionedUsers?.some((user: User) => user.id === botId), + ); + const hasAnyMention = Boolean( + !isDirectMessage && + (message.mentionedEveryone || + (message.mentionedUsers?.length ?? 0) > 0 || + (message.mentionedRoles?.length ?? 0) > 0), + ); const wasMentioned = !isDirectMessage && - (Boolean(botId && message.mentionedUsers?.some((user: User) => user.id === botId)) || - matchesMentionPatterns(baseText, mentionRegexes)); + matchesMentionWithExplicit({ + text: baseText, + mentionRegexes, + explicit: { + hasAnyMention, + isExplicitlyMentioned: explicitlyMentioned, + canResolveExplicit: Boolean(botId), + }, + }); const implicitMention = Boolean( !isDirectMessage && botId && @@ -341,12 +360,6 @@ export async function preflightDiscordMessage( channelConfig, guildInfo, }); - const hasAnyMention = Boolean( - !isDirectMessage && - (message.mentionedEveryone || - (message.mentionedUsers?.length ?? 0) > 0 || - (message.mentionedRoles?.length ?? 0) > 0), - ); const allowTextCommands = shouldHandleTextCommands({ cfg: params.cfg, surface: "discord", diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 534d3361b..5783711b1 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -17,7 +17,11 @@ import { resolveEnvelopeFormatOptions, } from "../../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; -import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, + matchesMentionWithExplicit, +} from "../../auto-reply/reply/mentions.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; @@ -200,6 +204,7 @@ export function createPluginRuntime(): PluginRuntime { mentions: { buildMentionRegexes, matchesMentionPatterns, + matchesMentionWithExplicit, }, reactions: { shouldAckReaction, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 3cda8ee51..115cb447e 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -19,6 +19,8 @@ type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer; type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes; type MatchesMentionPatterns = typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns; +type MatchesMentionWithExplicit = + typeof import("../../auto-reply/reply/mentions.js").matchesMentionWithExplicit; type ShouldAckReaction = typeof import("../../channels/ack-reactions.js").shouldAckReaction; type RemoveAckReactionAfterReply = typeof import("../../channels/ack-reactions.js").removeAckReactionAfterReply; @@ -215,6 +217,7 @@ export type PluginRuntime = { mentions: { buildMentionRegexes: BuildMentionRegexes; matchesMentionPatterns: MatchesMentionPatterns; + matchesMentionWithExplicit: MatchesMentionWithExplicit; }; reactions: { shouldAckReaction: ShouldAckReaction; diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 24c251d2a..8a2a9e111 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -12,7 +12,10 @@ import { recordPendingHistoryEntryIfEnabled, } from "../../../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; -import { buildMentionRegexes, matchesMentionPatterns } from "../../../auto-reply/reply/mentions.js"; +import { + buildMentionRegexes, + matchesMentionWithExplicit, +} from "../../../auto-reply/reply/mentions.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; import { buildPairingReply } from "../../../pairing/pairing-messages.js"; @@ -204,11 +207,22 @@ export async function prepareSlackMessage(params: { isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel; const mentionRegexes = buildMentionRegexes(cfg, route.agentId); + const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); + const explicitlyMentioned = Boolean( + ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`), + ); const wasMentioned = opts.wasMentioned ?? (!isDirectMessage && - (Boolean(ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`)) || - matchesMentionPatterns(message.text ?? "", mentionRegexes))); + matchesMentionWithExplicit({ + text: message.text ?? "", + mentionRegexes, + explicit: { + hasAnyMention, + isExplicitlyMentioned: explicitlyMentioned, + canResolveExplicit: Boolean(ctx.botUserId), + }, + })); const implicitMention = Boolean( !isDirectMessage && ctx.botUserId && @@ -232,7 +246,6 @@ export async function prepareSlackMessage(params: { return null; } - const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); const allowTextCommands = shouldHandleTextCommands({ cfg, surface: "slack", diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index e4a7d6780..85d21228e 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -10,7 +10,7 @@ import { type HistoryEntry, } from "../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; -import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js"; +import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; import { recordInboundSession } from "../channels/session.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -299,13 +299,20 @@ export const buildTelegramMessageContext = async ({ if (!bodyText && allMedia.length > 0) { bodyText = `${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`; } - const computedWasMentioned = - (botUsername ? hasBotMention(msg, botUsername) : false) || - matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes); - const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned; const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( (ent) => ent.type === "mention", ); + const explicitlyMentioned = botUsername ? hasBotMention(msg, botUsername) : false; + const computedWasMentioned = matchesMentionWithExplicit({ + text: msg.text ?? msg.caption ?? "", + mentionRegexes, + explicit: { + hasAnyMention, + isExplicitlyMentioned: explicitlyMentioned, + canResolveExplicit: Boolean(botUsername), + }, + }); + const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned; if (isGroup && commandGate.shouldBlock) { logInboundDrop({ log: logVerbose, diff --git a/src/web/auto-reply/mentions.ts b/src/web/auto-reply/mentions.ts index f3f92df25..bf1352799 100644 --- a/src/web/auto-reply/mentions.ts +++ b/src/web/auto-reply/mentions.ts @@ -43,13 +43,16 @@ export function isBotMentionedFromTargets( const isSelfChat = isSelfChatMode(targets.selfE164, mentionCfg.allowFrom); - if (msg.mentionedJids?.length && !isSelfChat) { + const hasMentions = (msg.mentionedJids?.length ?? 0) > 0; + if (hasMentions && !isSelfChat) { if (targets.selfE164 && targets.normalizedMentions.includes(targets.selfE164)) return true; - if (targets.selfJid && targets.selfE164) { + if (targets.selfJid) { // Some mentions use the bare JID; match on E.164 to be safe. if (targets.normalizedMentions.includes(targets.selfJid)) return true; } - } else if (msg.mentionedJids?.length && isSelfChat) { + // If the message explicitly mentions someone else, do not fall back to regex matches. + return false; + } else if (hasMentions && isSelfChat) { // Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in group chats triggers the bot. } const bodyClean = clean(msg.body);