fix: tighten group elevated targeting

This commit is contained in:
Peter Steinberger
2026-01-08 22:57:08 +01:00
parent cda2025c49
commit 014667e00b
32 changed files with 338 additions and 57 deletions

View File

@@ -340,6 +340,93 @@ describe("trigger handling", () => {
});
});
it("ignores elevated directive in groups when not mentioned", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: false } },
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "group:123@g.us",
To: "whatsapp:+2000",
Provider: "whatsapp",
SenderE164: "+1000",
ChatType: "group",
WasMentioned: false,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(text).not.toContain("Elevated mode enabled");
});
});
it("allows elevated directive in groups when mentioned", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
groups: { "*": { requireMention: true } },
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "group:123@g.us",
To: "whatsapp:+2000",
Provider: "whatsapp",
SenderE164: "+1000",
ChatType: "group",
WasMentioned: true,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe(
"on",
);
});
});
it("ignores inline elevated directive for unapproved sender", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

@@ -329,8 +329,10 @@ export async function getReplyFromConfig(
.map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true;
let parsedDirectives = parseInlineDirectives(rawBody, {
modelAliases: configuredAliases,
disableElevated: disableElevatedInGroup,
});
const hasDirective =
parsedDirectives.hasThinkDirective ||
@@ -342,7 +344,9 @@ export async function getReplyFromConfig(
parsedDirectives.hasQueueDirective;
if (hasDirective) {
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
const noMentions = isGroup
? stripMentions(stripped, ctx, cfg, agentId)
: stripped;
if (noMentions.trim().length > 0) {
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
}
@@ -467,6 +471,7 @@ export async function getReplyFromConfig(
cleanedBody: directives.cleaned,
ctx,
cfg,
agentId,
isGroup,
})
) {
@@ -549,6 +554,7 @@ export async function getReplyFromConfig(
const command = buildCommandContext({
ctx,
cfg,
agentId,
sessionKey,
isGroup,
triggerBodyNormalized,
@@ -579,6 +585,7 @@ export async function getReplyFromConfig(
ctx,
cfg,
command,
agentId,
directives,
sessionEntry,
sessionStore,

View File

@@ -125,11 +125,12 @@ function extractCompactInstructions(params: {
rawBody?: string;
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
}): string | undefined {
const raw = stripStructuralPrefixes(params.rawBody ?? "");
const stripped = params.isGroup
? stripMentions(raw, params.ctx, params.cfg)
? stripMentions(raw, params.ctx, params.cfg, params.agentId)
: raw;
const trimmed = stripped.trim();
if (!trimmed) return undefined;
@@ -144,12 +145,14 @@ function extractCompactInstructions(params: {
export function buildCommandContext(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
sessionKey?: string;
isGroup: boolean;
triggerBodyNormalized: string;
commandAuthorized: boolean;
}): CommandContext {
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } =
params;
const auth = resolveCommandAuthorization({
ctx,
cfg,
@@ -161,7 +164,9 @@ export function buildCommandContext(params: {
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = normalizeCommandBody(
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized,
isGroup
? stripMentions(rawBodyNormalized, ctx, cfg, agentId)
: rawBodyNormalized,
);
return {
@@ -206,6 +211,7 @@ export async function handleCommands(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
command: CommandContext;
agentId?: string;
directives: InlineDirectives;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
@@ -530,6 +536,7 @@ export async function handleCommands(params: {
rawBody: ctx.Body,
ctx,
cfg,
agentId: params.agentId,
isGroup,
});
const result = await compactEmbeddedPiSession({

View File

@@ -184,7 +184,7 @@ export type InlineDirectives = {
export function parseInlineDirectives(
body: string,
options?: { modelAliases?: string[] },
options?: { modelAliases?: string[]; disableElevated?: boolean },
): InlineDirectives {
const {
cleaned: thinkCleaned,
@@ -209,7 +209,14 @@ export function parseInlineDirectives(
elevatedLevel,
rawLevel: rawElevatedLevel,
hasDirective: hasElevatedDirective,
} = extractElevatedDirective(reasoningCleaned);
} = options?.disableElevated
? {
cleaned: reasoningCleaned,
elevatedLevel: undefined,
rawLevel: undefined,
hasDirective: false,
}
: extractElevatedDirective(reasoningCleaned);
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } =
extractStatusDirective(elevatedCleaned);
const {
@@ -272,9 +279,10 @@ export function isDirectiveOnly(params: {
cleanedBody: string;
ctx: MsgContext;
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
}): boolean {
const { directives, cleanedBody, ctx, cfg, isGroup } = params;
const { directives, cleanedBody, ctx, cfg, agentId, isGroup } = params;
if (
!directives.hasThinkDirective &&
!directives.hasVerboseDirective &&
@@ -285,7 +293,9 @@ export function isDirectiveOnly(params: {
)
return false;
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
const noMentions = isGroup
? stripMentions(stripped, ctx, cfg, agentId)
: stripped;
return noMentions.length === 0;
}

View File

@@ -27,4 +27,20 @@ describe("mention helpers", () => {
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
routing: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
agents: {
work: { mentionPatterns: ["\\bworkbot\\b"] },
},
},
},
"work",
);
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
});
});

View File

@@ -1,8 +1,23 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] {
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
function resolveMentionPatterns(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): string[] {
if (!cfg) return [];
const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined;
if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) {
return agentConfig.mentionPatterns ?? [];
}
return cfg.routing?.groupChat?.mentionPatterns ?? [];
}
export function buildMentionRegexes(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): RegExp[] {
const patterns = resolveMentionPatterns(cfg, agentId);
return patterns
.map((pattern) => {
try {
@@ -48,9 +63,10 @@ export function stripMentions(
text: string,
ctx: MsgContext,
cfg: ClawdbotConfig | undefined,
agentId?: string,
): string {
let result = text;
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
const patterns = resolveMentionPatterns(cfg, agentId);
for (const p of patterns) {
try {
const re = new RegExp(p, "gi");

View File

@@ -136,7 +136,7 @@ export async function initSessionState(params: {
// web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets.
const strippedForReset = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg)
? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
: triggerBodyNormalized;
for (const trigger of resetTriggers) {
if (!trigger) continue;

View File

@@ -723,6 +723,8 @@ export type RoutingConfig = {
workspace?: string;
agentDir?: string;
model?: string;
/** Per-agent override for group mention patterns. */
mentionPatterns?: string[];
subagents?: {
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
allowAgents?: string[];

View File

@@ -479,7 +479,6 @@ export function createDiscordMessageHandler(params: {
guildEntries,
} = params;
const logger = getChildLogger({ module: "discord-auto-reply" });
const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const groupPolicy = discordConfig?.groupPolicy ?? "open";
@@ -576,6 +575,17 @@ export function createDiscordMessageHandler(params: {
}
const botId = botUserId;
const baseText = resolveDiscordMessageText(message);
const route = resolveAgentRoute({
cfg,
provider: "discord",
accountId,
guildId: data.guild_id ?? undefined,
peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? author.id : message.channelId,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const wasMentioned =
!isDirectMessage &&
(Boolean(
@@ -647,16 +657,6 @@ export function createDiscordMessageHandler(params: {
guildInfo?.slug ||
(data.guild?.name ? normalizeDiscordSlug(data.guild.name) : "");
const route = resolveAgentRoute({
cfg,
provider: "discord",
accountId,
guildId: data.guild_id ?? undefined,
peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? author.id : message.channelId,
},
});
const baseSessionKey = route.sessionKey;
const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({

View File

@@ -149,7 +149,6 @@ export async function monitorIMessageProvider(
);
const groupPolicy = imessageCfg.groupPolicy ?? "open";
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
const mentionRegexes = buildMentionRegexes(cfg);
const includeAttachments =
opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
const mediaMaxBytes =
@@ -287,6 +286,18 @@ export async function monitorIMessageProvider(
}
}
const route = resolveAgentRoute({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup
? String(chatId ?? "unknown")
: normalizeIMessageHandle(sender),
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const messageText = (message.text ?? "").trim();
const mentioned = isGroup
? matchesMentionPatterns(messageText, mentionRegexes)
@@ -357,17 +368,6 @@ export async function monitorIMessageProvider(
body: bodyText,
});
const route = resolveAgentRoute({
cfg,
provider: "imessage",
accountId: accountInfo.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup
? String(chatId ?? "unknown")
: normalizeIMessageHandle(sender),
},
});
const imessageTo = chatTarget || `imessage:${sender}`;
const ctxPayload = {
Body: body,

View File

@@ -508,7 +508,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
opts.slashCommand ?? slackCfg.slashCommand,
);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes =
@@ -855,6 +854,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}
}
const route = resolveAgentRoute({
cfg,
provider: "slack",
accountId: account.accountId,
teamId: teamId || undefined,
peer: {
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const wasMentioned =
opts.wasMentioned ??
(!isDirectMessage &&
@@ -963,16 +973,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
: isRoom
? `slack:channel:${message.channel}`
: `slack:group:${message.channel}`;
const route = resolveAgentRoute({
cfg,
provider: "slack",
accountId: account.accountId,
teamId: teamId || undefined,
peer: {
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
},
});
const baseSessionKey = route.sessionKey;
const threadTs = message.thread_ts;
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;

View File

@@ -225,7 +225,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const mediaMaxBytes =
(opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024;
const logger = getChildLogger({ module: "telegram-auto-reply" });
const mentionRegexes = buildMentionRegexes(cfg);
let botHasTopicsEnabled: boolean | undefined;
const resolveBotTopicsEnabled = async (ctx?: TelegramContext) => {
const fromCtx = ctx?.me as { has_topics_enabled?: boolean } | undefined;
@@ -322,6 +321,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
id: peerId,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const effectiveDmAllow = normalizeAllowFrom([
...(allowFrom ?? []),
...storeAllowFrom,

View File

@@ -1132,6 +1132,81 @@ describe("web auto-reply", () => {
expect(payload.Body).toContain("[from: Bob (+222)]");
});
it("uses per-agent mention patterns for group gating", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
setLoadConfigMock(() => ({
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: true } },
},
routing: {
groupChat: { mentionPatterns: ["@global"] },
agents: {
work: { mentionPatterns: ["@workbot"] },
},
bindings: [
{
agentId: "work",
match: { provider: "whatsapp", peer: { kind: "group", id: "123@g.us" } },
},
],
},
}));
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "@global ping",
from: "123@g.us",
conversationId: "123@g.us",
chatId: "123@g.us",
chatType: "group",
to: "+2",
id: "g1",
senderE164: "+111",
senderName: "Alice",
selfE164: "+999",
sendComposing,
reply,
sendMedia,
});
expect(resolver).not.toHaveBeenCalled();
await capturedOnMessage?.({
body: "@workbot ping",
from: "123@g.us",
conversationId: "123@g.us",
chatId: "123@g.us",
chatType: "group",
to: "+2",
id: "g2",
senderE164: "+222",
senderName: "Bob",
selfE164: "+999",
sendComposing,
reply,
sendMedia,
});
expect(resolver).toHaveBeenCalledTimes(1);
});
it("allows group messages when whatsapp groups default disables mention gating", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);

View File

@@ -162,8 +162,11 @@ type MentionConfig = {
allowFrom?: Array<string | number>;
};
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
const mentionRegexes = buildMentionRegexes(cfg);
function buildMentionConfig(
cfg: ReturnType<typeof loadConfig>,
agentId?: string,
): MentionConfig {
const mentionRegexes = buildMentionRegexes(cfg, agentId);
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
}
@@ -793,7 +796,9 @@ export async function monitorWebProvider(
tuning.heartbeatSeconds,
);
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const mentionConfig = buildMentionConfig(cfg);
const resolveMentionConfig = (agentId?: string) =>
buildMentionConfig(cfg, agentId);
const baseMentionConfig = resolveMentionConfig();
const groupHistoryLimit =
cfg.routing?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT;
const groupHistories = new Map<
@@ -913,7 +918,7 @@ export async function monitorWebProvider(
};
const resolveOwnerList = (selfE164?: string | null) => {
const allowFrom = mentionConfig.allowFrom;
const allowFrom = baseMentionConfig.allowFrom;
const raw =
Array.isArray(allowFrom) && allowFrom.length > 0
? allowFrom
@@ -943,9 +948,13 @@ export async function monitorWebProvider(
);
};
const stripMentionsForCommand = (text: string, selfE164?: string | null) => {
const stripMentionsForCommand = (
text: string,
mentionRegexes: RegExp[],
selfE164?: string | null,
) => {
let result = text;
for (const re of mentionConfig.mentionRegexes) {
for (const re of mentionRegexes) {
result = result.replace(re, " ");
}
if (selfE164) {
@@ -1362,7 +1371,12 @@ export async function monitorWebProvider(
});
}
noteGroupMember(groupHistoryKey, msg.senderE164, msg.senderName);
const commandBody = stripMentionsForCommand(msg.body, msg.selfE164);
const mentionConfig = resolveMentionConfig(route.agentId);
const commandBody = stripMentionsForCommand(
msg.body,
mentionConfig.mentionRegexes,
msg.selfE164,
);
const activationCommand = parseActivationCommand(commandBody);
const isOwner = isOwnerSender(msg);
const statusCommand = isStatusCommand(commandBody);