fix: enforce explicit mention gating across channels

This commit is contained in:
Peter Steinberger
2026-01-24 11:09:15 +00:00
parent ab000398be
commit d905ca0e02
8 changed files with 87 additions and 22 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;

View File

@@ -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",

View File

@@ -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 = `<media:image>${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,

View File

@@ -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);