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

@@ -2,6 +2,7 @@
## Unreleased ## 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. - 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. - Agent: enable adaptive context pruning by default for tool-result trimming.
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete - Doctor: check config/state permissions and offer to tighten them. — thanks @steipete

View File

@@ -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. 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.<agentId>.mentionPatterns`.
## Whats implemented (2025-12-03) ## Whats implemented (2025-12-03)
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots 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). - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots 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).

View File

@@ -100,6 +100,7 @@ Group messages require a mention unless overridden per group. Defaults live per
Notes: Notes:
- `mentionPatterns` are case-insensitive regexes. - `mentionPatterns` are case-insensitive regexes.
- Surfaces that provide explicit mentions still pass; patterns are a fallback. - Surfaces that provide explicit mentions still pass; patterns are a fallback.
- Per-agent override: `routing.agents.<agentId>.mentionPatterns` (useful when multiple agents share a group).
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - 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). - Discord defaults live in `discord.guilds."*"` (overridable per guild/channel).

View File

@@ -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 - **Resource control**: Sandbox specific agents while keeping others on host
- **Flexible policies**: Different permissions per agent - **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. See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for detailed examples.

View File

@@ -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`). - **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. - **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`). - Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`).
- Per-agent override: `routing.agents.<agentId>.mentionPatterns` (useful when multiple agents share a group).
```json5 ```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. 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): To respond **only** to specific text triggers (ignoring native @-mentions):

View File

@@ -23,6 +23,7 @@ and process access when the model does something dumb.
Not sandboxed: Not sandboxed:
- The Gateway process itself. - The Gateway process itself.
- Any tool explicitly allowed to run on the host (e.g. `agent.elevated`). - Any tool explicitly allowed to run on the host (e.g. `agent.elevated`).
- **Elevated bash runs on the host and bypasses sandboxing.**
## Modes ## Modes
`agent.sandbox.mode` controls **when** sandboxing is used: `agent.sandbox.mode` controls **when** sandboxing is used:

View File

@@ -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: "ro"` mounts the agent workspace read-only at `/agent` (disables `write`/`edit`)
- `workspaceAccess: "rw"` mounts the agent workspace read/write at `/workspace` - `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 dont 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 dont enable it for strangers. See [Elevated Mode](/tools/elevated).
## Per-agent access profiles (multi-agent) ## Per-agent access profiles (multi-agent)

View File

@@ -114,6 +114,7 @@ Look for `AllowFrom: ...` in the output.
**Check 2:** For group chats, is mention required? **Check 2:** For group chats, is mention required?
```bash ```bash
# The message must match mentionPatterns or explicit mentions; defaults live in provider groups/guilds. # The message must match mentionPatterns or explicit mentions; defaults live in provider groups/guilds.
# Multi-agent: `routing.agents.<agentId>.mentionPatterns` overrides global patterns.
grep -n "routing\\|groupChat\\|mentionPatterns\\|whatsapp\\.groups\\|telegram\\.groups\\|imessage\\.groups\\|discord\\.guilds" \ grep -n "routing\\|groupChat\\|mentionPatterns\\|whatsapp\\.groups\\|telegram\\.groups\\|imessage\\.groups\\|discord\\.guilds" \
"${CLAWDBOT_CONFIG_PATH:-$HOME/.clawdbot/clawdbot.json}" "${CLAWDBOT_CONFIG_PATH:-$HOME/.clawdbot/clawdbot.json}"
``` ```

View File

@@ -169,6 +169,14 @@ The filtering order is:
Each level can further restrict tools, but cannot grant back denied tools from earlier levels. 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. 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 ## Migration from Single Agent

View File

@@ -138,6 +138,7 @@ Example “single server, only allow me, only allow #help”:
Notes: Notes:
- `requireMention: true` means the bot only replies when mentioned (recommended for shared channels). - `requireMention: true` means the bot only replies when mentioned (recommended for shared channels).
- `routing.groupChat.mentionPatterns` also count as mentions for guild messages. - `routing.groupChat.mentionPatterns` also count as mentions for guild messages.
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence.
- If `channels` is present, any channel not listed is denied by default. - If `channels` is present, any channel not listed is denied by default.
### 6) Verify it works ### 6) Verify it works

