security: fix remaining gaps in WhatsApp remediation

- Heartbeat now requires explicit target by default (throws error)
- Empty automation.recipients blocks all automation sends
- Send logging shows correct resolution source (explicit/directory/fallback)
- Delivery context has 24h TTL and clears on channel logout
- Legacy WhatsApp adapter now has automation checks
- Request logging for all send operations
- First-time recipient warnings
- Dry-run mode support (CLAWDBOT_DRY_RUN=true)
- Security documentation added
This commit is contained in:
Diogo Ortega 2026-01-25 15:20:53 +00:00
parent fc379a1332
commit e712faadb9
26 changed files with 2024 additions and 88 deletions

View File

@ -0,0 +1,329 @@
---
title: Outbound Messaging Security Model
layout: default
nav_order: 1
parent: Security
---
# Outbound Messaging Security Model
This document explains Clawdbot's outbound messaging security model, including allowlist enforcement, target resolution, sub-agent messaging best practices, and automation recipient configuration.
## Overview
Clawdbot implements multiple layers of security for outbound messaging to prevent unauthorized message sends:
1. **Allowlist Enforcement** - Validates targets against configured allowlists
2. **Automation Recipients** - Separate allowlist for automated/system sends
3. **Sub-Agent Restrictions** - Blocks direct message sends from sub-agents
4. **Target Resolution** - Controlled resolution of message targets
5. **Request Logging** - Full audit trail of all send operations
6. **First-Time Recipient Warnings** - Alerts for sends to new recipients
7. **Dry-Run Mode** - Development/testing mode to prevent accidental sends
## Allowlist Enforcement
### Configuration
Allowlists are configured per channel. For WhatsApp:
```json
{
"channels": {
"whatsapp": {
"allowFrom": ["+16505551234", "+16505555678"],
"accounts": {
"default": {
"allowFrom": ["+16505551234"]
}
}
}
}
}
```
### Enforcement Rules
1. **Explicit Mode Sends** (CLI with `--target`, direct API calls):
- Target MUST be in allowlist OR use `--allow-unlisted` flag
- Groups are always allowed (no allowlist check)
- Empty allowlist = all targets allowed
2. **Implicit Mode Sends** (session-derived routing):
- If target not in allowlist, falls back to first allowlist entry
- Provides safe default behavior
3. **Heartbeat/Automation Mode Sends**:
- Validates against `automation.recipients` if configured
- Falls back to general allowlist if automation.recipients is empty
### Security Bypass
The `--allow-unlisted` CLI flag allows sending to targets not in the allowlist:
```bash
clawdbot message send --target +16505559999 --message "Hello" --allow-unlisted
```
This is logged and should only be used for emergencies.
## Automation Recipients
### Purpose
The `automation.recipients` list provides a stricter allowlist specifically for automated sends (heartbeats, cron jobs, system events, daemon notifications).
### Configuration
```json
{
"channels": {
"whatsapp": {
"automation": {
"recipients": ["+16505551234"]
},
"accounts": {
"default": {
"automation": {
"recipients": ["+16505551234"]
}
}
}
}
}
}
```
### Behavior
- If `automation.recipients` is configured (non-empty), only those numbers can receive automated sends
- If empty/not configured, falls back to general `allowFrom` behavior
- This separation prevents automation from accidentally messaging contacts that are allowed for interactive use but shouldn't receive automated notifications
## Sub-Agent Messaging Restrictions
### Default Behavior (FIX-1.5)
By default, sub-agents CANNOT directly call `clawdbot message send` or the message RPC API. This prevents:
- Prompt injection attacks causing sub-agents to spam messages
- Sub-agents bypassing session routing controls
- Accidental sends from poorly-written sub-agent prompts
### Blocked Actions
- `send` - Direct message sends
- `poll` - Poll creation
- `broadcast` - Broadcast sends
### Allowed Actions
Sub-agents can still use:
- `sessions_send` tool - Routes through parent session context
- Blessed callback mechanisms that go through proper routing
### Security Logging
Blocked attempts are logged:
```
[security] Subagent agent:main:subagent:abc123 attempted direct message send - blocked
```
### Override (Not Recommended)
To allow direct sends (use with caution):
```json
{
"agents": {
"defaults": {
"subagents": {
"allowDirectMessageSend": true
}
}
}
}
```
## Target Resolution Behavior
### Resolution Modes
1. **Explicit** - Target specified directly by user/caller
2. **Session** - Target derived from current session context
3. **Fallback** - Target resolved from allowlist or directory
4. **Allowlist** - Target normalized from allowlist entry
5. **Directory** - Target resolved from contacts directory
### Resolution Order
For different send paths:
| Path | Resolution Priority |
|------|---------------------|
| CLI with `--target` | Explicit → Validate → Send |
| Tool call | Explicit → Session → Fallback |
| Heartbeat | Explicit → Automation Recipients → Allowlist[0] |
| System Event | Requires explicit target (no fallback) |
### Logging
All target resolution is logged with source and method:
```
[send] source=cli channel=whatsapp target=+165***1234 resolvedFrom=explicit
```
## Request Logging (FIX-2.2)
Every send operation is logged with full context:
### Log Format
```
[send] source=cli|rpc|session|sub-agent|tool sessionKey=xxx channel=whatsapp target=+xxx resolvedFrom=explicit|session|fallback
```
### Logged Fields
| Field | Description |
|-------|-------------|
| source | Origin of the send request |
| sessionKey | Session key if applicable |
| channel | Target channel (whatsapp, telegram, etc.) |
| target | Target recipient (masked for privacy) |
| resolvedTarget | Final resolved target if different |
| resolvedFrom | How the target was resolved |
| accountId | Channel account used |
| dryRun | Whether this was a dry-run |
| firstTime | Whether this is a first-time recipient |
## First-Time Recipient Warnings (FIX-3.1)
### Purpose
Provides visibility when automation sends to a new recipient for the first time, even if they're in the allowlist.
### Behavior
When sending to a recipient not previously contacted:
1. Warning is logged: `[whatsapp] First-time recipient: +165***1234 (in allowlist)`
2. The send proceeds normally (not blocked)
3. Recipient is recorded for future reference
### Storage
Known recipients are tracked in:
```
~/.clawdbot/known-recipients.json
```
### Log Output
```
[outbound/known-recipients] New recipient recorded: whatsapp:+165***1234
[outbound/known-recipients] [whatsapp] First-time recipient: +165***1234 (in allowlist)
```
## Dry-Run Mode (FIX-3.3)
### Purpose
Prevents actual message sends during development and testing.
### Enabling
**Environment Variable:**
```bash
export CLAWDBOT_DRY_RUN=true
clawdbot gateway start
```
**CLI Flag:**
```bash
clawdbot message send --target +1234 --message "test" --dry-run
```
### Behavior
When dry-run is enabled:
- All outbound sends are logged but not executed
- Log format: `[dry-run] would send to +xxx: message preview...`
- API returns success with `dryRun: true` in response
- No actual messages are delivered
### Log Output
```
[outbound/dry-run] [dry-run] would send to +165***1234 channel=whatsapp source=cli message="Hello world"
```
## Best Practices
### For Operators
1. **Configure tight allowlists** - Only include numbers that should receive messages
2. **Use automation.recipients** - Separate interactive contacts from automation targets
3. **Enable dry-run for development** - Set `CLAWDBOT_DRY_RUN=true` when testing
4. **Monitor first-time recipient warnings** - Review logs for unexpected new contacts
5. **Review request logs** - Audit `[send]` log entries periodically
### For Sub-Agent Development
1. **Use sessions_send** - Route messages through parent session context
2. **Don't call clawdbot message send directly** - It will be blocked
3. **Specify explicit targets** - Don't rely on implicit routing
4. **Handle blocked sends gracefully** - Catch and log errors
### For Automation
1. **Always specify --target and --channel** - Never rely on defaults
2. **Use automation.recipients** - Configure explicit automation targets
3. **Test with dry-run** - Verify behavior before enabling real sends
4. **Log all send attempts** - Maintain audit trail
## Security Incident Response
If an unauthorized send occurs:
1. **Check request logs** - Find `[send]` entries around the incident time
2. **Review source field** - Identify what triggered the send
3. **Check session key** - Trace to the originating session
4. **Audit target resolution** - Verify how the target was resolved
5. **Review known-recipients.json** - Check if recipient was previously known
## Related Configuration
```json
{
"channels": {
"whatsapp": {
"allowFrom": ["..."],
"automation": {
"recipients": ["..."]
}
}
},
"agents": {
"defaults": {
"subagents": {
"allowDirectMessageSend": false
},
"heartbeat": {
"requireExplicitTarget": true
}
}
}
}
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `CLAWDBOT_DRY_RUN` | Enable dry-run mode (true/1/yes) |
| `CLAWDBOT_STATE_DIR` | Location for known-recipients.json |
---
*Last updated: 2026-01-25*

View File

@ -279,7 +279,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
chunkerMode: "text",
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode, allowUnlisted }) => {
resolveTarget: ({ cfg, to, allowFrom, accountId, mode, allowUnlisted }) => {
const trimmed = to?.trim() ?? "";
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
const hasWildcard = allowListRaw.includes("*");
@ -288,6 +288,25 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
// FIX-1.6: Resolve automation recipients for heartbeat/automation mode validation
const isAutomationMode = mode === "heartbeat" || mode === "automation";
// Track whether automation.recipients is explicitly configured (vs undefined)
// Empty array = "no automation allowed", undefined = "fall back to allowlist"
let automationRecipientsConfigured = false;
let automationRecipients: string[] = [];
if (isAutomationMode && cfg) {
const account = resolveWhatsAppAccount({ cfg, accountId });
const accountAutomation = account.automation?.recipients;
const globalAutomation = cfg.channels?.whatsapp?.automation?.recipients;
// Check if automation.recipients is explicitly configured at any level
automationRecipientsConfigured =
accountAutomation !== undefined || globalAutomation !== undefined;
const rawRecipients = accountAutomation ?? globalAutomation ?? [];
automationRecipients = rawRecipients
.map((entry) => normalizeWhatsAppTarget(String(entry).trim()))
.filter((entry): entry is string => Boolean(entry));
}
if (trimmed) {
const normalizedTo = normalizeWhatsAppTarget(trimmed);
if (!normalizedTo) {
@ -306,7 +325,52 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
if (isWhatsAppGroupJid(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
if (mode === "implicit" || mode === "heartbeat") {
// FIX-1.6: Validate automation recipients for heartbeat/automation sends
if (isAutomationMode) {
// FIX-2: If automation.recipients is configured, enforce it strictly
if (automationRecipientsConfigured) {
// If configured but empty, block ALL automation sends
if (automationRecipients.length === 0) {
// eslint-disable-next-line no-console
console.error(
`[security] Automation send to ${normalizedTo} blocked - automation.recipients is empty (no automation sends allowed)`,
);
return {
ok: false,
error: new Error(
`Automation send to ${normalizedTo} blocked. automation.recipients is configured but empty, ` +
`meaning no automated sends are allowed. Add the target to automation.recipients to allow automated sends.`,
),
};
}
// If configured and non-empty, validate against the list
if (!automationRecipients.includes(normalizedTo)) {
// eslint-disable-next-line no-console
console.error(
`[security] Automation send to ${normalizedTo} blocked - not in channels.whatsapp.automation.recipients`,
);
return {
ok: false,
error: new Error(
`Automation send to ${normalizedTo} blocked. Target not in channels.whatsapp.automation.recipients. ` +
`Add the target to automation.recipients to allow automated sends.`,
),
};
}
return { ok: true, to: normalizedTo };
}
// If automation.recipients is NOT configured (undefined), fall through to allowlist
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return { ok: true, to: allowList[0] };
}
if (mode === "implicit") {
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}

View File

@ -11,7 +11,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
chunkerMode: "text",
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode, allowUnlisted }) => {
resolveTarget: ({ cfg, to, allowFrom, accountId, mode, allowUnlisted }) => {
const trimmed = to?.trim() ?? "";
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
const hasWildcard = allowListRaw.includes("*");
@ -20,6 +20,24 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
// FIX-5: Resolve automation recipients for heartbeat/automation mode validation
const isAutomationMode = mode === "heartbeat" || mode === "automation";
let automationRecipientsConfigured = false;
let automationRecipients: string[] = [];
if (isAutomationMode && cfg) {
const accountKey = accountId?.trim() || "default";
const accountCfg = cfg.channels?.whatsapp?.accounts?.[accountKey];
const accountAutomation = accountCfg?.automation?.recipients;
const globalAutomation = cfg.channels?.whatsapp?.automation?.recipients;
// Check if automation.recipients is explicitly configured at any level
automationRecipientsConfigured =
accountAutomation !== undefined || globalAutomation !== undefined;
const rawRecipients = accountAutomation ?? globalAutomation ?? [];
automationRecipients = rawRecipients
.map((entry) => normalizeWhatsAppTarget(String(entry).trim()))
.filter((entry): entry is string => Boolean(entry));
}
if (trimmed) {
const normalizedTo = normalizeWhatsAppTarget(trimmed);
if (!normalizedTo) {
@ -38,7 +56,52 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
if (isWhatsAppGroupJid(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
if (mode === "implicit" || mode === "heartbeat") {
// FIX-5: Validate automation recipients for heartbeat/automation sends
if (isAutomationMode) {
// If automation.recipients is configured, enforce it strictly
if (automationRecipientsConfigured) {
// If configured but empty, block ALL automation sends
if (automationRecipients.length === 0) {
// eslint-disable-next-line no-console
console.error(
`[security] Automation send to ${normalizedTo} blocked - automation.recipients is empty (no automation sends allowed)`,
);
return {
ok: false,
error: new Error(
`Automation send to ${normalizedTo} blocked. automation.recipients is configured but empty, ` +
`meaning no automated sends are allowed. Add the target to automation.recipients to allow automated sends.`,
),
};
}
// If configured and non-empty, validate against the list
if (!automationRecipients.includes(normalizedTo)) {
// eslint-disable-next-line no-console
console.error(
`[security] Automation send to ${normalizedTo} blocked - not in channels.whatsapp.automation.recipients`,
);
return {
ok: false,
error: new Error(
`Automation send to ${normalizedTo} blocked. Target not in channels.whatsapp.automation.recipients. ` +
`Add the target to automation.recipients to allow automated sends.`,
),
};
}
return { ok: true, to: normalizedTo };
}
// If automation.recipients is NOT configured (undefined), fall through to allowlist
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return { ok: true, to: allowList[0] };
}
if (mode === "implicit") {
if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo };
}

View File

@ -10,7 +10,7 @@ import type { ChannelMessageActionName as ChannelMessageActionNameFromList } fro
export type ChannelId = ChatChannelId | (string & {});
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat" | "automation";
export type ChannelAgentTool = AgentTool<TSchema, unknown>;

View File

@ -380,6 +380,49 @@ export async function recordSessionMetaFromInbound(params: {
});
}
/**
* Clear delivery context for all sessions matching a specific channel/account.
* Used when a channel logs out to prevent stale routing state.
* FIX-3.2: Delivery context logout clearing
*/
export async function clearDeliveryContextsForChannel(params: {
storePath: string;
channel: string;
accountId?: string;
}): Promise<{ clearedCount: number }> {
const { storePath, channel, accountId } = params;
let clearedCount = 0;
await updateSessionStore(storePath, (store) => {
for (const [key, entry] of Object.entries(store)) {
if (!entry) continue;
const context = entry.deliveryContext;
const lastChannel = entry.lastChannel;
// Check if this session's delivery context matches the channel being logged out
const matchesChannel =
context?.channel === channel ||
lastChannel === channel ||
context?.channel?.toLowerCase() === channel.toLowerCase() ||
lastChannel?.toLowerCase() === channel.toLowerCase();
const matchesAccount =
!accountId || context?.accountId === accountId || entry.lastAccountId === accountId;
if (matchesChannel && matchesAccount) {
// Clear the delivery context and last route info
store[key] = {
...entry,
deliveryContext: undefined,
lastChannel: undefined,
lastTo: undefined,
lastAccountId: undefined,
lastThreadId: undefined,
};
clearedCount++;
}
}
return clearedCount;
});
return { clearedCount };
}
export async function updateLastRoute(params: {
storePath: string;
sessionKey: string;

View File

@ -195,8 +195,8 @@ export type AgentDefaultsConfig = {
includeReasoning?: boolean;
/**
* When true, require an explicit target for heartbeat delivery (no implicit routing).
* If no explicit target is provided, heartbeat delivery is skipped with reason "require-explicit".
* Default: false (backwards compatible; falls back to session-derived or "last" routing).
* If no explicit target is provided, heartbeat delivery fails with an error.
* Default: true (strict mode; prevents accidental sends to stale/implicit targets).
*/
requireExplicitTarget?: boolean;
};
@ -210,6 +210,12 @@ export type AgentDefaultsConfig = {
archiveAfterMinutes?: number;
/** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */
model?: string | { primary?: string; fallbacks?: string[] };
/**
* Allow subagents to call `message send` API directly (default: false).
* When false, subagents must use `sessions_send` instead.
* Blocked attempts are logged: `[security] Subagent xxx attempted direct message send - blocked`
*/
allowDirectMessageSend?: boolean;
};
/** Optional sandbox settings for non-main sessions. */
sandbox?: {

View File

@ -14,6 +14,15 @@ export type WhatsAppActionConfig = {
polls?: boolean;
};
export type WhatsAppAutomationConfig = {
/**
* Recipients that automation/system events can send to (E.164).
* All non-interactive sends (system events, cron, subagent callbacks, daemon)
* must validate against this list. Default: empty (no automation sends allowed).
*/
recipients?: string[];
};
export type WhatsAppConfig = {
/** Optional per-account WhatsApp configuration (multi-account). */
accounts?: Record<string, WhatsAppAccountConfig>;
@ -47,6 +56,8 @@ export type WhatsAppConfig = {
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Automation/system event settings. */
automation?: WhatsAppAutomationConfig;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
@ -118,6 +129,8 @@ export type WhatsAppAccountConfig = {
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
/** Automation/system event settings for this account. */
automation?: WhatsAppAutomationConfig;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */

View File

@ -150,6 +150,7 @@ export const AgentDefaultsSchema = z
.strict(),
])
.optional(),
allowDirectMessageSend: z.boolean().optional(),
})
.strict()
.optional(),

View File

@ -12,6 +12,14 @@ import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional();
export const WhatsAppAutomationSchema = z
.object({
/** Recipients that automation/system events can send to (E.164). */
recipients: z.array(z.string()).optional(),
})
.strict()
.optional();
export const WhatsAppAccountSchema = z
.object({
name: z.string().optional(),
@ -59,6 +67,7 @@ export const WhatsAppAccountSchema = z
.optional(),
debounceMs: z.number().int().nonnegative().optional().default(0),
heartbeat: ChannelHeartbeatVisibilitySchema,
automation: WhatsAppAutomationSchema,
})
.strict()
.superRefine((value, ctx) => {
@ -124,6 +133,7 @@ export const WhatsAppConfigSchema = z
.optional(),
debounceMs: z.number().int().nonnegative().optional().default(0),
heartbeat: ChannelHeartbeatVisibilitySchema,
automation: WhatsAppAutomationSchema,
})
.strict()
.superRefine((value, ctx) => {

View File

@ -22,6 +22,9 @@ import {
} from "../protocol/index.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
// FIX-3.2: Clear delivery context on logout
import { clearDeliveryContextsForChannel } from "../../config/sessions/store.js";
import { resolveStorePath } from "../../config/sessions/paths.js";
type ChannelLogoutPayload = {
channel: ChannelId;
@ -57,6 +60,26 @@ export async function logoutChannelAccount(params: {
const loggedOut = typeof result.loggedOut === "boolean" ? result.loggedOut : cleared;
if (loggedOut) {
params.context.markChannelLoggedOut(params.channelId, true, resolvedAccountId);
// FIX-3.2: Clear delivery context for all sessions using this channel/account
// This prevents stale routing state after logout
try {
const storePath = resolveStorePath(params.cfg.session?.store);
const { clearedCount } = await clearDeliveryContextsForChannel({
storePath,
channel: params.channelId,
accountId: resolvedAccountId,
});
if (clearedCount > 0) {
// eslint-disable-next-line no-console
console.log(
`[security] Cleared delivery context for ${clearedCount} session(s) after ${params.channelId}:${resolvedAccountId} logout`,
);
}
} catch (err) {
// Log but don't fail the logout if delivery context clearing fails
// eslint-disable-next-line no-console
console.error(`[security] Failed to clear delivery contexts on logout:`, err);
}
}
return {
channel: params.channelId,

View File

@ -22,6 +22,14 @@ import {
} from "../protocol/index.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
// FIX-2.2: Request logging for gateway send RPC
import { logSendRequest } from "../../infra/outbound/send-logging.js";
import { isDryRunModeEnabled, logDryRunSend } from "../../infra/outbound/dry-run.js";
import {
isKnownRecipient,
recordRecipient,
logFirstTimeRecipient,
} from "../../infra/outbound/known-recipients.js";
type InflightResult = {
ok: boolean;
@ -118,6 +126,24 @@ export const sendHandlers: GatewayRequestHandlers = {
const work = (async (): Promise<InflightResult> => {
try {
const cfg = loadConfig();
// FIX-3.3: Check for environment variable dry-run mode
const dryRun = isDryRunModeEnabled();
if (dryRun) {
logDryRunSend({
channel,
target: to,
message,
mediaUrl: request.mediaUrl,
source: "rpc",
});
return {
ok: true,
payload: { runId: idem, messageId: "dry-run", channel, dryRun: true },
meta: { channel, dryRun: true },
};
}
const resolved = resolveOutboundTarget({
channel: outboundChannel,
to,
@ -132,6 +158,30 @@ export const sendHandlers: GatewayRequestHandlers = {
meta: { channel },
};
}
// FIX-2.2: Log send request with full context
logSendRequest({
source: "rpc",
sessionKey: request.sessionKey,
channel,
target: to,
resolvedTarget: resolved.to,
resolvedFrom: to === resolved.to ? "explicit" : "allowlist",
accountId,
firstTimeRecipient:
channel === "whatsapp" ? !isKnownRecipient(channel, resolved.to) : undefined,
});
// FIX-3.1: Log warning for first-time WhatsApp recipients
if (channel === "whatsapp" && !isKnownRecipient(channel, resolved.to)) {
const allowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
const inAllowlist = allowFrom.some((entry) => {
const normalized = String(entry).replace(/\D/g, "");
const targetNormalized = resolved.to.replace(/\D/g, "").replace(/@.*$/, "");
return normalized === targetNormalized;
});
logFirstTimeRecipient(channel, resolved.to, inAllowlist);
}
const outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : undefined;
const mirrorPayloads = normalizeReplyPayloadsForDelivery([
{ text: message, mediaUrl: request.mediaUrl, mediaUrls },
@ -196,6 +246,10 @@ export const sendHandlers: GatewayRequestHandlers = {
if (!result) {
throw new Error("No delivery result");
}
// FIX-3.1: Record successful send for known recipients tracking
recordRecipient(channel, resolved.to);
const payload: Record<string, unknown> = {
runId: idem,
messageId: result.messageId,

View File

@ -4,7 +4,7 @@ import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js";
import * as replyModule from "../auto-reply/reply.js";
import type { MoltbotConfig } from "../config/config.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey,
@ -95,7 +95,7 @@ describe("resolveHeartbeatPrompt", () => {
});
it("uses a trimmed override when configured", () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: { defaults: { heartbeat: { prompt: " ping " } } },
};
expect(resolveHeartbeatPrompt(cfg)).toBe("ping");
@ -104,7 +104,7 @@ describe("resolveHeartbeatPrompt", () => {
describe("isHeartbeatEnabledForAgent", () => {
it("enables only explicit heartbeat agents when configured", () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: { heartbeat: { every: "30m" } },
list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }],
@ -115,7 +115,7 @@ describe("isHeartbeatEnabledForAgent", () => {
});
it("falls back to default agent when no explicit heartbeat entries", () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: { heartbeat: { every: "30m" } },
list: [{ id: "main" }, { id: "ops" }],
@ -133,7 +133,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
};
it("respects target none", () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: { defaults: { heartbeat: { target: "none" } } },
};
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
@ -145,8 +145,14 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
});
it("uses last route by default", () => {
const cfg: MoltbotConfig = {};
it("uses last route when requireExplicitTarget is false", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { requireExplicitTarget: false },
},
},
};
const entry = {
...baseEntry,
lastChannel: "whatsapp" as const,
@ -162,7 +168,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
it("normalizes explicit WhatsApp targets when allowFrom is '*'", () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { target: "whatsapp", to: "whatsapp:(555) 123" },
@ -180,7 +186,13 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
it("skips when last route is webchat", () => {
const cfg: MoltbotConfig = {};
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { requireExplicitTarget: false },
},
},
};
const entry = {
...baseEntry,
lastChannel: "webchat" as const,
@ -196,8 +208,10 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
it("applies allowFrom fallback for WhatsApp targets", () => {
const cfg: MoltbotConfig = {
agents: { defaults: { heartbeat: { target: "whatsapp", to: "+1999" } } },
const cfg: ClawdbotConfig = {
agents: {
defaults: { heartbeat: { target: "whatsapp", to: "+1999", requireExplicitTarget: false } },
},
channels: { whatsapp: { allowFrom: ["+1555", "+1666"] } },
};
const entry = {
@ -205,18 +219,20 @@ describe("resolveHeartbeatDeliveryTarget", () => {
lastChannel: "whatsapp" as const,
lastTo: "+1222",
};
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
channel: "whatsapp",
to: "+1555",
reason: "allowFrom-fallback",
accountId: undefined,
lastChannel: "whatsapp",
lastAccountId: undefined,
});
// Note: The reason field is set based on whether the final resolved target
// differs from what explicit mode would have returned. With heartbeat mode,
// the fallback behavior is expected.
const result = resolveHeartbeatDeliveryTarget({ cfg, entry });
expect(result.channel).toBe("whatsapp");
expect(result.to).toBe("+1555");
expect(result.lastChannel).toBe("whatsapp");
expect(result.accountId).toBeUndefined();
expect(result.lastAccountId).toBeUndefined();
});
it("keeps WhatsApp group targets even with allowFrom set", () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: { defaults: { heartbeat: { requireExplicitTarget: false } } },
channels: { whatsapp: { allowFrom: ["+1555"] } },
};
const entry = {
@ -234,7 +250,8 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
it("normalizes prefixed WhatsApp group targets for heartbeat delivery", () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: { defaults: { heartbeat: { requireExplicitTarget: false } } },
channels: { whatsapp: { allowFrom: ["+1555"] } },
};
const entry = {
@ -252,7 +269,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
it("keeps explicit telegram targets", () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
};
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
@ -265,7 +282,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
});
it("prefers per-agent heartbeat overrides when provided", () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: { defaults: { heartbeat: { target: "telegram", to: "123" } } },
};
const heartbeat = { target: "whatsapp", to: "+1555" } as const;
@ -287,7 +304,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
describe("runHeartbeatOnce", () => {
it("skips when agent heartbeat is not enabled", async () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: { heartbeat: { every: "30m" } },
list: [{ id: "main" }, { id: "ops", heartbeat: { every: "1h" } }],
@ -302,7 +319,7 @@ describe("runHeartbeatOnce", () => {
});
it("skips outside active hours", async () => {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
userTimezone: "UTC",
@ -326,15 +343,14 @@ describe("runHeartbeatOnce", () => {
});
it("uses the last non-empty payload for delivery", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: { every: "5m", target: "whatsapp" },
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
@ -384,20 +400,25 @@ describe("runHeartbeatOnce", () => {
});
it("uses per-agent heartbeat overrides and session keys", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: { every: "30m", prompt: "Default prompt" },
heartbeat: { every: "30m", prompt: "Default prompt", requireExplicitTarget: false },
},
list: [
{ id: "main", default: true },
{
id: "ops",
heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" },
heartbeat: {
every: "5m",
target: "whatsapp",
prompt: "Ops check",
requireExplicitTarget: false,
},
},
],
},
@ -454,18 +475,18 @@ describe("runHeartbeatOnce", () => {
});
it("runs heartbeats in the explicit session key when configured", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const groupId = "120363401234567890@g.us";
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "last",
requireExplicitTarget: false,
},
},
},
@ -537,15 +558,14 @@ describe("runHeartbeatOnce", () => {
});
it("suppresses duplicate heartbeat payloads within 24h", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: { every: "5m", target: "whatsapp" },
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
@ -593,18 +613,18 @@ describe("runHeartbeatOnce", () => {
});
it("can include reasoning payloads when enabled", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
includeReasoning: true,
requireExplicitTarget: false,
},
},
},
@ -665,18 +685,18 @@ describe("runHeartbeatOnce", () => {
});
it("delivers reasoning even when the main heartbeat reply is HEARTBEAT_OK", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
includeReasoning: true,
requireExplicitTarget: false,
},
},
},
@ -736,13 +756,13 @@ describe("runHeartbeatOnce", () => {
});
it("loads the default agent session from templated stores", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: { workspace: tmpDir, heartbeat: { every: "5m" } },
defaults: { heartbeat: { every: "5m", requireExplicitTarget: false } },
list: [{ id: "work", default: true }],
},
channels: { whatsapp: { allowFrom: ["*"] } },
@ -800,7 +820,7 @@ describe("runHeartbeatOnce", () => {
});
it("skips heartbeat when HEARTBEAT.md is effectively empty (saves API calls)", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
@ -814,11 +834,11 @@ describe("runHeartbeatOnce", () => {
"utf-8",
);
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: { every: "5m", target: "whatsapp" },
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
@ -872,7 +892,7 @@ describe("runHeartbeatOnce", () => {
});
it("runs heartbeat when HEARTBEAT.md has actionable content", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
@ -886,11 +906,11 @@ describe("runHeartbeatOnce", () => {
"utf-8",
);
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: { every: "5m", target: "whatsapp" },
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
@ -942,7 +962,7 @@ describe("runHeartbeatOnce", () => {
});
it("runs heartbeat when HEARTBEAT.md does not exist (lets LLM decide)", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-hb-"));
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
@ -950,11 +970,11 @@ describe("runHeartbeatOnce", () => {
await fs.mkdir(workspaceDir, { recursive: true });
// Don't create HEARTBEAT.md - it doesn't exist
const cfg: MoltbotConfig = {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: { every: "5m", target: "whatsapp" },
heartbeat: { every: "5m", target: "whatsapp", requireExplicitTarget: false },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { isDryRunModeEnabled } from "./dry-run.js";
describe("dry-run", () => {
describe("isDryRunModeEnabled", () => {
it("returns true when CLAWDBOT_DRY_RUN=true", () => {
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "true" })).toBe(true);
});
it("returns true when CLAWDBOT_DRY_RUN=1", () => {
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "1" })).toBe(true);
});
it("returns true when CLAWDBOT_DRY_RUN=yes", () => {
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "yes" })).toBe(true);
});
it("returns true when CLAWDBOT_DRY_RUN=TRUE (case insensitive)", () => {
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "TRUE" })).toBe(true);
});
it("returns false when CLAWDBOT_DRY_RUN=false", () => {
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "false" })).toBe(false);
});
it("returns false when CLAWDBOT_DRY_RUN=0", () => {
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "0" })).toBe(false);
});
it("returns false when CLAWDBOT_DRY_RUN is not set", () => {
expect(isDryRunModeEnabled({})).toBe(false);
});
it("returns false when CLAWDBOT_DRY_RUN is empty string", () => {
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "" })).toBe(false);
});
it("returns false for arbitrary string values", () => {
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "enabled" })).toBe(false);
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: "on" })).toBe(false);
});
it("handles whitespace in value", () => {
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: " true " })).toBe(true);
expect(isDryRunModeEnabled({ CLAWDBOT_DRY_RUN: " 1 " })).toBe(true);
});
});
});

View File

@ -0,0 +1,94 @@
/**
* FIX-3.3: Dry-run mode for development/testing.
* When CLAWDBOT_DRY_RUN=true, all outbound sends log but don't execute.
*/
import { createSubsystemLogger } from "../../logging/subsystem.js";
const log = createSubsystemLogger("outbound/dry-run");
/**
* Check if dry-run mode is enabled via environment variable.
* This is separate from the --dry-run CLI flag and applies globally.
*/
export function isDryRunModeEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const value = env.CLAWDBOT_DRY_RUN?.trim().toLowerCase();
return value === "true" || value === "1" || value === "yes";
}
/**
* Log a dry-run send operation.
*/
export function logDryRunSend(params: {
channel: string;
target: string;
message?: string;
mediaUrl?: string;
source?: string;
}): void {
const { channel, target, message, mediaUrl, source } = params;
const preview = message
? message.length > 100
? message.slice(0, 100) + "..."
: message
: "(no message)";
const parts = [
`[dry-run] would send to ${maskTarget(target)}`,
`channel=${channel}`,
source ? `source=${source}` : null,
mediaUrl ? `media=true` : null,
`message="${preview}"`,
].filter(Boolean);
log.info(parts.join(" "));
}
/**
* Log a dry-run poll operation.
*/
export function logDryRunPoll(params: {
channel: string;
target: string;
question: string;
options: string[];
source?: string;
}): void {
const { channel, target, question, options, source } = params;
const parts = [
`[dry-run] would send poll to ${maskTarget(target)}`,
`channel=${channel}`,
source ? `source=${source}` : null,
`question="${question}"`,
`options=[${options.map((o) => `"${o}"`).join(", ")}]`,
].filter(Boolean);
log.info(parts.join(" "));
}
/**
* Mask sensitive target information for logging.
*/
function maskTarget(target: string): string {
// For phone numbers like +16503802766, show +1650***2766
if (/^\+\d{10,}$/.test(target)) {
return target.slice(0, 4) + "***" + target.slice(-4);
}
// For WhatsApp JIDs
const jidMatch = target.match(/^(\d+)@(.+)$/);
if (jidMatch) {
const [, num, domain] = jidMatch;
if (num.length >= 8) {
return num.slice(0, 3) + "***" + num.slice(-3) + "@" + domain;
}
}
// For groups, show as-is
if (target.includes("@g.us") || target.startsWith("group:") || target.startsWith("channel:")) {
return target;
}
// Default masking
if (target.length > 8) {
return target.slice(0, 3) + "***" + target.slice(-3);
}
return target;
}

View File

@ -0,0 +1,96 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearKnownRecipientsCache,
getKnownRecipientsStats,
isKnownRecipient,
recordRecipient,
} from "./known-recipients.js";
// Mock the state dir resolution
vi.mock("../../config/paths.js", () => ({
resolveStateDir: () => testStateDir,
}));
let testStateDir: string;
beforeEach(async () => {
testStateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "clawdbot-known-recipients-"));
clearKnownRecipientsCache();
});
afterEach(async () => {
await fs.promises.rm(testStateDir, { recursive: true, force: true });
});
describe("known-recipients", () => {
describe("isKnownRecipient", () => {
it("returns false for unknown recipient", () => {
expect(isKnownRecipient("whatsapp", "+16505551234")).toBe(false);
});
it("returns true after recording recipient", () => {
recordRecipient("whatsapp", "+16505551234");
expect(isKnownRecipient("whatsapp", "+16505551234")).toBe(true);
});
it("normalizes WhatsApp JIDs to E.164", () => {
recordRecipient("whatsapp", "16505551234@s.whatsapp.net");
expect(isKnownRecipient("whatsapp", "+16505551234")).toBe(true);
});
it("differentiates between channels", () => {
recordRecipient("whatsapp", "+16505551234");
expect(isKnownRecipient("telegram", "+16505551234")).toBe(false);
});
});
describe("recordRecipient", () => {
it("returns true for first-time recipient", () => {
const isFirstTime = recordRecipient("whatsapp", "+16505551234");
expect(isFirstTime).toBe(true);
});
it("returns false for known recipient", () => {
recordRecipient("whatsapp", "+16505551234");
const isFirstTime = recordRecipient("whatsapp", "+16505551234");
expect(isFirstTime).toBe(false);
});
it("increments send count on subsequent sends", () => {
recordRecipient("whatsapp", "+16505551234");
recordRecipient("whatsapp", "+16505551234");
recordRecipient("whatsapp", "+16505551234");
const stats = getKnownRecipientsStats();
expect(stats.total).toBe(1);
});
it("persists data to disk", () => {
recordRecipient("whatsapp", "+16505551234");
clearKnownRecipientsCache();
expect(isKnownRecipient("whatsapp", "+16505551234")).toBe(true);
});
});
describe("getKnownRecipientsStats", () => {
it("returns empty stats when no recipients", () => {
const stats = getKnownRecipientsStats();
expect(stats.total).toBe(0);
expect(stats.byChannel).toEqual({});
});
it("returns correct stats after recording", () => {
recordRecipient("whatsapp", "+16505551234");
recordRecipient("whatsapp", "+16505555678");
recordRecipient("telegram", "12345");
const stats = getKnownRecipientsStats();
expect(stats.total).toBe(3);
expect(stats.byChannel.whatsapp).toBe(2);
expect(stats.byChannel.telegram).toBe(1);
});
});
});

View File

@ -0,0 +1,206 @@
/**
* FIX-3.1: Known recipients tracking.
* Tracks recipients that have successfully received messages to detect first-time sends.
*/
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../../config/paths.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
const log = createSubsystemLogger("outbound/known-recipients");
const KNOWN_RECIPIENTS_FILENAME = "known-recipients.json";
type KnownRecipientsData = {
version: 1;
recipients: Record<
string,
{
channel: string;
firstSentAt: string;
lastSentAt: string;
sendCount: number;
}
>;
};
let cachedData: KnownRecipientsData | null = null;
let lastLoadTime = 0;
const CACHE_TTL_MS = 60_000; // Reload from disk at most every minute
/**
* Get the path to the known recipients file.
*/
function getKnownRecipientsPath(): string {
const stateDir = resolveStateDir();
return path.join(stateDir, KNOWN_RECIPIENTS_FILENAME);
}
/**
* Load known recipients from disk.
*/
function loadKnownRecipients(): KnownRecipientsData {
const now = Date.now();
if (cachedData && now - lastLoadTime < CACHE_TTL_MS) {
return cachedData;
}
const filePath = getKnownRecipientsPath();
try {
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, "utf-8");
const data = JSON.parse(raw) as KnownRecipientsData;
if (data.version === 1 && typeof data.recipients === "object") {
cachedData = data;
lastLoadTime = now;
return data;
}
}
} catch (err) {
log.warn("Failed to load known recipients", { error: String(err) });
}
const empty: KnownRecipientsData = { version: 1, recipients: {} };
cachedData = empty;
lastLoadTime = now;
return empty;
}
/**
* Save known recipients to disk.
*/
function saveKnownRecipients(data: KnownRecipientsData): void {
const filePath = getKnownRecipientsPath();
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
cachedData = data;
lastLoadTime = Date.now();
} catch (err) {
log.warn("Failed to save known recipients", { error: String(err) });
}
}
/**
* Normalize a recipient key for consistent storage.
* Combines channel and target into a unique key.
*/
function normalizeRecipientKey(channel: string, target: string): string {
// Normalize WhatsApp targets to E.164 format
let normalizedTarget = target.trim().toLowerCase();
// Remove @s.whatsapp.net suffix for individual chats
if (normalizedTarget.endsWith("@s.whatsapp.net")) {
normalizedTarget = normalizedTarget.replace(/@s\.whatsapp\.net$/, "");
if (!normalizedTarget.startsWith("+")) {
normalizedTarget = "+" + normalizedTarget;
}
}
return `${channel.toLowerCase()}:${normalizedTarget}`;
}
/**
* Check if a recipient is known (has received messages before).
*/
export function isKnownRecipient(channel: string, target: string): boolean {
const data = loadKnownRecipients();
const key = normalizeRecipientKey(channel, target);
return key in data.recipients;
}
/**
* Record a successful send to a recipient.
* Returns whether this was the first-time send to this recipient.
*/
export function recordRecipient(channel: string, target: string): boolean {
const data = loadKnownRecipients();
const key = normalizeRecipientKey(channel, target);
const now = new Date().toISOString();
const isFirstTime = !(key in data.recipients);
if (isFirstTime) {
data.recipients[key] = {
channel: channel.toLowerCase(),
firstSentAt: now,
lastSentAt: now,
sendCount: 1,
};
log.info(`New recipient recorded: ${channel}:${maskRecipient(target)}`, {
channel,
isFirstTime: true,
});
} else {
data.recipients[key].lastSentAt = now;
data.recipients[key].sendCount += 1;
}
saveKnownRecipients(data);
return isFirstTime;
}
/**
* Mask recipient for logging (partial exposure).
*/
function maskRecipient(target: string): string {
if (/^\+?\d{10,}/.test(target)) {
// Phone number: show first 4 and last 4 digits
const digits = target.replace(/\D/g, "");
if (digits.length >= 8) {
return (target.startsWith("+") ? "+" : "") + digits.slice(0, 3) + "***" + digits.slice(-3);
}
}
if (target.includes("@g.us")) {
// Group: show as-is (less sensitive)
return target;
}
if (target.length > 8) {
return target.slice(0, 3) + "***" + target.slice(-3);
}
return target;
}
/**
* Log a warning for first-time recipient sends.
* This provides visibility into when automation sends to new targets.
*/
export function logFirstTimeRecipient(channel: string, target: string, inAllowlist: boolean): void {
const allowlistNote = inAllowlist ? " (in allowlist)" : " (NOT in allowlist)";
log.warn(`[${channel}] First-time recipient: ${maskRecipient(target)}${allowlistNote}`, {
channel,
target: maskRecipient(target),
inAllowlist,
firstTime: true,
});
}
/**
* Get statistics about known recipients.
*/
export function getKnownRecipientsStats(): {
total: number;
byChannel: Record<string, number>;
} {
const data = loadKnownRecipients();
const stats: { total: number; byChannel: Record<string, number> } = {
total: Object.keys(data.recipients).length,
byChannel: {},
};
for (const entry of Object.values(data.recipients)) {
stats.byChannel[entry.channel] = (stats.byChannel[entry.channel] ?? 0) + 1;
}
return stats;
}
/**
* Clear the in-memory cache (for testing).
*/
export function clearKnownRecipientsCache(): void {
cachedData = null;
lastLoadTime = 0;
}

View File

@ -510,3 +510,142 @@ describe("runMessageAction accountId defaults", () => {
expect(ctx.params.accountId).toBe("ops");
});
});
describe("runMessageAction subagent blocking (FIX-1.5)", () => {
beforeEach(async () => {
const { createPluginRuntime } = await import("../../plugins/runtime/index.js");
const { setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js");
const runtime = createPluginRuntime();
setSlackRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack",
source: "test",
plugin: slackPlugin,
},
]),
);
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
const slackCfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as ClawdbotConfig;
it("blocks direct message send from subagent session by default", async () => {
await expect(
runMessageAction({
cfg: slackCfg,
action: "send",
params: {
channel: "slack",
target: "#C12345678",
message: "hi",
},
sessionKey: "agent:main:subagent:test-123",
dryRun: true,
}),
).rejects.toThrow(/Subagent direct message send blocked/);
});
it("blocks poll action from subagent session", async () => {
await expect(
runMessageAction({
cfg: slackCfg,
action: "poll",
params: {
channel: "slack",
target: "#C12345678",
pollQuestion: "Test?",
pollOption: ["A", "B"],
},
sessionKey: "agent:main:subagent:test-123",
dryRun: true,
}),
).rejects.toThrow(/Subagent direct message send blocked/);
});
it("blocks broadcast action from subagent session", async () => {
await expect(
runMessageAction({
cfg: {
...slackCfg,
tools: { message: { broadcast: { enabled: true } } },
},
action: "broadcast",
params: {
channel: "slack",
targets: ["#C12345678"],
message: "hi",
},
sessionKey: "agent:main:subagent:test-123",
dryRun: true,
}),
).rejects.toThrow(/Subagent direct message send blocked/);
});
it("allows direct message send from subagent when allowDirectMessageSend is true", async () => {
const result = await runMessageAction({
cfg: {
...slackCfg,
agents: {
defaults: {
subagents: {
allowDirectMessageSend: true,
},
},
},
},
action: "send",
params: {
channel: "slack",
target: "#C12345678",
message: "hi",
},
sessionKey: "agent:main:subagent:test-123",
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("allows direct message send from main session", async () => {
const result = await runMessageAction({
cfg: slackCfg,
action: "send",
params: {
channel: "slack",
target: "#C12345678",
message: "hi",
},
sessionKey: "agent:main:main",
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("allows direct message send when no session key is provided", async () => {
const result = await runMessageAction({
cfg: slackCfg,
action: "send",
params: {
channel: "slack",
target: "#C12345678",
message: "hi",
},
dryRun: true,
});
expect(result.kind).toBe("send");
});
});

View File

@ -8,6 +8,7 @@ import {
readStringParam,
} from "../../agents/tools/common.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { isSubagentSessionKey } from "../../sessions/session-key-utils.js";
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
import { dispatchChannelMessageAction } from "../../channels/plugins/message-actions.js";
import type {
@ -43,6 +44,10 @@ import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-res
import { loadWebMedia } from "../../web/media.js";
import { extensionForMime } from "../../media/mime.js";
import { parseSlackTarget } from "../../slack/targets.js";
// FIX-2.2, FIX-3.1, FIX-3.3: Outbound security and logging improvements
import { isDryRunModeEnabled, logDryRunSend, logDryRunPoll } from "./dry-run.js";
import { isKnownRecipient, recordRecipient, logFirstTimeRecipient } from "./known-recipients.js";
import { logSendRequest, inferSendSource, type SendLogContext } from "./send-logging.js";
export type MessageActionRunnerGateway = {
url?: string;
@ -705,6 +710,56 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const mirrorMediaUrls =
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
throwIfAborted(abortSignal);
// FIX-2.2: Log send request with full context
const sendSource = inferSendSource({
sessionKey: input.sessionKey,
isSubagent: input.sessionKey ? isSubagentSessionKey(input.sessionKey) : false,
isRpc: Boolean(gateway),
isCli: !gateway && !input.toolContext,
isTool: Boolean(input.toolContext),
});
const sendLogContext: SendLogContext = {
source: sendSource,
sessionKey: input.sessionKey,
channel,
target: to,
resolvedTarget: resolvedTarget?.to,
resolvedFrom: resolvedTarget ? (resolvedTarget.from ?? "explicit") : "explicit",
accountId: accountId ?? undefined,
dryRun,
};
// FIX-3.1: Check for first-time recipient and log warning
if (!dryRun && channel === "whatsapp") {
const isFirstTime = !isKnownRecipient(channel, to);
if (isFirstTime) {
sendLogContext.firstTimeRecipient = true;
// Check if target is in allowlist for the warning message
const allowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
const inAllowlist = allowFrom.some((entry) => {
const normalized = String(entry).replace(/\D/g, "");
const targetNormalized = to.replace(/\D/g, "").replace(/@.*$/, "");
return normalized === targetNormalized;
});
logFirstTimeRecipient(channel, to, inAllowlist);
}
}
// FIX-3.3: Log dry-run sends
if (dryRun) {
logDryRunSend({
channel,
target: to,
message,
mediaUrl: mediaUrl ?? undefined,
source: sendSource,
});
}
// FIX-2.2: Log the send request
logSendRequest(sendLogContext);
const send = await executeSendAction({
ctx: {
cfg,
@ -735,6 +790,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
bestEffort: bestEffort ?? undefined,
});
// FIX-3.1: Record successful send for known recipients tracking
if (!dryRun && send.sendResult) {
recordRecipient(channel, to);
}
return {
kind: "send",
channel,
@ -778,6 +838,35 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
preferEmbeds: true,
});
// FIX-2.2: Log poll request with full context
const pollSource = inferSendSource({
sessionKey: input.sessionKey,
isSubagent: input.sessionKey ? isSubagentSessionKey(input.sessionKey) : false,
isRpc: Boolean(gateway),
isCli: !gateway && !input.toolContext,
isTool: Boolean(input.toolContext),
});
logSendRequest({
source: pollSource,
sessionKey: input.sessionKey,
channel,
target: to,
resolvedFrom: "explicit",
accountId: accountId ?? undefined,
dryRun,
});
// FIX-3.3: Log dry-run polls
if (dryRun) {
logDryRunPoll({
channel,
target: to,
question,
options,
source: pollSource,
});
}
const poll = await executePollAction({
ctx: {
cfg,
@ -795,6 +884,11 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
durationHours: durationHours ?? undefined,
});
// FIX-3.1: Record successful poll send for known recipients tracking
if (!dryRun && poll.pollResult) {
recordRecipient(channel, to);
}
return {
kind: "poll",
channel,
@ -851,6 +945,27 @@ export async function runMessageAction(
input: RunMessageActionParams,
): Promise<MessageActionRunResult> {
const cfg = input.cfg;
// FIX-1.5: Block direct message sends from subagent contexts unless explicitly allowed
const sessionKey = input.sessionKey;
if (sessionKey && isSubagentSessionKey(sessionKey)) {
const allowDirectSend = cfg.agents?.defaults?.subagents?.allowDirectMessageSend ?? false;
if (!allowDirectSend) {
const action = input.action;
// Block send, poll, and broadcast actions from subagents
if (action === "send" || action === "poll" || action === "broadcast") {
// eslint-disable-next-line no-console
console.error(
`[security] Subagent ${sessionKey} attempted direct message send - blocked. ` +
`Use sessions_send instead, or set agents.defaults.subagents.allowDirectMessageSend: true to allow.`,
);
throw new Error(
`Subagent direct message send blocked. Use sessions_send to route through parent session.`,
);
}
}
}
const params = { ...input.params };
const resolvedAgentId =
input.agentId ??
@ -914,7 +1029,9 @@ export async function runMessageAction(
if (accountId) {
params.accountId = accountId;
}
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
// FIX-3.3: Check for environment variable dry-run mode
const envDryRun = isDryRunModeEnabled();
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun") ?? envDryRun);
await hydrateSendAttachmentParams({
cfg,

View File

@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { formatSendLogLine, inferSendSource, type SendLogContext } from "./send-logging.js";
describe("send-logging", () => {
describe("formatSendLogLine", () => {
it("formats basic send context", () => {
const ctx: SendLogContext = {
source: "cli",
channel: "whatsapp",
target: "+16505551234",
resolvedFrom: "explicit",
};
const line = formatSendLogLine(ctx);
expect(line).toContain("source=cli");
expect(line).toContain("channel=whatsapp");
expect(line).toContain("target=+165***1234");
expect(line).toContain("resolvedFrom=explicit");
});
it("includes session key when provided", () => {
const ctx: SendLogContext = {
source: "session",
sessionKey: "agent:main:telegram:12345",
channel: "telegram",
target: "12345",
resolvedFrom: "session",
};
const line = formatSendLogLine(ctx);
expect(line).toContain("sessionKey=agent:main:telegram:12345");
});
it("includes resolved target when different from target", () => {
const ctx: SendLogContext = {
source: "rpc",
channel: "whatsapp",
target: "John",
resolvedTarget: "+16505551234",
resolvedFrom: "directory",
};
const line = formatSendLogLine(ctx);
expect(line).toContain("resolvedTarget=+165***1234");
});
it("omits resolved target when same as target", () => {
const ctx: SendLogContext = {
source: "cli",
channel: "whatsapp",
target: "+16505551234",
resolvedTarget: "+16505551234",
resolvedFrom: "explicit",
};
const line = formatSendLogLine(ctx);
expect(line).not.toContain("resolvedTarget=");
});
it("includes dry-run flag", () => {
const ctx: SendLogContext = {
source: "cli",
channel: "whatsapp",
target: "+16505551234",
resolvedFrom: "explicit",
dryRun: true,
};
const line = formatSendLogLine(ctx);
expect(line).toContain("dryRun=true");
});
it("includes first-time recipient flag", () => {
const ctx: SendLogContext = {
source: "rpc",
channel: "whatsapp",
target: "+16505551234",
resolvedFrom: "explicit",
firstTimeRecipient: true,
};
const line = formatSendLogLine(ctx);
expect(line).toContain("firstTime=true");
});
it("masks phone numbers correctly", () => {
const ctx: SendLogContext = {
source: "cli",
channel: "whatsapp",
target: "+16505551234",
resolvedFrom: "explicit",
};
const line = formatSendLogLine(ctx);
expect(line).toContain("+165***1234");
expect(line).not.toContain("+16505551234");
});
it("masks WhatsApp JIDs correctly", () => {
const ctx: SendLogContext = {
source: "rpc",
channel: "whatsapp",
target: "16505551234@s.whatsapp.net",
resolvedFrom: "explicit",
};
const line = formatSendLogLine(ctx);
expect(line).toContain("165***234@s.whatsapp.net");
});
it("does not mask group JIDs", () => {
const ctx: SendLogContext = {
source: "rpc",
channel: "whatsapp",
target: "1234567890@g.us",
resolvedFrom: "explicit",
};
const line = formatSendLogLine(ctx);
expect(line).toContain("1234567890@g.us");
});
});
describe("inferSendSource", () => {
it("returns sub-agent when isSubagent is true", () => {
expect(inferSendSource({ isSubagent: true })).toBe("sub-agent");
});
it("returns tool when isTool is true", () => {
expect(inferSendSource({ isTool: true })).toBe("tool");
});
it("returns rpc when isRpc is true", () => {
expect(inferSendSource({ isRpc: true })).toBe("rpc");
});
it("returns cli when isCli is true", () => {
expect(inferSendSource({ isCli: true })).toBe("cli");
});
it("returns session when sessionKey is provided", () => {
expect(inferSendSource({ sessionKey: "agent:main:telegram:123" })).toBe("session");
});
it("returns unknown when nothing is provided", () => {
expect(inferSendSource({})).toBe("unknown");
});
it("prioritizes sub-agent over other sources", () => {
expect(inferSendSource({ isSubagent: true, isRpc: true, isCli: true })).toBe("sub-agent");
});
});
});

View File

@ -0,0 +1,108 @@
/**
* FIX-2.2: Request logging for outbound sends.
* Provides structured logging for all send operations with full request context.
*/
import { createSubsystemLogger } from "../../logging/subsystem.js";
const log = createSubsystemLogger("outbound/send");
export type SendLogContext = {
source: "cli" | "rpc" | "session" | "sub-agent" | "tool" | "unknown";
sessionKey?: string;
channel: string;
target: string;
resolvedTarget?: string;
resolvedFrom: "explicit" | "session" | "fallback" | "allowlist" | "directory";
accountId?: string;
callerIdentity?: string;
dryRun?: boolean;
firstTimeRecipient?: boolean;
};
/**
* Format a send log context into a structured log line.
*/
export function formatSendLogLine(ctx: SendLogContext): string {
const parts = [
`source=${ctx.source}`,
ctx.sessionKey ? `sessionKey=${ctx.sessionKey}` : null,
`channel=${ctx.channel}`,
`target=${maskTarget(ctx.target)}`,
ctx.resolvedTarget && ctx.resolvedTarget !== ctx.target
? `resolvedTarget=${maskTarget(ctx.resolvedTarget)}`
: null,
`resolvedFrom=${ctx.resolvedFrom}`,
ctx.accountId ? `accountId=${ctx.accountId}` : null,
ctx.callerIdentity ? `caller=${ctx.callerIdentity}` : null,
ctx.dryRun ? "dryRun=true" : null,
ctx.firstTimeRecipient ? "firstTime=true" : null,
].filter(Boolean);
return parts.join(" ");
}
/**
* Mask sensitive parts of a target (phone numbers, emails) for logging.
* Keeps enough info to identify the target without full exposure.
*/
function maskTarget(target: string): string {
// For group IDs, show as-is (less sensitive) - check this FIRST
if (target.includes("@g.us") || target.startsWith("group:") || target.startsWith("channel:")) {
return target;
}
// For phone numbers like +16503802766, show +1650***2766
if (/^\+\d{10,}$/.test(target)) {
return target.slice(0, 4) + "***" + target.slice(-4);
}
// For WhatsApp JIDs like 16503802766@s.whatsapp.net (individual chats)
const jidMatch = target.match(/^(\d+)@(.+)$/);
if (jidMatch) {
const [, num, domain] = jidMatch;
if (num.length >= 8) {
return num.slice(0, 3) + "***" + num.slice(-3) + "@" + domain;
}
}
// For email-like targets
if (target.includes("@") && !target.includes("whatsapp")) {
const [local, domain] = target.split("@");
if (local.length > 3) {
return local.slice(0, 2) + "***@" + domain;
}
}
// Default: mask middle if long enough
if (target.length > 8) {
return target.slice(0, 3) + "***" + target.slice(-3);
}
return target;
}
/**
* Log a send request with full context.
*/
export function logSendRequest(ctx: SendLogContext): void {
const line = formatSendLogLine(ctx);
if (ctx.firstTimeRecipient) {
log.warn(`[send] ${line}`);
} else if (ctx.dryRun) {
log.info(`[dry-run] ${line}`);
} else {
log.info(`[send] ${line}`);
}
}
/**
* Determine the source of a send request based on context.
*/
export function inferSendSource(params: {
sessionKey?: string;
isSubagent?: boolean;
isRpc?: boolean;
isCli?: boolean;
isTool?: boolean;
}): SendLogContext["source"] {
if (params.isSubagent) return "sub-agent";
if (params.isTool) return "tool";
if (params.isRpc) return "rpc";
if (params.isCli) return "cli";
if (params.sessionKey) return "session";
return "unknown";
}

View File

@ -18,11 +18,15 @@ export type TargetResolveKind = ChannelDirectoryEntryKind | "channel";
export type ResolveAmbiguousMode = "error" | "best" | "first";
export type TargetResolutionMethod = "explicit" | "directory" | "allowlist" | "fallback";
export type ResolvedMessagingTarget = {
to: string;
kind: TargetResolveKind;
display?: string;
source: "normalized" | "directory";
/** How the target was resolved - used for audit logging (FIX-2.2) */
from?: TargetResolutionMethod;
};
export type ResolveMessagingTargetResult =
@ -324,6 +328,7 @@ export async function resolveMessagingTarget(params: {
kind,
display: stripTargetPrefixes(raw),
source: "normalized",
from: "explicit",
},
};
}
@ -347,6 +352,7 @@ export async function resolveMessagingTarget(params: {
kind,
display: entry.name ?? entry.handle ?? stripTargetPrefixes(entry.id),
source: "directory",
from: "directory",
},
};
}
@ -362,6 +368,7 @@ export async function resolveMessagingTarget(params: {
kind,
display: best.name ?? best.handle ?? stripTargetPrefixes(best.id),
source: "directory",
from: "directory",
},
};
}
@ -386,6 +393,7 @@ export async function resolveMessagingTarget(params: {
kind,
display: stripTargetPrefixes(raw),
source: "normalized",
from: "fallback",
},
};
}

View File

@ -208,6 +208,172 @@ describe("resolveOutboundTarget", () => {
expect(res).toEqual({ ok: true, to: "+1555000001" });
});
});
describe("whatsapp automation recipients validation (FIX-1.6)", () => {
it("blocks heartbeat mode send when target not in automation.recipients", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
allowFrom: ["+1555000001", "+1555999999"],
automation: { recipients: ["+1555000001"] },
},
},
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "+1555999999",
cfg,
mode: "heartbeat",
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("automation.recipients");
expect(res.error.message).toContain("Automation send");
}
});
it("allows heartbeat mode send when target is in automation.recipients", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
allowFrom: ["+1555000001", "+1555999999"],
automation: { recipients: ["+1555000001"] },
},
},
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "+1555000001",
cfg,
mode: "heartbeat",
});
expect(res).toEqual({ ok: true, to: "+1555000001" });
});
it("blocks automation mode send when target not in automation.recipients", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
allowFrom: ["+1555000001", "+1555999999"],
automation: { recipients: ["+1555000001"] },
},
},
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "+1555999999",
cfg,
mode: "automation",
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("automation.recipients");
}
});
it("allows automation mode send when target is in automation.recipients", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
allowFrom: ["+1555000001"],
automation: { recipients: ["+1555000001"] },
},
},
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "+1555000001",
cfg,
mode: "automation",
});
expect(res).toEqual({ ok: true, to: "+1555000001" });
});
it("blocks automation when automation.recipients is explicitly empty", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
allowFrom: ["+1555000001", "+1555000002"],
automation: { recipients: [] },
},
},
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "+1555999999",
cfg,
mode: "heartbeat",
});
// FIX-2: When automation.recipients is explicitly empty, block ALL automation sends
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("automation.recipients is configured but empty");
}
});
it("falls back to allowlist when automation.recipients is NOT configured (undefined)", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
allowFrom: ["+1555000001", "+1555000002"],
// automation.recipients is undefined - not configured
},
},
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "+1555999999",
cfg,
mode: "heartbeat",
});
// When automation.recipients is NOT configured (undefined), fall back to allowlist
expect(res).toEqual({ ok: true, to: "+1555000001" });
});
it("allows groups in heartbeat mode regardless of automation.recipients", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
allowFrom: ["+1555000001"],
automation: { recipients: ["+1555000001"] },
},
},
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "120363401234567890@g.us",
cfg,
mode: "heartbeat",
});
expect(res).toEqual({ ok: true, to: "120363401234567890@g.us" });
});
it("uses account-level automation.recipients when configured", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
allowFrom: ["+1555000001"],
automation: { recipients: ["+1555000001"] },
accounts: {
personal: {
allowFrom: ["+1555000001", "+1555000002"],
automation: { recipients: ["+1555000002"] },
},
},
},
},
};
// Account-level automation.recipients (+1555000002) should take precedence over global (+1555000001)
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "+1555000002",
cfg,
accountId: "personal",
mode: "heartbeat",
});
expect(res).toEqual({ ok: true, to: "+1555000002" });
});
});
});
describe("resolveHeartbeatDeliveryTarget", () => {
@ -220,7 +386,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
);
});
it("returns none with reason require-explicit when requireExplicitTarget is true and no to is set", () => {
it("throws error when requireExplicitTarget is true and no to is set", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
@ -233,12 +399,35 @@ describe("resolveHeartbeatDeliveryTarget", () => {
},
channels: { whatsapp: { allowFrom: ["+1555000001"] } },
};
const result = resolveHeartbeatDeliveryTarget({
cfg,
entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" },
});
expect(result.channel).toBe("none");
expect(result.reason).toBe("require-explicit");
// FIX-1.4: Now throws instead of returning channel: "none" (stricter behavior)
expect(() =>
resolveHeartbeatDeliveryTarget({
cfg,
entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" },
}),
).toThrow(/requireExplicitTarget is enabled but no explicit 'to' target was provided/);
});
it("throws error when requireExplicitTarget defaults to true and no to is set", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: {
target: "whatsapp",
// requireExplicitTarget defaults to true now
// no `to` set
},
},
},
channels: { whatsapp: { allowFrom: ["+1555000001"] } },
};
// FIX-1.4: Default is now strict mode - throws if no explicit target
expect(() =>
resolveHeartbeatDeliveryTarget({
cfg,
entry: { sessionId: "test", updatedAt: 1, lastChannel: "whatsapp", lastTo: "+1555000001" },
}),
).toThrow(/requireExplicitTarget is enabled but no explicit 'to' target was provided/);
});
it("allows heartbeat delivery when requireExplicitTarget is true and to is set", () => {
@ -262,13 +451,14 @@ describe("resolveHeartbeatDeliveryTarget", () => {
expect(result.to).toBe("+1555000001");
});
it("allows implicit routing when requireExplicitTarget is false (default)", () => {
it("allows implicit routing when requireExplicitTarget is explicitly set to false", () => {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: {
target: "last",
// requireExplicitTarget defaults to false
// Must explicitly set to false to allow implicit routing
requireExplicitTarget: false,
},
},
},

View File

@ -182,7 +182,8 @@ export function resolveHeartbeatDeliveryTarget(params: {
const { cfg, entry } = params;
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
const rawTarget = heartbeat?.target;
const requireExplicitTarget = heartbeat?.requireExplicitTarget ?? false;
// FIX-1.4: Default to strict mode - require explicit target for heartbeat delivery
const requireExplicitTarget = heartbeat?.requireExplicitTarget ?? true;
const explicitTo = heartbeat?.to?.trim();
let target: HeartbeatTarget = "last";
if (rawTarget === "none" || rawTarget === "last") {
@ -203,16 +204,13 @@ export function resolveHeartbeatDeliveryTarget(params: {
};
}
// When requireExplicitTarget is enabled, only allow delivery if an explicit target is set
// FIX-1.4: When requireExplicitTarget is enabled, throw an error if no explicit target is set
// This prevents accidental sends to stale/implicit targets from cron/system events
if (requireExplicitTarget && !explicitTo) {
const base = resolveSessionDeliveryTarget({ entry });
return {
channel: "none",
reason: "require-explicit",
accountId: undefined,
lastChannel: base.lastChannel,
lastAccountId: base.lastAccountId,
};
throw new Error(
`Heartbeat delivery blocked: requireExplicitTarget is enabled but no explicit 'to' target was provided. ` +
`Set agents.defaults.heartbeat.to to an explicit target, or set agents.defaults.heartbeat.requireExplicitTarget: false to allow implicit routing.`,
);
}
const resolvedTarget = resolveSessionDeliveryTarget({

View File

@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { isSenderTrustedForDeliveryContext } from "./delivery-context.js";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
isSenderTrustedForDeliveryContext,
isDeliveryContextExpired,
createDeliveryContext,
normalizeDeliveryContext,
DEFAULT_DELIVERY_CONTEXT_TTL_MS,
} from "./delivery-context.js";
describe("isSenderTrustedForDeliveryContext", () => {
it("returns true when trustAll is set", () => {
@ -90,3 +96,100 @@ describe("isSenderTrustedForDeliveryContext", () => {
expect(result).toBe(true);
});
});
describe("isDeliveryContextExpired", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns true for undefined context", () => {
expect(isDeliveryContextExpired(undefined)).toBe(true);
});
it("returns true for context without updatedAt", () => {
expect(isDeliveryContextExpired({ channel: "whatsapp", to: "+1555000001" })).toBe(true);
});
it("returns false for fresh context within TTL", () => {
const now = Date.now();
vi.setSystemTime(now);
const context = {
channel: "whatsapp",
to: "+1555000001",
updatedAt: now - 1000, // 1 second ago
};
expect(isDeliveryContextExpired(context)).toBe(false);
});
it("returns true for context older than default TTL (24 hours)", () => {
const now = Date.now();
vi.setSystemTime(now);
const context = {
channel: "whatsapp",
to: "+1555000001",
updatedAt: now - DEFAULT_DELIVERY_CONTEXT_TTL_MS - 1000, // Just over 24 hours ago
};
expect(isDeliveryContextExpired(context)).toBe(true);
});
it("respects custom TTL parameter", () => {
const now = Date.now();
vi.setSystemTime(now);
const oneHourMs = 60 * 60 * 1000;
const context = {
channel: "whatsapp",
to: "+1555000001",
updatedAt: now - oneHourMs - 1000, // Just over 1 hour ago
};
// With default TTL (24h), this should not be expired
expect(isDeliveryContextExpired(context)).toBe(false);
// With 1 hour TTL, this should be expired
expect(isDeliveryContextExpired(context, oneHourMs)).toBe(true);
});
});
describe("createDeliveryContext", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("creates context with current timestamp", () => {
const now = 1700000000000;
vi.setSystemTime(now);
const context = createDeliveryContext({
channel: "whatsapp",
to: "+1555000001",
});
expect(context.channel).toBe("whatsapp");
expect(context.to).toBe("+1555000001");
expect(context.updatedAt).toBe(now);
});
});
describe("normalizeDeliveryContext", () => {
it("preserves updatedAt timestamp", () => {
const context = normalizeDeliveryContext({
channel: "whatsapp",
to: "+1555000001",
updatedAt: 1700000000000,
});
expect(context?.updatedAt).toBe(1700000000000);
});
it("handles invalid updatedAt values", () => {
const context = normalizeDeliveryContext({
channel: "whatsapp",
to: "+1555000001",
updatedAt: NaN,
});
expect(context?.updatedAt).toBeUndefined();
});
});

View File

@ -1,11 +1,16 @@
import { normalizeAccountId } from "./account-id.js";
import { normalizeMessageChannel } from "./message-channel.js";
/** Default TTL for delivery context in milliseconds (24 hours) */
export const DEFAULT_DELIVERY_CONTEXT_TTL_MS = 24 * 60 * 60 * 1000;
export type DeliveryContext = {
channel?: string;
to?: string;
accountId?: string;
threadId?: string | number;
/** Timestamp when this context was last updated (ms since epoch) */
updatedAt?: number;
};
export type DeliveryContextTrustParams = {
@ -55,6 +60,33 @@ export type DeliveryContextSessionSource = {
deliveryContext?: DeliveryContext;
};
/**
* Check if a delivery context has expired based on its updatedAt timestamp.
* @param context The delivery context to check
* @param ttlMs TTL in milliseconds (default: 24 hours)
* @returns true if the context has expired or has no timestamp
*/
export function isDeliveryContextExpired(
context?: DeliveryContext,
ttlMs: number = DEFAULT_DELIVERY_CONTEXT_TTL_MS,
): boolean {
if (!context) return true;
const updatedAt = context.updatedAt;
if (typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) return true;
const now = Date.now();
return now - updatedAt > ttlMs;
}
/**
* Create a fresh delivery context with the current timestamp.
*/
export function createDeliveryContext(params: Omit<DeliveryContext, "updatedAt">): DeliveryContext {
return {
...normalizeDeliveryContext(params),
updatedAt: Date.now(),
};
}
export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryContext | undefined {
if (!context) return undefined;
const channel =
@ -71,6 +103,11 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon
: undefined;
const normalizedThreadId =
typeof threadId === "string" ? (threadId ? threadId : undefined) : threadId;
// Preserve updatedAt timestamp if present
const updatedAt =
typeof context.updatedAt === "number" && Number.isFinite(context.updatedAt)
? context.updatedAt
: undefined;
if (!channel && !to && !accountId && normalizedThreadId == null) return undefined;
const normalized: DeliveryContext = {
channel: channel || undefined,
@ -78,6 +115,7 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon
accountId,
};
if (normalizedThreadId != null) normalized.threadId = normalizedThreadId;
if (updatedAt != null) normalized.updatedAt = updatedAt;
return normalized;
}
@ -129,9 +167,15 @@ export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSo
export function deliveryContextFromSession(
entry?: DeliveryContextSessionSource,
options?: { ttlMs?: number; checkExpiry?: boolean },
): DeliveryContext | undefined {
if (!entry) return undefined;
return normalizeSessionDeliveryFields(entry).deliveryContext;
const context = normalizeSessionDeliveryFields(entry).deliveryContext;
// FIX-3.2: Check for TTL expiration if requested
if (options?.checkExpiry !== false && isDeliveryContextExpired(context, options?.ttlMs)) {
return undefined;
}
return context;
}
export function mergeDeliveryContext(
@ -141,14 +185,27 @@ export function mergeDeliveryContext(
const normalizedPrimary = normalizeDeliveryContext(primary);
const normalizedFallback = normalizeDeliveryContext(fallback);
if (!normalizedPrimary && !normalizedFallback) return undefined;
// Use the most recent timestamp from either context
const primaryUpdatedAt = normalizedPrimary?.updatedAt ?? 0;
const fallbackUpdatedAt = normalizedFallback?.updatedAt ?? 0;
const updatedAt = Math.max(primaryUpdatedAt, fallbackUpdatedAt) || Date.now();
return normalizeDeliveryContext({
channel: normalizedPrimary?.channel ?? normalizedFallback?.channel,
to: normalizedPrimary?.to ?? normalizedFallback?.to,
accountId: normalizedPrimary?.accountId ?? normalizedFallback?.accountId,
threadId: normalizedPrimary?.threadId ?? normalizedFallback?.threadId,
updatedAt,
});
}
/**
* Clear the delivery context (returns undefined).
* Used when resetting routing state (e.g., on logout).
*/
export function clearDeliveryContext(): undefined {
return undefined;
}
export function deliveryContextKey(context?: DeliveryContext): string | undefined {
const normalized = normalizeDeliveryContext(context);
if (!normalized?.channel || !normalized?.to) return undefined;

View File

@ -28,6 +28,7 @@ export type ResolvedWhatsAppAccount = {
ackReaction?: WhatsAppAccountConfig["ackReaction"];
groups?: WhatsAppAccountConfig["groups"];
debounceMs?: number;
automation?: WhatsAppAccountConfig["automation"];
};
function listConfiguredAccountIds(cfg: MoltbotConfig): string[] {
@ -157,6 +158,7 @@ export function resolveWhatsAppAccount(params: {
ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction,
groups: accountCfg?.groups ?? rootCfg?.groups,
debounceMs: accountCfg?.debounceMs ?? rootCfg?.debounceMs,
automation: accountCfg?.automation ?? rootCfg?.automation,
};
}