From 014667e00be82d1267c01189dd3d1301d309dfd3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 22:57:08 +0100 Subject: [PATCH] fix: tighten group elevated targeting --- CHANGELOG.md | 1 + docs/concepts/group-messages.md | 2 +- docs/concepts/groups.md | 1 + docs/concepts/multi-agent.md | 4 + docs/gateway/configuration.md | 13 ++++ docs/gateway/sandboxing.md | 1 + docs/gateway/security.md | 2 +- docs/gateway/troubleshooting.md | 1 + docs/multi-agent-sandbox-tools.md | 8 ++ docs/providers/discord.md | 1 + docs/providers/imessage.md | 1 + docs/providers/signal.md | 1 + docs/providers/slack.md | 1 + docs/providers/telegram.md | 2 + docs/providers/whatsapp.md | 1 + docs/tools/elevated.md | 8 ++ docs/tools/index.md | 1 + docs/tools/slash-commands.md | 4 +- src/auto-reply/reply.triggers.test.ts | 87 ++++++++++++++++++++++ src/auto-reply/reply.ts | 9 ++- src/auto-reply/reply/commands.ts | 13 +++- src/auto-reply/reply/directive-handling.ts | 18 ++++- src/auto-reply/reply/mentions.test.ts | 16 ++++ src/auto-reply/reply/mentions.ts | 22 +++++- src/auto-reply/reply/session.ts | 2 +- src/config/types.ts | 2 + src/discord/monitor.ts | 22 +++--- src/imessage/monitor.ts | 24 +++--- src/slack/monitor.ts | 22 +++--- src/telegram/bot.ts | 2 +- src/web/auto-reply.test.ts | 75 +++++++++++++++++++ src/web/auto-reply.ts | 28 +++++-- 32 files changed, 338 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb2734e0..28c68d07a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. - Agent: enable adaptive context pruning by default for tool-result trimming. - Doctor: check config/state permissions and offer to tighten them. — thanks @steipete diff --git a/docs/concepts/group-messages.md b/docs/concepts/group-messages.md index 358a64c95..d452208f6 100644 --- a/docs/concepts/group-messages.md +++ b/docs/concepts/group-messages.md @@ -7,7 +7,7 @@ read_when: Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. -Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. +Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. For multi-agent setups, you can override per agent with `routing.agents..mentionPatterns`. ## What’s implemented (2025-12-03) - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. When `whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). diff --git a/docs/concepts/groups.md b/docs/concepts/groups.md index cf387729d..27020c6d6 100644 --- a/docs/concepts/groups.md +++ b/docs/concepts/groups.md @@ -100,6 +100,7 @@ Group messages require a mention unless overridden per group. Defaults live per Notes: - `mentionPatterns` are case-insensitive regexes. - Surfaces that provide explicit mentions still pass; patterns are a fallback. +- Per-agent override: `routing.agents..mentionPatterns` (useful when multiple agents share a group). - Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - Discord defaults live in `discord.guilds."*"` (overridable per guild/channel). diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 9627932fa..56a0521c8 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -186,4 +186,8 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio - **Resource control**: Sandbox specific agents while keeping others on host - **Flexible policies**: Different permissions per agent +Note: `agent.elevated` is **global** and sender-based; it is not configurable per agent. +If you need per-agent boundaries, use `routing.agents[id].tools` to deny `bash`. +For group targeting, you can set `routing.agents[id].mentionPatterns` so @mentions map cleanly to the intended agent. + See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for detailed examples. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 62b3e619f..8c488e006 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -319,6 +319,7 @@ Group messages default to **require mention** (either metadata mention or regex - **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`). - **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode. - Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`). + - Per-agent override: `routing.agents..mentionPatterns` (useful when multiple agents share a group). ```json5 { @@ -331,6 +332,18 @@ Group messages default to **require mention** (either metadata mention or regex } ``` +Per-agent override (takes precedence when set, even `[]`): +```json5 +{ + routing: { + agents: { + work: { mentionPatterns: ["@workbot", "\\+15555550123"] }, + personal: { mentionPatterns: ["@homebot", "\\+15555550999"] } + } + } +} +``` + Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`). When `*.groups` is set, it also acts as a group allowlist; include `"*"` to allow all groups. To respond **only** to specific text triggers (ignoring native @-mentions): diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 4ae1fa662..f17dfc27f 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -23,6 +23,7 @@ and process access when the model does something dumb. Not sandboxed: - The Gateway process itself. - Any tool explicitly allowed to run on the host (e.g. `agent.elevated`). + - **Elevated bash runs on the host and bypasses sandboxing.** ## Modes `agent.sandbox.mode` controls **when** sandboxing is used: diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 3dda917b7..c3730c152 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -162,7 +162,7 @@ Also consider agent workspace access inside the sandbox: - `workspaceAccess: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`) - `workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace` -Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers. +Important: `agent.elevated` is a **global**, sender-based escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers. See [Elevated Mode](/tools/elevated). ## Per-agent access profiles (multi-agent) diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index f1295cb0c..be0157064 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -114,6 +114,7 @@ Look for `AllowFrom: ...` in the output. **Check 2:** For group chats, is mention required? ```bash # The message must match mentionPatterns or explicit mentions; defaults live in provider groups/guilds. +# Multi-agent: `routing.agents..mentionPatterns` overrides global patterns. grep -n "routing\\|groupChat\\|mentionPatterns\\|whatsapp\\.groups\\|telegram\\.groups\\|imessage\\.groups\\|discord\\.guilds" \ "${CLAWDBOT_CONFIG_PATH:-$HOME/.clawdbot/clawdbot.json}" ``` diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index 5b5604e95..58d47cee7 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -169,6 +169,14 @@ The filtering order is: Each level can further restrict tools, but cannot grant back denied tools from earlier levels. If `routing.agents[id].sandbox.tools` is set, it replaces `agent.sandbox.tools` for that agent. +### Elevated Mode (global) +`agent.elevated` is **global** and **sender-based** (per-provider allowlist). It is **not** configurable per agent. + +Mitigation patterns: +- Deny `bash` for untrusted agents (`routing.agents[id].tools.deny: ["bash"]`) +- Avoid allowlisting senders that route to restricted agents +- Disable elevated globally (`agent.elevated.enabled: false`) if you only want sandboxed execution + --- ## Migration from Single Agent diff --git a/docs/providers/discord.md b/docs/providers/discord.md index fbf512dc0..c74cec95b 100644 --- a/docs/providers/discord.md +++ b/docs/providers/discord.md @@ -138,6 +138,7 @@ Example “single server, only allow me, only allow #help”: Notes: - `requireMention: true` means the bot only replies when mentioned (recommended for shared channels). - `routing.groupChat.mentionPatterns` also count as mentions for guild messages. +- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. - If `channels` is present, any channel not listed is denied by default. ### 6) Verify it works diff --git a/docs/providers/imessage.md b/docs/providers/imessage.md index f036ae629..2da09f276 100644 --- a/docs/providers/imessage.md +++ b/docs/providers/imessage.md @@ -68,6 +68,7 @@ Groups: - `imessage.groupPolicy = open | allowlist | disabled`. - `imessage.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. - Mention gating uses `routing.groupChat.mentionPatterns` (iMessage has no native mention metadata). +- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. ## How it works (behavior) - `imsg` streams message events; the gateway normalizes them into the shared provider envelope. diff --git a/docs/providers/signal.md b/docs/providers/signal.md index a3f081616..ff1ec8b9f 100644 --- a/docs/providers/signal.md +++ b/docs/providers/signal.md @@ -93,4 +93,5 @@ Provider options: Related global options: - `routing.groupChat.mentionPatterns` (Signal does not support native mentions). +- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. - `messages.responsePrefix`. diff --git a/docs/providers/slack.md b/docs/providers/slack.md index d71e2e668..9b21a6883 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -251,6 +251,7 @@ Slack tool actions can be gated with `slack.actions.*`: ## Notes - Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions. +- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. - Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`). - Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels..allowBots`. - For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions). diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index a0c7b3e79..547e11be3 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -66,6 +66,7 @@ group messages, so use admin if you need full visibility. ## How it works (behavior) - Inbound messages are normalized into the shared provider envelope with reply context and media placeholders. - Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`). +- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. - Replies always route back to the same Telegram chat. - Long-polling uses grammY runner with per-chat sequencing; overall concurrency is capped by `agent.maxConcurrent`. @@ -279,5 +280,6 @@ Provider options: Related global options: - `routing.groupChat.mentionPatterns` (mention gating patterns). +- `routing.agents..mentionPatterns` overrides for multi-agent setups. - `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior). - `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`. diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index f2750625f..1530e1b13 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -170,6 +170,7 @@ Recommended for personal numbers: - `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `whatsapp.actions.reactions` (gate WhatsApp tool reactions). - `routing.groupChat.mentionPatterns` +- Multi-agent override: `routing.agents..mentionPatterns` takes precedence. - `routing.groupChat.historyLimit` - `messages.messagePrefix` (inbound prefix) - `messages.responsePrefix` (outbound prefix) diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index a88d5ea9c..eb22ddd91 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -10,6 +10,14 @@ read_when: - Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`. - Only `on|off` are accepted; anything else returns a hint and does not change state. +## What it controls (and what it doesn’t) +- **Global availability gate**: `agent.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere. +- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. +- **Inline directive**: `/elevated on` inside a message applies to that message only. +- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. +- **Host execution**: elevated runs `bash` on the host (bypasses sandbox). +- **Tool policy still applies**: if `bash` is denied by tool policy, elevated cannot be used. + ## Resolution order 1. Inline directive on the message (applies only to that message). 2. Session override (set by sending a directive-only message). diff --git a/docs/tools/index.md b/docs/tools/index.md index b29712258..a27731fc1 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -43,6 +43,7 @@ Notes: - Returns `status: "running"` with a `sessionId` when backgrounded. - Use `process` to poll/log/write/kill/clear background sessions. - If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`. +- `elevated` is gated by `agent.elevated` (global sender allowlist) and runs on the host. ### `process` Manage background bash sessions. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index fa9e4e636..9e34bcfad 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -7,7 +7,9 @@ read_when: # Slash commands Commands are handled by the Gateway. Send them as a **standalone** message that starts with `/`. -Inline text like `hello /status` is ignored. +Inline text like `hello /status` is ignored for commands. + +Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even when inline and are stripped from the message before the model sees it. ## Config diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 860c02c34..8a4ed150f 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -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({ diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 7c7b06539..5248fb5c6 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -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, diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 5c80076c4..ba61e5c0b 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -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; @@ -530,6 +536,7 @@ export async function handleCommands(params: { rawBody: ctx.Body, ctx, cfg, + agentId: params.agentId, isGroup, }); const result = await compactEmbeddedPiSession({ diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 0b6e3ecf6..cdc2fc8ac 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -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; } diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts index 34639c5aa..7d218f305 100644 --- a/src/auto-reply/reply/mentions.test.ts +++ b/src/auto-reply/reply/mentions.test.ts @@ -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); + }); }); diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index d9edcfa0f..6403776e0 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -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"); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 0b141d82a..65744c62e 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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; diff --git a/src/config/types.ts b/src/config/types.ts index 0a9245058..0aa90d22d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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[]; diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 754d5f302..4a548b7fe 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -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({ diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index b30f066b6..3aed656f7 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -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, diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 47b70b4c8..729cf660a 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -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; diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 713acdb3a..42d601ed7 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -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, diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 54e74c71c..5a456cf04 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -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) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + 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); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index f2540712f..0ab2b7ddf 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -162,8 +162,11 @@ type MentionConfig = { allowFrom?: Array; }; -function buildMentionConfig(cfg: ReturnType): MentionConfig { - const mentionRegexes = buildMentionRegexes(cfg); +function buildMentionConfig( + cfg: ReturnType, + 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);