View File

@@ -68,6 +68,7 @@ Groups:
- `imessage.groupPolicy = open | allowlist | disabled`. - `imessage.groupPolicy = open | allowlist | disabled`.
- `imessage.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. - `imessage.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
- Mention gating uses `routing.groupChat.mentionPatterns` (iMessage has no native mention metadata). - Mention gating uses `routing.groupChat.mentionPatterns` (iMessage has no native mention metadata).
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence.
## How it works (behavior) ## How it works (behavior)
- `imsg` streams message events; the gateway normalizes them into the shared provider envelope. - `imsg` streams message events; the gateway normalizes them into the shared provider envelope.

View File

@@ -93,4 +93,5 @@ Provider options:
Related global options: Related global options:
- `routing.groupChat.mentionPatterns` (Signal does not support native mentions). - `routing.groupChat.mentionPatterns` (Signal does not support native mentions).
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence.
- `messages.responsePrefix`. - `messages.responsePrefix`.

View File

@@ -251,6 +251,7 @@ Slack tool actions can be gated with `slack.actions.*`:
## Notes ## Notes
- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions. - Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions.
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence.
- Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`). - Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`).
- Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels.<id>.allowBots`. - Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels.<id>.allowBots`.
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions). - For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).

View File

@@ -66,6 +66,7 @@ group messages, so use admin if you need full visibility.
## How it works (behavior) ## How it works (behavior)
- Inbound messages are normalized into the shared provider envelope with reply context and media placeholders. - 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`). - Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`).
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence.
- Replies always route back to the same Telegram chat. - 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`. - 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: Related global options:
- `routing.groupChat.mentionPatterns` (mention gating patterns). - `routing.groupChat.mentionPatterns` (mention gating patterns).
- `routing.agents.<agentId>.mentionPatterns` overrides for multi-agent setups.
- `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior). - `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior).
- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`. - `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`.

View File

@@ -170,6 +170,7 @@ Recommended for personal numbers:
- `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all)
- `whatsapp.actions.reactions` (gate WhatsApp tool reactions). - `whatsapp.actions.reactions` (gate WhatsApp tool reactions).
- `routing.groupChat.mentionPatterns` - `routing.groupChat.mentionPatterns`
- Multi-agent override: `routing.agents.<agentId>.mentionPatterns` takes precedence.
- `routing.groupChat.historyLimit` - `routing.groupChat.historyLimit`
- `messages.messagePrefix` (inbound prefix) - `messages.messagePrefix` (inbound prefix)
- `messages.responsePrefix` (outbound prefix) - `messages.responsePrefix` (outbound prefix)

View File

@@ -10,6 +10,14 @@ read_when:
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`. - 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. - Only `on|off` are accepted; anything else returns a hint and does not change state.
## What it controls (and what it doesnt)
- **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 ## Resolution order
1. Inline directive on the message (applies only to that message). 1. Inline directive on the message (applies only to that message).
2. Session override (set by sending a directive-only message). 2. Session override (set by sending a directive-only message).

View File

@@ -43,6 +43,7 @@ Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded. - Returns `status: "running"` with a `sessionId` when backgrounded.
- Use `process` to poll/log/write/kill/clear background sessions. - Use `process` to poll/log/write/kill/clear background sessions.
- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`. - 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` ### `process`
Manage background bash sessions. Manage background bash sessions.

View File

@@ -7,7 +7,9 @@ read_when:
# Slash commands # Slash commands
Commands are handled by the Gateway. Send them as a **standalone** message that starts with `/`. 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 ## Config

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 () => { it("ignores inline elevated directive for unapproved sender", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

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

View File

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

View File

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

View File

@@ -27,4 +27,20 @@ describe("mention helpers", () => {
}); });
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true); 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 { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] { function resolveMentionPatterns(
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? []; 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 return patterns
.map((pattern) => { .map((pattern) => {
try { try {
@@ -48,9 +63,10 @@ export function stripMentions(
text: string, text: string,
ctx: MsgContext, ctx: MsgContext,
cfg: ClawdbotConfig | undefined, cfg: ClawdbotConfig | undefined,
agentId?: string,
): string { ): string {
let result = text; let result = text;
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? []; const patterns = resolveMentionPatterns(cfg, agentId);
for (const p of patterns) { for (const p of patterns) {
try { try {
const re = new RegExp(p, "gi"); 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" // web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets. // from matching, so strip structural wrappers when checking for resets.
const strippedForReset = isGroup const strippedForReset = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg) ? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
: triggerBodyNormalized; : triggerBodyNormalized;
for (const trigger of resetTriggers) { for (const trigger of resetTriggers) {
if (!trigger) continue; if (!trigger) continue;

View File

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

View File

@@ -479,7 +479,6 @@ export function createDiscordMessageHandler(params: {
guildEntries, guildEntries,
} = params; } = params;
const logger = getChildLogger({ module: "discord-auto-reply" }); const logger = getChildLogger({ module: "discord-auto-reply" });
const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const groupPolicy = discordConfig?.groupPolicy ?? "open"; const groupPolicy = discordConfig?.groupPolicy ?? "open";
@@ -576,6 +575,17 @@ export function createDiscordMessageHandler(params: {
} }
const botId = botUserId; const botId = botUserId;
const baseText = resolveDiscordMessageText(message); 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 = const wasMentioned =
!isDirectMessage && !isDirectMessage &&
(Boolean( (Boolean(
@@ -647,16 +657,6 @@ export function createDiscordMessageHandler(params: {
guildInfo?.slug || guildInfo?.slug ||
(data.guild?.name ? normalizeDiscordSlug(data.guild.name) : ""); (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 baseSessionKey = route.sessionKey;
const channelConfig = isGuildMessage const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({ ? resolveDiscordChannelConfig({

View File

@@ -149,7 +149,6 @@ export async function monitorIMessageProvider(
); );
const groupPolicy = imessageCfg.groupPolicy ?? "open"; const groupPolicy = imessageCfg.groupPolicy ?? "open";
const dmPolicy = imessageCfg.dmPolicy ?? "pairing"; const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
const mentionRegexes = buildMentionRegexes(cfg);
const includeAttachments = const includeAttachments =
opts.includeAttachments ?? imessageCfg.includeAttachments ?? false; opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
const mediaMaxBytes = 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 messageText = (message.text ?? "").trim();
const mentioned = isGroup const mentioned = isGroup
? matchesMentionPatterns(messageText, mentionRegexes) ? matchesMentionPatterns(messageText, mentionRegexes)
@@ -357,17 +368,6 @@ export async function monitorIMessageProvider(
body: bodyText, 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 imessageTo = chatTarget || `imessage:${sender}`;
const ctxPayload = { const ctxPayload = {
Body: body, Body: body,

View File

@@ -508,7 +508,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
opts.slashCommand ?? slackCfg.slashCommand, opts.slashCommand ?? slackCfg.slashCommand,
); );
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const mediaMaxBytes = 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 = const wasMentioned =
opts.wasMentioned ?? opts.wasMentioned ??
(!isDirectMessage && (!isDirectMessage &&
@@ -963,16 +973,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
: isRoom : isRoom
? `slack:channel:${message.channel}` ? `slack:channel:${message.channel}`
: `slack:group:${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 baseSessionKey = route.sessionKey;
const threadTs = message.thread_ts; const threadTs = message.thread_ts;
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0; const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;

View File

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

View File

@@ -1132,6 +1132,81 @@ describe("web auto-reply", () => {
expect(payload.Body).toContain("[from: Bob (+222)]"); 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 () => { it("allows group messages when whatsapp groups default disables mention gating", async () => {
const sendMedia = vi.fn(); const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(undefined);

View File

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