fix(security): lock down inbound DMs by default

This commit is contained in:
Peter Steinberger
2026-01-06 17:51:38 +01:00
parent 327ad3c9c7
commit 967cef80bc
36 changed files with 2093 additions and 203 deletions

View File

@@ -5,6 +5,11 @@
## Unreleased
### Breaking
- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack.
- Previously, if you didnt configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots).
- New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
- To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
- Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>` (Telegram also supports `clawdbot telegram pairing ...`).
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the users local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
@@ -86,6 +91,7 @@
- Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194.
- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
- Telegram: notify users when inbound media exceeds size limits. Thanks @jarvis-medmatic for PR #283.
- Telegram: send GIF media as animations (auto-play) and improve filename sniffing.
- Bash tool: inherit gateway PATH so Nix-provided tools resolve during commands. Thanks @joshp123 for PR #202.

View File

@@ -184,15 +184,28 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
}
```
### `whatsapp.dmPolicy`
Controls how WhatsApp direct chats (DMs) are handled:
- `"pairing"` (default): unknown senders get a pairing code; owner must approve
- `"allowlist"`: only allow senders in `whatsapp.allowFrom` (or paired allow store)
- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`)
- `"disabled"`: ignore all inbound DMs
Pairing approvals:
- `clawdbot pairing list --provider whatsapp`
- `clawdbot pairing approve --provider whatsapp <code>`
### `whatsapp.allowFrom`
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (**DMs only**).
If empty, the default allowlist is your own WhatsApp number (self-chat mode).
If empty and `whatsapp.dmPolicy="pairing"`, unknown senders will receive a pairing code.
For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`.
```json5
{
whatsapp: {
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "+447700900123"],
textChunkLimit: 4000 // optional outbound chunk size (chars)
}
@@ -338,8 +351,9 @@ Set `telegram.enabled: false` to disable automatic startup.
telegram: {
enabled: true,
botToken: "your-bot-token",
requireMention: true,
allowFrom: ["123456789"],
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["tg:123456789"], // optional; "open" requires ["*"]
groups: { "*": { requireMention: true } },
mediaMaxMb: 5,
proxy: "socks5://localhost:9050",
webhookUrl: "https://example.com/telegram-webhook",
@@ -385,7 +399,8 @@ Configure the Discord bot by setting the bot token and optional gating:
},
dm: {
enabled: true, // disable all DMs when false
allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names)
policy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["1234567890", "steipete"], // optional DM allowlist ("open" requires ["*"])
groupEnabled: false, // enable group DMs
groupChannels: ["clawd-dm"] // optional group DM allowlist
},
@@ -426,7 +441,8 @@ Slack runs in Socket Mode and requires both a bot token and app token:
appToken: "xapp-...",
dm: {
enabled: true,
allowFrom: ["U123", "U456", "*"],
policy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["U123", "U456", "*"], // optional; "open" requires ["*"]
groupEnabled: false,
groupChannels: ["G123"]
},
@@ -481,6 +497,7 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
enabled: true,
cliPath: "imsg",
dbPath: "~/Library/Messages/chat.db",
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
includeAttachments: false,
mediaMaxMb: 16,

View File

@@ -23,11 +23,15 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
- If you prefer env vars, still add `discord: { enabled: true }` to `~/.clawdbot/clawdbot.json` and set `DISCORD_BOT_TOKEN`.
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs.
8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
11. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`).
7. Direct chats: secure by default via `discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code; approve via `clawdbot pairing approve --provider discord <code>`.
- To keep old “open to anyone” behavior: set `discord.dm.policy="open"` and `discord.dm.allowFrom=["*"]`.
- To hard-allowlist: set `discord.dm.policy="allowlist"` and list senders in `discord.dm.allowFrom`.
- To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`.
8. Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`.
9. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
10. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
11. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`).
- The `discord` tool is only exposed when the current surface is Discord.
12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session.
@@ -138,7 +142,7 @@ Notes:
- The bot lacks channel permissions (View/Send/Read History), or
- Your config requires mentions and you didnt mention it, or
- Your guild/channel allowlist denies the channel/user.
- **DMs dont work**: `discord.dm.enabled` may be `false` or `discord.dm.allowFrom` doesnt include you.
- **DMs dont work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you havent been approved yet (`discord.dm.policy="pairing"`).
## Capabilities & limits
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
@@ -183,6 +187,7 @@ Notes:
},
dm: {
enabled: true,
policy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["123456789012345678", "steipete"],
groupEnabled: false,
groupChannels: ["clawd-dm"]
@@ -208,7 +213,8 @@ Ack reactions are controlled globally via `messages.ackReaction` +
`messages.ackReactionScope`.
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender.
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation.
- `dm.groupEnabled`: enable group DMs (default `false`).
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.

View File

@@ -18,7 +18,7 @@ Updated: 2025-12-07
- **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammYs `client.baseFetch`.
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls).
- **Sessions:** direct chats map to `main`; groups map to `telegram:group:<chatId>`; replies route back to the same surface.
- **Config knobs:** `telegram.botToken`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`.
- **Config knobs:** `telegram.botToken`, `telegram.dmPolicy`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
Open questions

View File

@@ -26,6 +26,7 @@ Status: external CLI integration. No daemon.
enabled: true,
cliPath: "imsg",
dbPath: "~/Library/Messages/chat.db",
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
groupPolicy: "open",
groupAllowFrom: ["chat_id:123"],
@@ -39,6 +40,7 @@ Status: external CLI integration. No daemon.
Notes:
- `allowFrom` accepts handles (phone/email) or `chat_id:<id>` entries.
- Default: `imessage.dmPolicy="pairing"` — unknown DM senders get a pairing code (approve via `clawdbot pairing approve --provider imessage <code>`). `"open"` requires `allowFrom=["*"]`.
- `groupPolicy` controls group handling (`open|disabled|allowlist`).
- `groupAllowFrom` accepts the same entries as `allowFrom`.
- `service` defaults to `auto` (use `imessage` or `sms` to pin).

View File

@@ -49,6 +49,7 @@ This is social engineering 101. Create distrust, encourage snooping.
```
Only allow specific phone numbers to trigger your AI. Never use `["*"]` in production.
Newer versions default to **DM pairing** (`*.dmPolicy="pairing"`) on most providers; avoid `dmPolicy="open"` unless you explicitly want public inbound access.
### 2. Group Chat Mentions

View File

@@ -51,7 +51,8 @@ You can still run Clawdbot on your own Signal account if your goal is “respond
httpPort: 8080,
// Who is allowed to talk to the bot (DMs)
allowFrom: ["+15557654321"], // your personal number (or "*")
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15557654321"], // your personal number ("open" requires ["*"])
// Group policy + allowlist
groupPolicy: "open",
@@ -64,6 +65,10 @@ You can still run Clawdbot on your own Signal account if your goal is “respond
- Expect `signal.probe.ok=true` and `signal.probe.version`.
5) DM the bot number from your phone; Clawdbot replies.
## DM pairing
- Default: `signal.dmPolicy="pairing"` — unknown DM senders get a pairing code.
- Approve via: `clawdbot pairing approve --provider signal <code>`.
## “Do I need a separate number?”
- If you want “I text her and she texts me back”, yes: **use a separate Signal account/number for the bot**.
- Your personal account can run `signal-cli`, but you cant self-chat (Signal loop protection; Clawdbot ignores sender==account).

View File

@@ -148,6 +148,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
"groupPolicy": "open",
"dm": {
"enabled": true,
"policy": "pairing",
"allowFrom": ["U123", "U456", "*"],
"groupEnabled": false,
"groupChannels": ["G123"]
@@ -189,6 +190,11 @@ Ack reactions are controlled globally via `messages.ackReaction` +
- Channels map to `slack:channel:<channelId>` sessions.
- Slash commands use `slack:slash:<userId>` sessions.
## DM security (pairing)
- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code.
- Approve via: `clawdbot pairing approve --provider slack <code>`.
- To allow anyone: set `slack.dm.policy="open"` and `slack.dm.allowFrom=["*"]`.
## Group policy
- `slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
- `allowlist` requires channels to be listed in `slack.channels`.

View File

@@ -23,10 +23,10 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
- **Webhook mode** is enabled by setting `telegram.webhookUrl` (optionally `telegram.webhookSecret` / `telegram.webhookPath`).
- The webhook listener currently binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default.
- If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`.
4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config).
4) Direct chats: secure by default — unknown senders are gated by `telegram.dmPolicy` (default: `"pairing"`). The bot responds with a pairing code that the owner must approve before messages are processed. If you really want public inbound DMs: set `telegram.dmPolicy="open"` and `telegram.allowFrom=["*"]`.
5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:<chatId>`. When `telegram.groups` is set, it becomes a group allowlist (use `"*"` to allow all). Mention/command gating defaults come from `telegram.groups`.
6) Optional allowlist:
- Direct chats: `telegram.allowFrom` by chat id (`123456789`, `telegram:123456789`, or `tg:123456789`; prefixes are case-insensitive).
6) Allowlist + pairing:
- Direct chats: `telegram.allowFrom` (chat ids) or pairing approvals via `clawdbot pairing approve --provider telegram <code>` (alias: `clawdbot telegram pairing approve <code>`).
- Groups: set `telegram.groupPolicy = "allowlist"` and list senders in `telegram.groupAllowFrom` (fallback: explicit `telegram.allowFrom`).
## Capabilities & limits (Bot API)
@@ -39,7 +39,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
- Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits.
- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config).
- Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort.
- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.dmPolicy`, `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.groupAllowFrom`, `telegram.groupPolicy`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
- Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`.
- Mention gating precedence (most specific wins): `telegram.groups.<chatId>.requireMention``telegram.groups."*".requireMention` → default `true`.
@@ -49,12 +49,13 @@ Example config:
telegram: {
enabled: true,
botToken: "123:abc",
dmPolicy: "pairing", // pairing | allowlist | open | disabled
replyToMode: "off",
groups: {
"*": { requireMention: true }, // allow all groups
"123456789": { requireMention: false } // group chat id
},
allowFrom: ["123456789"], // direct chat ids allowed (or "*")
allowFrom: ["123456789"], // direct chat ids allowed ("open" requires ["*"])
groupPolicy: "allowlist",
groupAllowFrom: ["tg:123456789", "@alice"],
mediaMaxMb: 5,

View File

@@ -47,8 +47,10 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts.
- Status/broadcast chats are ignored.
- Direct chats use E.164; groups use group JID.
- **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only.
- If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode).
- **DM policy**: `whatsapp.dmPolicy` controls direct chat access (default: `pairing`).
- Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp <code>`).
- Open: requires `whatsapp.allowFrom` to include `"*"`.
- Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number.
- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).
- `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
@@ -120,6 +122,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Logged-out => stop and require re-link.
## Config quick map
- `whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
- `whatsapp.allowFrom` (DM allowlist).
- `whatsapp.groupAllowFrom` (group sender allowlist).
- `whatsapp.groupPolicy` (group policy).

122
src/cli/pairing-cli.ts Normal file
View File

@@ -0,0 +1,122 @@
import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import {
approveProviderPairingCode,
listProviderPairingRequests,
type PairingProvider,
} from "../pairing/pairing-store.js";
import { sendMessageSignal } from "../signal/send.js";
import { sendMessageSlack } from "../slack/send.js";
import { sendMessageTelegram } from "../telegram/send.js";
import { resolveTelegramToken } from "../telegram/token.js";
const PROVIDERS: PairingProvider[] = [
"telegram",
"signal",
"imessage",
"discord",
"slack",
"whatsapp",
];
function parseProvider(raw: unknown): PairingProvider {
const value = String(raw ?? "")
.trim()
.toLowerCase();
if ((PROVIDERS as string[]).includes(value)) return value as PairingProvider;
throw new Error(
`Invalid provider: ${value || "(empty)"} (expected one of: ${PROVIDERS.join(", ")})`,
);
}
async function notifyApproved(provider: PairingProvider, id: string) {
const message =
"✅ Clawdbot access approved. Send a message to start chatting.";
if (provider === "telegram") {
const cfg = loadConfig();
const { token } = resolveTelegramToken(cfg);
if (!token) throw new Error("telegram token not configured");
await sendMessageTelegram(id, message, { token });
return;
}
if (provider === "discord") {
await sendMessageDiscord(`user:${id}`, message);
return;
}
if (provider === "slack") {
await sendMessageSlack(`user:${id}`, message);
return;
}
if (provider === "signal") {
await sendMessageSignal(id, message);
return;
}
if (provider === "imessage") {
await sendMessageIMessage(id, message);
return;
}
// WhatsApp: approval still works (store); notifying requires an active web session.
}
export function registerPairingCli(program: Command) {
const pairing = program
.command("pairing")
.description("Secure DM pairing (approve inbound requests)");
pairing
.command("list")
.description("List pending pairing requests")
.requiredOption(
"--provider <provider>",
`Provider (${PROVIDERS.join(", ")})`,
)
.option("--json", "Print JSON", false)
.action(async (opts) => {
const provider = parseProvider(opts.provider);
const requests = await listProviderPairingRequests(provider);
if (opts.json) {
console.log(JSON.stringify({ provider, requests }, null, 2));
return;
}
if (requests.length === 0) {
console.log(`No pending ${provider} pairing requests.`);
return;
}
for (const r of requests) {
const meta = r.meta ? JSON.stringify(r.meta) : "";
console.log(
`${r.code} id=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
);
}
});
pairing
.command("approve")
.description("Approve a pairing code and allow that sender")
.requiredOption(
"--provider <provider>",
`Provider (${PROVIDERS.join(", ")})`,
)
.argument("<code>", "Pairing code (shown to the requester)")
.option("--notify", "Notify the requester on the same provider", false)
.action(async (code, opts) => {
const provider = parseProvider(opts.provider);
const approved = await approveProviderPairingCode({
provider,
code: String(code),
});
if (!approved) {
throw new Error(`No pending pairing request found for code: ${code}`);
}
console.log(`Approved ${provider} sender ${approved.id}.`);
if (!opts.notify) return;
await notifyApproved(provider, approved.id).catch((err) => {
console.log(`Failed to notify requester: ${String(err)}`);
});
});
}

View File

@@ -30,7 +30,9 @@ import { registerGatewayCli } from "./gateway-cli.js";
import { registerHooksCli } from "./hooks-cli.js";
import { registerModelsCli } from "./models-cli.js";
import { registerNodesCli } from "./nodes-cli.js";
import { registerPairingCli } from "./pairing-cli.js";
import { forceFreePort } from "./ports.js";
import { registerTelegramCli } from "./telegram-cli.js";
import { registerTuiCli } from "./tui-cli.js";
export { forceFreePort };
@@ -507,6 +509,8 @@ Examples:
registerCronCli(program);
registerDnsCli(program);
registerHooksCli(program);
registerPairingCli(program);
registerTelegramCli(program);
program
.command("status")

74
src/cli/telegram-cli.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import {
approveTelegramPairingCode,
listTelegramPairingRequests,
} from "../telegram/pairing-store.js";
import { sendMessageTelegram } from "../telegram/send.js";
import { resolveTelegramToken } from "../telegram/token.js";
export function registerTelegramCli(program: Command) {
const telegram = program
.command("telegram")
.description("Telegram helpers (pairing, allowlists)");
const pairing = telegram
.command("pairing")
.description("Secure DM pairing (approve inbound requests)");
pairing
.command("list")
.description("List pending Telegram pairing requests")
.option("--json", "Print JSON", false)
.action(async (opts) => {
const requests = await listTelegramPairingRequests();
if (opts.json) {
console.log(JSON.stringify({ requests }, null, 2));
return;
}
if (requests.length === 0) {
console.log("No pending Telegram pairing requests.");
return;
}
for (const r of requests) {
const name = [r.firstName, r.lastName].filter(Boolean).join(" ").trim();
const username = r.username ? `@${r.username}` : "";
const who = [name, username].filter(Boolean).join(" ").trim();
console.log(
`${r.code} chatId=${r.chatId}${who ? ` ${who}` : ""} ${r.createdAt}`,
);
}
});
pairing
.command("approve")
.description("Approve a pairing code and allow that chatId")
.argument("<code>", "Pairing code (shown to the requester)")
.option("--no-notify", "Do not notify the requester on Telegram")
.action(async (code, opts) => {
const approved = await approveTelegramPairingCode({ code: String(code) });
if (!approved) {
throw new Error(`No pending pairing request found for code: ${code}`);
}
console.log(`Approved Telegram chatId ${approved.chatId}.`);
if (opts.notify === false) return;
const cfg = loadConfig();
const { token } = resolveTelegramToken(cfg);
if (!token) {
console.log(
"Telegram token not configured; skipping requester notification.",
);
return;
}
await sendMessageTelegram(
approved.chatId,
"✅ Clawdbot access approved. Send a message to start chatting.",
{ token },
).catch((err) => {
console.log(`Failed to notify requester: ${String(err)}`);
});
});
}

View File

@@ -93,6 +93,18 @@ vi.mock("../daemon/service.js", () => ({
}),
}));
vi.mock("../telegram/pairing-store.js", () => ({
readTelegramAllowFromStore: vi.fn().mockResolvedValue([]),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: vi.fn().mockResolvedValue([]),
}));
vi.mock("../telegram/token.js", () => ({
resolveTelegramToken: vi.fn(() => ({ token: "", source: "none" })),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: {
log: () => {},

View File

@@ -27,10 +27,13 @@ import {
} from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js";
import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164, resolveUserPath, sleep } from "../utils.js";
import { healthCommand } from "./health.js";
import {
applyWizardMetadata,
@@ -50,6 +53,196 @@ function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string {
return path.join(os.homedir(), ".clawdis", "clawdis.json");
}
async function noteSecurityWarnings(cfg: ClawdbotConfig) {
const warnings: string[] = [];
const warnDmPolicy = async (params: {
label: string;
provider:
| "telegram"
| "signal"
| "imessage"
| "discord"
| "slack"
| "whatsapp";
dmPolicy: string;
allowFrom?: Array<string | number> | null;
allowFromPath: string;
approveHint: string;
normalizeEntry?: (raw: string) => string;
}) => {
const dmPolicy = params.dmPolicy;
const configAllowFrom = (params.allowFrom ?? []).map((v) =>
String(v).trim(),
);
const hasWildcard = configAllowFrom.includes("*");
const storeAllowFrom = await readProviderAllowFromStore(
params.provider,
).catch(() => []);
const normalizedCfg = configAllowFrom
.filter((v) => v !== "*")
.map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v))
.map((v) => v.trim())
.filter(Boolean);
const normalizedStore = storeAllowFrom
.map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v))
.map((v) => v.trim())
.filter(Boolean);
const allowCount = Array.from(
new Set([...normalizedCfg, ...normalizedStore]),
).length;
if (dmPolicy === "open") {
const policyPath = `${params.allowFromPath}policy`;
const allowFromPath = `${params.allowFromPath}allowFrom`;
warnings.push(
`- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`,
);
if (!hasWildcard) {
warnings.push(
`- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`,
);
}
return;
}
if (dmPolicy === "disabled") {
const policyPath = `${params.allowFromPath}policy`;
warnings.push(
`- ${params.label} DMs: disabled (${policyPath}="disabled").`,
);
return;
}
if (allowCount === 0) {
const policyPath = `${params.allowFromPath}policy`;
warnings.push(
`- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`,
);
warnings.push(` ${params.approveHint}`);
}
};
const telegramConfigured = Boolean(cfg.telegram);
const { token: telegramToken } = resolveTelegramToken(cfg);
if (telegramConfigured && telegramToken.trim()) {
const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing";
const configAllowFrom = (cfg.telegram?.allowFrom ?? []).map((v) =>
String(v).trim(),
);
const hasWildcard = configAllowFrom.includes("*");
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const allowCount = Array.from(
new Set([
...configAllowFrom
.filter((v) => v !== "*")
.map((v) => v.replace(/^(telegram|tg):/i, ""))
.filter(Boolean),
...storeAllowFrom.filter((v) => v !== "*"),
]),
).length;
if (dmPolicy === "open") {
warnings.push(
`- Telegram DMs: OPEN (telegram.dmPolicy="open"). Anyone who can find the bot can DM it.`,
);
if (!hasWildcard) {
warnings.push(
`- Telegram DMs: config invalid — dmPolicy "open" requires telegram.allowFrom to include "*".`,
);
}
} else if (dmPolicy === "disabled") {
warnings.push(`- Telegram DMs: disabled (telegram.dmPolicy="disabled").`);
} else if (allowCount === 0) {
warnings.push(
`- Telegram DMs: locked (telegram.dmPolicy="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`,
);
warnings.push(
` Approve via: clawdbot telegram pairing list / clawdbot telegram pairing approve <code>`,
);
}
const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
const groupAllowlistConfigured =
cfg.telegram?.groups && Object.keys(cfg.telegram.groups).length > 0;
if (groupPolicy === "open" && !groupAllowlistConfigured) {
warnings.push(
`- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`,
);
}
}
if (cfg.discord?.enabled !== false) {
await warnDmPolicy({
label: "Discord",
provider: "discord",
dmPolicy: cfg.discord?.dm?.policy ?? "pairing",
allowFrom: cfg.discord?.dm?.allowFrom ?? [],
allowFromPath: "discord.dm.",
approveHint:
"Approve via: clawdbot pairing list --provider discord / clawdbot pairing approve --provider discord <code>",
normalizeEntry: (raw) =>
raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
});
}
if (cfg.slack?.enabled !== false) {
await warnDmPolicy({
label: "Slack",
provider: "slack",
dmPolicy: cfg.slack?.dm?.policy ?? "pairing",
allowFrom: cfg.slack?.dm?.allowFrom ?? [],
allowFromPath: "slack.dm.",
approveHint:
"Approve via: clawdbot pairing list --provider slack / clawdbot pairing approve --provider slack <code>",
normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""),
});
}
if (cfg.signal?.enabled !== false) {
await warnDmPolicy({
label: "Signal",
provider: "signal",
dmPolicy: cfg.signal?.dmPolicy ?? "pairing",
allowFrom: cfg.signal?.allowFrom ?? [],
allowFromPath: "signal.",
approveHint:
"Approve via: clawdbot pairing list --provider signal / clawdbot pairing approve --provider signal <code>",
normalizeEntry: (raw) =>
normalizeE164(raw.replace(/^signal:/i, "").trim()),
});
}
if (cfg.imessage?.enabled !== false) {
await warnDmPolicy({
label: "iMessage",
provider: "imessage",
dmPolicy: cfg.imessage?.dmPolicy ?? "pairing",
allowFrom: cfg.imessage?.allowFrom ?? [],
allowFromPath: "imessage.",
approveHint:
"Approve via: clawdbot pairing list --provider imessage / clawdbot pairing approve --provider imessage <code>",
});
}
if (cfg.whatsapp) {
await warnDmPolicy({
label: "WhatsApp",
provider: "whatsapp",
dmPolicy: cfg.whatsapp?.dmPolicy ?? "pairing",
allowFrom: cfg.whatsapp?.allowFrom ?? [],
allowFromPath: "whatsapp.",
approveHint:
"Approve via: clawdbot pairing list --provider whatsapp / clawdbot pairing approve --provider whatsapp <code>",
normalizeEntry: (raw) => normalizeE164(raw),
});
}
if (warnings.length > 0) {
note(warnings.join("\n"), "Security");
}
}
function replacePathSegment(
value: string | undefined,
from: string,
@@ -645,6 +838,8 @@ export async function doctorCommand(
await maybeMigrateLegacyGatewayService(cfg, runtime);
await noteSecurityWarnings(cfg);
if (process.platform === "linux" && resolveMode(cfg) === "local") {
const service = resolveGatewayService();
let loaded = false;

View File

@@ -679,6 +679,166 @@ describe("legacy config detection", () => {
}
});
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
telegram: { dmPolicy: "open", allowFrom: ["123456789"] },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("telegram.allowFrom");
}
});
it('accepts telegram.dmPolicy="open" with allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
telegram: { dmPolicy: "open", allowFrom: ["*"] },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.telegram?.dmPolicy).toBe("open");
}
});
it("defaults telegram.dmPolicy to pairing when telegram section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ telegram: {} });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.telegram?.dmPolicy).toBe("pairing");
}
});
it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("whatsapp.allowFrom");
}
});
it('accepts whatsapp.dmPolicy="open" with allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
whatsapp: { dmPolicy: "open", allowFrom: ["*"] },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.whatsapp?.dmPolicy).toBe("open");
}
});
it("defaults whatsapp.dmPolicy to pairing when whatsapp section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ whatsapp: {} });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.whatsapp?.dmPolicy).toBe("pairing");
}
});
it('rejects signal.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
signal: { dmPolicy: "open", allowFrom: ["+15555550123"] },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("signal.allowFrom");
}
});
it('accepts signal.dmPolicy="open" with allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
signal: { dmPolicy: "open", allowFrom: ["*"] },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.signal?.dmPolicy).toBe("open");
}
});
it("defaults signal.dmPolicy to pairing when signal section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ signal: {} });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.signal?.dmPolicy).toBe("pairing");
}
});
it('rejects imessage.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("imessage.allowFrom");
}
});
it('accepts imessage.dmPolicy="open" with allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
imessage: { dmPolicy: "open", allowFrom: ["*"] },
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.imessage?.dmPolicy).toBe("open");
}
});
it("defaults imessage.dmPolicy to pairing when imessage section exists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ imessage: {} });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.imessage?.dmPolicy).toBe("pairing");
}
});
it('rejects discord.dm.policy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
discord: { dm: { policy: "open", allowFrom: ["123"] } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("discord.dm.allowFrom");
}
});
it('rejects slack.dm.policy="open" without allowFrom "*"', async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
slack: { dm: { policy: "open", allowFrom: ["U123"] } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("slack.dm.allowFrom");
}
});
it("rejects legacy agent.model string", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");

View File

@@ -101,6 +101,12 @@ const FIELD_LABELS: Record<string, string> = {
"messages.ackReactionScope": "Ack Reaction Scope",
"talk.apiKey": "Talk API Key",
"telegram.botToken": "Telegram Bot Token",
"telegram.dmPolicy": "Telegram DM Policy",
"whatsapp.dmPolicy": "WhatsApp DM Policy",
"signal.dmPolicy": "Signal DM Policy",
"imessage.dmPolicy": "iMessage DM Policy",
"discord.dm.policy": "Discord DM Policy",
"slack.dm.policy": "Slack DM Policy",
"discord.token": "Discord Bot Token",
"slack.botToken": "Slack Bot Token",
"slack.appToken": "Slack App Token",
@@ -137,6 +143,18 @@ const FIELD_HELP: Record<string, string> = {
"Emoji reaction used to acknowledge inbound messages (empty disables).",
"messages.ackReactionScope":
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
"telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].',
"whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].',
"signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires signal.allowFrom=["*"].',
"imessage.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires imessage.allowFrom=["*"].',
"discord.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires discord.dm.allowFrom=["*"].',
"slack.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].',
};
const FIELD_PLACEHOLDERS: Record<string, string> = {

View File

@@ -2,6 +2,7 @@ export type ReplyMode = "text" | "command";
export type SessionScope = "per-sender" | "global";
export type ReplyToMode = "off" | "first" | "all";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type SessionSendPolicyAction = "allow" | "deny";
export type SessionSendPolicyMatch = {
@@ -79,6 +80,8 @@ export type AgentElevatedAllowFromConfig = {
export type WhatsAppConfig = {
/** Optional per-account WhatsApp configuration (multi-account). */
accounts?: Record<string, WhatsAppAccountConfig>;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
/** Optional allowlist for WhatsApp direct chats (E.164). */
allowFrom?: string[];
/** Optional allowlist for WhatsApp group senders (E.164). */
@@ -105,6 +108,8 @@ export type WhatsAppAccountConfig = {
enabled?: boolean;
/** Override auth directory (Baileys multi-file auth state). */
authDir?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
@@ -222,6 +227,14 @@ export type HooksConfig = {
};
export type TelegramConfig = {
/**
* Controls how Telegram direct chats (DMs) are handled:
* - "pairing" (default): unknown senders get a pairing code; owner must approve
* - "allowlist": only allow senders in allowFrom (or paired allow store)
* - "open": allow all inbound DMs (requires allowFrom to include "*")
* - "disabled": ignore all inbound DMs
*/
dmPolicy?: DmPolicy;
/** If false, do not start the Telegram provider. Default: true. */
enabled?: boolean;
botToken?: string;
@@ -257,6 +270,8 @@ export type TelegramConfig = {
export type DiscordDmConfig = {
/** If false, ignore all incoming Discord DMs. Default: true. */
enabled?: boolean;
/** Direct message access policy (default: pairing). */
policy?: DmPolicy;
/** Allowlist for DM senders (ids or names). */
allowFrom?: Array<string | number>;
/** If true, allow group DMs (default: false). */
@@ -344,6 +359,8 @@ export type DiscordConfig = {
export type SlackDmConfig = {
/** If false, ignore all incoming Slack DMs. Default: true. */
enabled?: boolean;
/** Direct message access policy (default: pairing). */
policy?: DmPolicy;
/** Allowlist for DM senders (ids). */
allowFrom?: Array<string | number>;
/** If true, allow group DMs (default: false). */
@@ -424,6 +441,8 @@ export type SignalConfig = {
ignoreAttachments?: boolean;
ignoreStories?: boolean;
sendReadReceipts?: boolean;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
/** Optional allowlist for Signal group senders (E.164). */
groupAllowFrom?: Array<string | number>;
@@ -450,6 +469,8 @@ export type IMessageConfig = {
service?: "imessage" | "sms" | "auto";
/** Optional default region (used when sending SMS). */
region?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
/** Optional allowlist for inbound handles or chat_id targets. */
allowFrom?: Array<string | number>;
/** Optional allowlist for group senders or chat_id targets. */

View File

@@ -87,6 +87,8 @@ const ReplyToModeSchema = z.union([
// - .default("open") ensures runtime always resolves to "open" if not provided
const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
const QueueModeBySurfaceSchema = z
.object({
whatsapp: QueueModeSchema.optional(),
@@ -674,6 +676,7 @@ export const ClawdbotSchema = z.object({
enabled: z.boolean().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
@@ -689,9 +692,23 @@ export const ClawdbotSchema = z.object({
)
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"',
});
})
.optional(),
)
.optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.string()).optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
@@ -707,10 +724,24 @@ export const ClawdbotSchema = z.object({
)
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'whatsapp.dmPolicy="open" requires whatsapp.allowFrom to include "*"',
});
})
.optional(),
telegram: z
.object({
enabled: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
botToken: z.string().optional(),
tokenFile: z.string().optional(),
replyToMode: ReplyToModeSchema.optional(),
@@ -734,6 +765,19 @@ export const ClawdbotSchema = z.object({
webhookSecret: z.string().optional(),
webhookPath: z.string().optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"',
});
})
.optional(),
discord: z
.object({
@@ -774,10 +818,24 @@ export const ClawdbotSchema = z.object({
dm: z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
})
.superRefine((value, ctx) => {
if (value.policy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"',
});
})
.optional(),
guilds: z
.record(
@@ -842,10 +900,24 @@ export const ClawdbotSchema = z.object({
dm: z
.object({
enabled: z.boolean().optional(),
policy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
})
.superRefine((value, ctx) => {
if (value.policy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"',
});
})
.optional(),
channels: z
.record(
@@ -875,11 +947,25 @@ export const ClawdbotSchema = z.object({
ignoreAttachments: z.boolean().optional(),
ignoreStories: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'signal.dmPolicy="open" requires signal.allowFrom to include "*"',
});
})
.optional(),
imessage: z
@@ -891,11 +977,12 @@ export const ClawdbotSchema = z.object({
.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")])
.optional(),
region: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
includeAttachments: z.boolean().optional(),
mediaMaxMb: z.number().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
textChunkLimit: z.number().int().positive().optional(),
groups: z
.record(
@@ -908,6 +995,19 @@ export const ClawdbotSchema = z.object({
)
.optional(),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean);
if (allow.includes("*")) return;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["allowFrom"],
message:
'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"',
});
})
.optional(),
bridge: z
.object({

View File

@@ -6,6 +6,8 @@ const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
let config: Record<string, unknown> = {};
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
@@ -23,6 +25,13 @@ vi.mock("./send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: (...args: unknown[]) =>
readAllowFromStoreMock(...args),
upsertProviderPairingRequest: (...args: unknown[]) =>
upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
@@ -53,7 +62,10 @@ vi.mock("discord.js", () => {
}
}
login = vi.fn().mockResolvedValue(undefined);
destroy = vi.fn().mockResolvedValue(undefined);
destroy = vi.fn().mockImplementation(async () => {
handlers.clear();
Client.lastClient = null;
});
}
return {
@@ -98,12 +110,16 @@ async function waitForClient() {
beforeEach(() => {
config = {
messages: { responsePrefix: "PFX" },
discord: { dm: { enabled: true } },
discord: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } },
routing: { allowFrom: [] },
};
sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset();
updateLastRouteMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock
.mockReset()
.mockResolvedValue({ code: "PAIRCODE", created: true });
});
describe("monitorDiscordProvider tool results", () => {
@@ -152,7 +168,7 @@ describe("monitorDiscordProvider tool results", () => {
config = {
messages: { responsePrefix: "PFX" },
discord: {
dm: { enabled: true },
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
guilds: { "*": { requireMention: true } },
},
routing: {
@@ -202,4 +218,50 @@ describe("monitorDiscordProvider tool results", () => {
expect(replyMock).toHaveBeenCalledTimes(1);
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
config = {
...config,
discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
};
const controller = new AbortController();
const run = monitorDiscordProvider({
token: "token",
abortSignal: controller.signal,
});
const discord = await import("discord.js");
const client = await waitForClient();
if (!client) throw new Error("Discord client not created");
const reply = vi.fn().mockResolvedValue(undefined);
client.emit(discord.Events.MessageCreate, {
id: "m3",
content: "hello",
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
channelId: "c1",
channel: {
type: discord.ChannelType.DM,
isSendable: () => false,
},
guild: undefined,
mentions: { has: () => false },
attachments: { first: () => undefined },
type: discord.MessageType.Default,
createdTimestamp: Date.now(),
reply,
});
await flush();
controller.abort();
await run;
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(reply).toHaveBeenCalledTimes(1);
expect(String(reply.mock.calls[0]?.[0] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
});
});

View File

@@ -41,6 +41,10 @@ import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
import type { RuntimeEnv } from "../runtime.js";
import { sendMessageDiscord } from "./send.js";
import { normalizeDiscordToken } from "./token.js";
@@ -142,6 +146,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const dmConfig = cfg.discord?.dm;
const guildEntries = cfg.discord?.guilds;
const groupPolicy = cfg.discord?.groupPolicy ?? "open";
const dmPolicy = dmConfig?.policy ?? "pairing";
const allowFrom = dmConfig?.allowFrom;
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
@@ -160,7 +165,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (shouldLogVerbose()) {
logVerbose(
`discord: config dm=${dmEnabled ? "on" : "off"} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`,
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`,
);
}
@@ -210,6 +215,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
logVerbose("discord: drop dm (dms disabled)");
return;
}
if (isDirectMessage && dmPolicy === "disabled") {
logVerbose("discord: drop dm (dmPolicy: disabled)");
return;
}
const botId = client.user?.id;
const forwardedSnapshot = resolveForwardedSnapshot(message);
const forwardedText = forwardedSnapshot
@@ -386,22 +395,58 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
}
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
const allowList = normalizeDiscordAllowList(allowFrom, [
if (isDirectMessage && dmPolicy !== "open") {
const storeAllowFrom = await readProviderAllowFromStore(
"discord",
).catch(() => []);
const effectiveAllowFrom = Array.from(
new Set([...(allowFrom ?? []), ...storeAllowFrom]),
);
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [
"discord:",
"user:",
]);
const permitted =
allowList &&
allowList != null &&
allowListMatches(allowList, {
id: message.author.id,
name: message.author.username,
tag: message.author.tag,
});
if (!permitted) {
logVerbose(
`Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`,
);
if (dmPolicy === "pairing") {
const { code } = await upsertProviderPairingRequest({
provider: "discord",
id: message.author.id,
meta: {
username: message.author.username,
tag: message.author.tag,
},
});
logVerbose(
`discord pairing request sender=${message.author.id} tag=${message.author.tag} code=${code}`,
);
try {
await message.reply(
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider discord <code>",
].join("\n"),
);
} catch (err) {
logVerbose(
`discord pairing reply failed for ${message.author.id}: ${String(err)}`,
);
}
} else {
logVerbose(
`Blocked unauthorized discord sender ${message.author.id} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}

View File

@@ -7,6 +7,8 @@ const stopMock = vi.fn();
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
let config: Record<string, unknown> = {};
let notificationHandler:
@@ -30,6 +32,13 @@ vi.mock("./send.js", () => ({
sendMessageIMessage: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: (...args: unknown[]) =>
readAllowFromStoreMock(...args),
upsertProviderPairingRequest: (...args: unknown[]) =>
upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
@@ -63,7 +72,11 @@ async function waitForSubscribe() {
beforeEach(() => {
config = {
imessage: { groups: { "*": { requireMention: true } } },
imessage: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "*": { requireMention: true } },
},
session: { mainKey: "main" },
routing: {
groupChat: { mentionPatterns: ["@clawd"] },
@@ -79,6 +92,10 @@ beforeEach(() => {
sendMock.mockReset().mockResolvedValue({ messageId: "ok" });
replyMock.mockReset().mockResolvedValue({ text: "ok" });
updateLastRouteMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock
.mockReset()
.mockResolvedValue({ code: "PAIRCODE", created: true });
notificationHandler = undefined;
closeResolve = undefined;
});
@@ -234,6 +251,44 @@ describe("monitorIMessageProvider", () => {
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => {
config = {
...config,
imessage: {
dmPolicy: "pairing",
allowFrom: [],
groups: { "*": { requireMention: true } },
},
};
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
method: "message",
params: {
message: {
id: 99,
chat_id: 77,
sender: "+15550001111",
is_from_me: false,
text: "hello",
is_group: false,
},
},
});
await flush();
closeResolve?.();
await run;
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
});
it("delivers group replies when mentioned", async () => {
replyMock.mockResolvedValueOnce({ text: "yo" });
const run = monitorIMessageProvider();

View File

@@ -16,6 +16,10 @@ import {
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { mediaKindFromMime } from "../media/constants.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
import type { RuntimeEnv } from "../runtime.js";
import { createIMessageRpcClient } from "./client.js";
import { sendMessageIMessage } from "./send.js";
@@ -130,6 +134,7 @@ export async function monitorIMessageProvider(
const allowFrom = resolveAllowFrom(opts);
const groupAllowFrom = resolveGroupAllowFrom(opts);
const groupPolicy = cfg.imessage?.groupPolicy ?? "open";
const dmPolicy = cfg.imessage?.dmPolicy ?? "pairing";
const mentionRegexes = buildMentionRegexes(cfg);
const includeAttachments =
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
@@ -153,20 +158,34 @@ export async function monitorIMessageProvider(
if (isGroup && !chatId) return;
const groupId = isGroup ? String(chatId) : undefined;
const storeAllowFrom = await readProviderAllowFromStore("imessage").catch(
() => [],
);
const effectiveDmAllowFrom = Array.from(
new Set([...allowFrom, ...storeAllowFrom]),
)
.map((v) => String(v).trim())
.filter(Boolean);
const effectiveGroupAllowFrom = Array.from(
new Set([...groupAllowFrom, ...storeAllowFrom]),
)
.map((v) => String(v).trim())
.filter(Boolean);
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose("Blocked iMessage group message (groupPolicy: disabled)");
return;
}
if (groupPolicy === "allowlist") {
if (groupAllowFrom.length === 0) {
if (effectiveGroupAllowFrom.length === 0) {
logVerbose(
"Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)",
);
return;
}
const allowed = isAllowedIMessageSender({
allowFrom: groupAllowFrom,
allowFrom: effectiveGroupAllowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,
@@ -192,16 +211,64 @@ export async function monitorIMessageProvider(
}
}
const dmAuthorized = isAllowedIMessageSender({
allowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,
chatIdentifier,
});
if (!isGroup && !dmAuthorized) {
logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`);
return;
const dmHasWildcard = effectiveDmAllowFrom.includes("*");
const dmAuthorized =
dmPolicy === "open"
? true
: dmHasWildcard ||
(effectiveDmAllowFrom.length > 0 &&
isAllowedIMessageSender({
allowFrom: effectiveDmAllowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,
chatIdentifier,
}));
if (!isGroup) {
if (dmPolicy === "disabled") return;
if (!dmAuthorized) {
if (dmPolicy === "pairing") {
const senderId = normalizeIMessageHandle(sender);
const { code } = await upsertProviderPairingRequest({
provider: "imessage",
id: senderId,
meta: {
sender: senderId,
chatId: chatId ? String(chatId) : undefined,
},
});
logVerbose(
`imessage pairing request sender=${senderId} code=${code}`,
);
try {
await sendMessageIMessage(
sender,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider imessage <code>",
].join("\n"),
{
client,
maxBytes: mediaMaxBytes,
...(chatId ? { chatId } : {}),
},
);
} catch (err) {
logVerbose(
`imessage pairing reply failed for ${senderId}: ${String(err)}`,
);
}
} else {
logVerbose(
`Blocked iMessage sender ${sender} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
const messageText = (message.text ?? "").trim();
@@ -217,9 +284,9 @@ export async function monitorIMessageProvider(
});
const canDetectMention = mentionRegexes.length > 0;
const commandAuthorized = isGroup
? groupAllowFrom.length > 0
? effectiveGroupAllowFrom.length > 0
? isAllowedIMessageSender({
allowFrom: groupAllowFrom,
allowFrom: effectiveGroupAllowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,

View File

@@ -0,0 +1,268 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
export type PairingProvider =
| "telegram"
| "signal"
| "imessage"
| "discord"
| "slack"
| "whatsapp";
export type PairingRequest = {
id: string;
code: string;
createdAt: string;
lastSeenAt: string;
meta?: Record<string, string>;
};
type PairingStore = {
version: 1;
requests: PairingRequest[];
};
type AllowFromStore = {
version: 1;
allowFrom: string[];
};
function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {
const stateDir = resolveStateDir(env, os.homedir);
return resolveOAuthDir(env, stateDir);
}
function resolvePairingPath(
provider: PairingProvider,
env: NodeJS.ProcessEnv = process.env,
): string {
return path.join(resolveCredentialsDir(env), `${provider}-pairing.json`);
}
function resolveAllowFromPath(
provider: PairingProvider,
env: NodeJS.ProcessEnv = process.env,
): string {
return path.join(resolveCredentialsDir(env), `${provider}-allowFrom.json`);
}
function safeParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function readJsonFile<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = safeParseJson<T>(raw);
if (parsed == null) return { value: fallback, exists: true };
return { value: parsed, exists: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") return { value: fallback, exists: false };
return { value: fallback, exists: false };
}
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(
filePath,
`${JSON.stringify(value, null, 2)}\n`,
"utf-8",
);
}
function randomCode(): string {
// Human-friendly: 8 chars, upper, no ambiguous chars (0O1I).
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "";
for (let i = 0; i < 8; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
function normalizeId(value: string | number): string {
return String(value).trim();
}
function normalizeAllowEntry(provider: PairingProvider, entry: string): string {
const trimmed = entry.trim();
if (!trimmed) return "";
if (trimmed === "*") return "";
if (provider === "telegram") return trimmed.replace(/^(telegram|tg):/i, "");
if (provider === "signal") return trimmed.replace(/^signal:/i, "");
if (provider === "discord") return trimmed.replace(/^(discord|user):/i, "");
if (provider === "slack") return trimmed.replace(/^(slack|user):/i, "");
return trimmed;
}
export async function readProviderAllowFromStore(
provider: PairingProvider,
env: NodeJS.ProcessEnv = process.env,
): Promise<string[]> {
const filePath = resolveAllowFromPath(provider, env);
const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
allowFrom: [],
});
const list = Array.isArray(value.allowFrom) ? value.allowFrom : [];
return list
.map((v) => normalizeAllowEntry(provider, String(v)))
.filter(Boolean);
}
export async function addProviderAllowFromStoreEntry(params: {
provider: PairingProvider;
entry: string | number;
env?: NodeJS.ProcessEnv;
}): Promise<{ changed: boolean; allowFrom: string[] }> {
const env = params.env ?? process.env;
const filePath = resolveAllowFromPath(params.provider, env);
const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
allowFrom: [],
});
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
.map((v) => normalizeAllowEntry(params.provider, String(v)))
.filter(Boolean);
const normalized = normalizeAllowEntry(
params.provider,
normalizeId(params.entry),
);
if (!normalized) return { changed: false, allowFrom: current };
if (current.includes(normalized))
return { changed: false, allowFrom: current };
const next = [...current, normalized];
await writeJsonFile(filePath, {
version: 1,
allowFrom: next,
} satisfies AllowFromStore);
return { changed: true, allowFrom: next };
}
export async function listProviderPairingRequests(
provider: PairingProvider,
env: NodeJS.ProcessEnv = process.env,
): Promise<PairingRequest[]> {
const filePath = resolvePairingPath(provider, env);
const { value } = await readJsonFile<PairingStore>(filePath, {
version: 1,
requests: [],
});
const reqs = Array.isArray(value.requests) ? value.requests : [];
return reqs
.filter(
(r) =>
r &&
typeof r.id === "string" &&
typeof r.code === "string" &&
typeof r.createdAt === "string",
)
.slice()
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
export async function upsertProviderPairingRequest(params: {
provider: PairingProvider;
id: string | number;
meta?: Record<string, string | undefined | null>;
env?: NodeJS.ProcessEnv;
}): Promise<{ code: string; created: boolean }> {
const env = params.env ?? process.env;
const filePath = resolvePairingPath(params.provider, env);
const { value } = await readJsonFile<PairingStore>(filePath, {
version: 1,
requests: [],
});
const now = new Date().toISOString();
const id = normalizeId(params.id);
const meta =
params.meta && typeof params.meta === "object"
? Object.fromEntries(
Object.entries(params.meta)
.map(([k, v]) => [k, String(v ?? "").trim()] as const)
.filter(([_, v]) => Boolean(v)),
)
: undefined;
const reqs = Array.isArray(value.requests) ? value.requests : [];
const existingIdx = reqs.findIndex((r) => r.id === id);
if (existingIdx >= 0) {
const existing = reqs[existingIdx];
const existingCode =
existing && typeof existing.code === "string" ? existing.code.trim() : "";
const code = existingCode || randomCode();
const next: PairingRequest = {
id,
code,
createdAt: existing?.createdAt ?? now,
lastSeenAt: now,
meta: meta ?? existing?.meta,
};
reqs[existingIdx] = next;
await writeJsonFile(filePath, {
version: 1,
requests: reqs,
} satisfies PairingStore);
return { code, created: false };
}
const code = randomCode();
const next: PairingRequest = {
id,
code,
createdAt: now,
lastSeenAt: now,
...(meta ? { meta } : {}),
};
await writeJsonFile(filePath, {
version: 1,
requests: [...reqs, next],
} satisfies PairingStore);
return { code, created: true };
}
export async function approveProviderPairingCode(params: {
provider: PairingProvider;
code: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ id: string; entry?: PairingRequest } | null> {
const env = params.env ?? process.env;
const code = params.code.trim().toUpperCase();
if (!code) return null;
const filePath = resolvePairingPath(params.provider, env);
const { value } = await readJsonFile<PairingStore>(filePath, {
version: 1,
requests: [],
});
const reqs = Array.isArray(value.requests) ? value.requests : [];
const idx = reqs.findIndex(
(r) => String(r.code ?? "").toUpperCase() === code,
);
if (idx < 0) return null;
const entry = reqs[idx];
if (!entry) return null;
reqs.splice(idx, 1);
await writeJsonFile(filePath, {
version: 1,
requests: reqs,
} satisfies PairingStore);
await addProviderAllowFromStoreEntry({
provider: params.provider,
entry: entry.id,
env,
});
return { id: entry.id, entry };
}

View File

@@ -6,6 +6,8 @@ const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
let config: Record<string, unknown> = {};
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
@@ -23,6 +25,13 @@ vi.mock("./send.js", () => ({
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: (...args: unknown[]) =>
readAllowFromStoreMock(...args),
upsertProviderPairingRequest: (...args: unknown[]) =>
upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
@@ -47,7 +56,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
beforeEach(() => {
config = {
messages: { responsePrefix: "PFX" },
signal: { autoStart: false },
signal: { autoStart: false, dmPolicy: "open", allowFrom: ["*"] },
routing: { allowFrom: [] },
};
sendMock.mockReset().mockResolvedValue(undefined);
@@ -56,6 +65,10 @@ beforeEach(() => {
streamMock.mockReset();
signalCheckMock.mockReset().mockResolvedValue({});
signalRpcRequestMock.mockReset().mockResolvedValue({});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock
.mockReset()
.mockResolvedValue({ code: "PAIRCODE", created: true });
});
describe("monitorSignalProvider tool results", () => {
@@ -93,4 +106,42 @@ describe("monitorSignalProvider tool results", () => {
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
config = {
...config,
signal: { autoStart: false, dmPolicy: "pairing", allowFrom: [] },
};
streamMock.mockImplementation(async ({ onEvent }) => {
const payload = {
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1,
dataMessage: {
message: "hello",
},
},
};
await onEvent({
event: "receive",
data: JSON.stringify(payload),
});
});
await monitorSignalProvider({
autoStart: false,
baseUrl: "http://127.0.0.1:8080",
});
await flush();
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
});
});

View File

@@ -8,6 +8,10 @@ import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { mediaKindFromMime } from "../media/constants.js";
import { saveMediaBuffer } from "../media/store.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeE164 } from "../utils.js";
import { signalCheck, signalRpcRequest, streamSignalEvents } from "./client.js";
@@ -110,7 +114,7 @@ function resolveGroupAllowFrom(opts: MonitorSignalOpts): string[] {
}
function isAllowedSender(sender: string, allowFrom: string[]): boolean {
if (allowFrom.length === 0) return true;
if (allowFrom.length === 0) return false;
if (allowFrom.includes("*")) return true;
const normalizedAllow = allowFrom
.map((entry) => entry.replace(/^signal:/i, ""))
@@ -245,6 +249,7 @@ export async function monitorSignalProvider(
const textLimit = resolveTextChunkLimit(cfg, "signal");
const baseUrl = resolveBaseUrl(opts);
const account = resolveAccount(opts);
const dmPolicy = cfg.signal?.dmPolicy ?? "pairing";
const allowFrom = resolveAllowFrom(opts);
const groupAllowFrom = resolveGroupAllowFrom(opts);
const groupPolicy = cfg.signal?.groupPolicy ?? "open";
@@ -317,18 +322,67 @@ export async function monitorSignalProvider(
const groupId = dataMessage.groupInfo?.groupId ?? undefined;
const groupName = dataMessage.groupInfo?.groupName ?? undefined;
const isGroup = Boolean(groupId);
const storeAllowFrom = await readProviderAllowFromStore("signal").catch(
() => [],
);
const effectiveDmAllow = [...allowFrom, ...storeAllowFrom];
const effectiveGroupAllow = [...groupAllowFrom, ...storeAllowFrom];
const dmAllowed =
dmPolicy === "open" ? true : isAllowedSender(sender, effectiveDmAllow);
if (!isGroup) {
if (dmPolicy === "disabled") return;
if (!dmAllowed) {
if (dmPolicy === "pairing") {
const senderId = normalizeE164(sender);
const { code } = await upsertProviderPairingRequest({
provider: "signal",
id: senderId,
meta: {
name: envelope.sourceName ?? undefined,
},
});
logVerbose(
`signal pairing request sender=${senderId} code=${code}`,
);
try {
await sendMessageSignal(
senderId,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider signal <code>",
].join("\n"),
{ baseUrl, account, maxBytes: mediaMaxBytes },
);
} catch (err) {
logVerbose(
`signal pairing reply failed for ${senderId}: ${String(err)}`,
);
}
} else {
logVerbose(
`Blocked signal sender ${sender} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
if (isGroup && groupPolicy === "disabled") {
logVerbose("Blocked signal group message (groupPolicy: disabled)");
return;
}
if (isGroup && groupPolicy === "allowlist") {
if (groupAllowFrom.length === 0) {
if (effectiveGroupAllow.length === 0) {
logVerbose(
"Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)",
);
return;
}
if (!isAllowedSender(sender, groupAllowFrom)) {
if (!isAllowedSender(sender, effectiveGroupAllow)) {
logVerbose(
`Blocked signal group sender ${sender} (not in groupAllowFrom)`,
);
@@ -337,14 +391,10 @@ export async function monitorSignalProvider(
}
const commandAuthorized = isGroup
? groupAllowFrom.length > 0
? isAllowedSender(sender, groupAllowFrom)
? effectiveGroupAllow.length > 0
? isAllowedSender(sender, effectiveGroupAllow)
: true
: isAllowedSender(sender, allowFrom);
if (!isGroup && !commandAuthorized) {
logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`);
return;
}
: dmAllowed;
const messageText = (dataMessage.message ?? "").trim();
let mediaPath: string | undefined;

View File

@@ -7,6 +7,8 @@ const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
const reactMock = vi.fn();
let config: Record<string, unknown> = {};
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
const getSlackHandlers = () =>
(
globalThis as {
@@ -32,6 +34,13 @@ vi.mock("./send.js", () => ({
sendMessageSlack: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: (...args: unknown[]) =>
readAllowFromStoreMock(...args),
upsertProviderPairingRequest: (...args: unknown[]) =>
upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
@@ -89,13 +98,17 @@ beforeEach(() => {
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
slack: { dm: { enabled: true }, groupDm: { enabled: false } },
slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } },
routing: { allowFrom: [] },
};
sendMock.mockReset().mockResolvedValue(undefined);
replyMock.mockReset();
updateLastRouteMock.mockReset();
reactMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock
.mockReset()
.mockResolvedValue({ code: "PAIRCODE", created: true });
});
describe("monitorSlackProvider tool results", () => {
@@ -140,8 +153,7 @@ describe("monitorSlackProvider tool results", () => {
config = {
messages: { responsePrefix: "PFX" },
slack: {
dm: { enabled: true },
groupDm: { enabled: false },
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: true } },
},
routing: {
@@ -291,4 +303,44 @@ describe("monitorSlackProvider tool results", () => {
name: "👀",
});
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
config = {
...config,
slack: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
};
const controller = new AbortController();
const run = monitorSlackProvider({
botToken: "bot-token",
appToken: "app-token",
abortSignal: controller.signal,
});
await waitForEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
await handler({
event: {
type: "message",
user: "U1",
text: "hello",
ts: "123",
channel: "C1",
channel_type: "im",
},
});
await flush();
controller.abort();
await run;
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
});
});

View File

@@ -30,6 +30,10 @@ import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
import type { RuntimeEnv } from "../runtime.js";
import { reactSlackMessage } from "./actions.js";
import { sendMessageSlack } from "./send.js";
@@ -374,6 +378,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
};
const dmConfig = cfg.slack?.dm;
const dmPolicy = dmConfig?.policy ?? "pairing";
const allowFrom = normalizeAllowList(dmConfig?.allowFrom);
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels);
@@ -578,17 +583,63 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
return;
}
if (isDirectMessage && allowFrom.length > 0) {
const permitted = allowListMatches({
allowList: normalizeAllowListLower(allowFrom),
id: message.user,
});
if (!permitted) {
logVerbose(
`Blocked unauthorized slack sender ${message.user} (not in allowFrom)`,
);
const storeAllowFrom = await readProviderAllowFromStore("slack").catch(
() => [],
);
const effectiveAllowFrom = normalizeAllowList([
...allowFrom,
...storeAllowFrom,
]);
const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom);
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
logVerbose("slack: drop dm (dms disabled)");
return;
}
if (dmPolicy !== "open") {
const permitted = allowListMatches({
allowList: effectiveAllowFromLower,
id: message.user,
});
if (!permitted) {
if (dmPolicy === "pairing") {
const sender = await resolveUserName(message.user);
const senderName = sender?.name ?? undefined;
const { code } = await upsertProviderPairingRequest({
provider: "slack",
id: message.user,
meta: { name: senderName },
});
logVerbose(
`slack pairing request sender=${message.user} name=${senderName ?? "unknown"} code=${code}`,
);
try {
await sendMessageSlack(
message.channel,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider slack <code>",
].join("\n"),
{ token: botToken, client: app.client },
);
} catch (err) {
logVerbose(
`slack pairing reply failed for ${message.user}: ${String(err)}`,
);
}
} else {
logVerbose(
`Blocked unauthorized slack sender ${message.user} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
}
const channelConfig = isRoom
@@ -606,7 +657,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
const sender = await resolveUserName(message.user);
const senderName = sender?.name ?? message.user;
const allowList = normalizeAllowListLower(allowFrom);
const allowList = effectiveAllowFromLower;
const commandAuthorized =
allowList.length === 0 ||
allowListMatches({
@@ -1301,20 +1352,56 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}
}
if (isDirectMessage && allowFrom.length > 0) {
const sender = await resolveUserName(command.user_id);
const permitted = allowListMatches({
allowList: normalizeAllowListLower(allowFrom),
id: command.user_id,
name: sender?.name ?? undefined,
});
if (!permitted) {
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
await respond({
text: "You are not authorized to use this command.",
text: "Slack DMs are disabled.",
response_type: "ephemeral",
});
return;
}
if (dmPolicy !== "open") {
const storeAllowFrom = await readProviderAllowFromStore(
"slack",
).catch(() => []);
const effectiveAllowFrom = normalizeAllowList([
...allowFrom,
...storeAllowFrom,
]);
const sender = await resolveUserName(command.user_id);
const permitted = allowListMatches({
allowList: normalizeAllowListLower(effectiveAllowFrom),
id: command.user_id,
name: sender?.name ?? undefined,
});
if (!permitted) {
if (dmPolicy === "pairing") {
const senderName = sender?.name ?? undefined;
const { code } = await upsertProviderPairingRequest({
provider: "slack",
id: command.user_id,
meta: { name: senderName },
});
await respond({
text: [
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider slack <code>",
].join("\n"),
response_type: "ephemeral",
});
} else {
await respond({
text: "You are not authorized to use this command.",
response_type: "ephemeral",
});
}
return;
}
}
}
if (isRoom) {

View File

@@ -35,10 +35,18 @@ vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({}),
loadConfig: () => ({ telegram: { dmPolicy: "open", allowFrom: ["*"] } }),
};
});
vi.mock("./pairing-store.js", () => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
code: "PAIRCODE",
created: true,
})),
}));
vi.mock("../auto-reply/reply.js", () => {
const replySpy = vi.fn(async (_ctx, opts) => {
await opts?.onReplyStart?.();

View File

@@ -21,6 +21,21 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(
() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
code: "PAIRCODE",
created: true,
})),
}),
);
vi.mock("./pairing-store.js", () => ({
readTelegramAllowFromStore,
upsertTelegramPairingRequest,
}));
const useSpy = vi.fn();
const onSpy = vi.fn();
const stopSpy = vi.fn();
@@ -73,7 +88,9 @@ vi.mock("../auto-reply/reply.js", () => {
describe("createTelegramBot", () => {
beforeEach(() => {
loadConfig.mockReturnValue({});
loadConfig.mockReturnValue({
telegram: { dmPolicy: "open", allowFrom: ["*"] },
});
loadWebMedia.mockReset();
sendAnimationSpy.mockReset();
sendPhotoSpy.mockReset();
@@ -130,6 +147,46 @@ describe("createTelegramBot", () => {
}
});
it("requests pairing by default for unknown DM senders", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<
typeof vi.fn
>;
replySpy.mockReset();
loadConfig.mockReturnValue({ telegram: { dmPolicy: "pairing" } });
readTelegramAllowFromStore.mockResolvedValue([]);
upsertTelegramPairingRequest.mockResolvedValue({
code: "PAIRME12",
created: true,
});
createTelegramBot({ token: "tok" });
const handler = onSpy.mock.calls[0][1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
text: "hello",
date: 1736380800,
from: { id: 999, username: "random" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy.mock.calls[0]?.[0]).toBe(1234);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain(
"Pairing code:",
);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("PAIRME12");
});
it("triggers typing cue via onReplyStart", async () => {
onSpy.mockReset();
sendChatActionSpy.mockReset();
@@ -397,7 +454,10 @@ describe("createTelegramBot", () => {
await opts?.onToolResult?.({ text: "tool result" });
return { text: "final reply" };
});
loadConfig.mockReturnValue({ messages: { responsePrefix: "PFX" } });
loadConfig.mockReturnValue({
telegram: { dmPolicy: "open", allowFrom: ["*"] },
messages: { responsePrefix: "PFX" },
});
createTelegramBot({ token: "tok" });
const handler = onSpy.mock.calls[0][1] as (

View File

@@ -35,6 +35,10 @@ import {
} from "../providers/location.js";
import type { RuntimeEnv } from "../runtime.js";
import { loadWebMedia } from "../web/media.js";
import {
readTelegramAllowFromStore,
upsertTelegramPairingRequest,
} from "./pairing-store.js";
const PARSE_ERR_RE =
/can't parse entities|parse entities|find end of the entity/i;
@@ -111,6 +115,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "telegram");
const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing";
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom;
const groupAllowFrom =
opts.groupAllowFrom ??
@@ -150,8 +155,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
(entry) => entry === username || entry === `@${username}`,
);
};
const dmAllow = normalizeAllowFrom(allowFrom);
const groupAllow = normalizeAllowFrom(groupAllowFrom);
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
@@ -177,10 +180,19 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const processMessage = async (
primaryCtx: TelegramContext,
allMedia: Array<{ path: string; contentType?: string }>,
storeAllowFrom: string[],
) => {
const msg = primaryCtx.message;
const chatId = msg.chat.id;
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
const effectiveDmAllow = normalizeAllowFrom([
...(allowFrom ?? []),
...storeAllowFrom,
]);
const effectiveGroupAllow = normalizeAllowFrom([
...(groupAllowFrom ?? []),
...storeAllowFrom,
]);
const sendTyping = async () => {
try {
@@ -192,14 +204,70 @@ export function createTelegramBot(opts: TelegramBotOptions) {
}
};
// allowFrom for direct chats
if (!isGroup && dmAllow.hasEntries) {
const candidate = String(chatId);
if (!isSenderAllowed({ allow: dmAllow, senderId: candidate })) {
logVerbose(
`Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`,
);
return;
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled"
if (!isGroup) {
if (dmPolicy === "disabled") return;
if (dmPolicy !== "open") {
const candidate = String(chatId);
const senderUsername = msg.from?.username ?? "";
const allowed =
effectiveDmAllow.hasWildcard ||
(effectiveDmAllow.hasEntries &&
isSenderAllowed({
allow: effectiveDmAllow,
senderId: candidate,
senderUsername,
}));
if (!allowed) {
if (dmPolicy === "pairing") {
try {
const from = msg.from as
| {
first_name?: string;
last_name?: string;
username?: string;
}
| undefined;
const { code } = await upsertTelegramPairingRequest({
chatId: candidate,
username: from?.username,
firstName: from?.first_name,
lastName: from?.last_name,
});
logger.info(
{
chatId: candidate,
username: from?.username,
firstName: from?.first_name,
lastName: from?.last_name,
code,
},
"telegram pairing request",
);
await bot.api.sendMessage(
chatId,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot telegram pairing approve <code>",
].join("\n"),
);
} catch (err) {
logVerbose(
`telegram pairing reply failed for chat ${chatId}: ${String(err)}`,
);
}
} else {
logVerbose(
`Blocked unauthorized telegram sender ${candidate} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
}
@@ -207,7 +275,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
const commandAuthorized = isSenderAllowed({
allow: isGroup ? groupAllow : dmAllow,
allow: isGroup ? effectiveGroupAllow : effectiveDmAllow,
senderId,
senderUsername,
});
@@ -407,6 +475,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const chatId = msg.chat.id;
const isGroup =
msg.chat.type === "group" || msg.chat.type === "supergroup";
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
if (isGroup) {
// Group policy filtering: controls how group messages are handled
@@ -419,6 +488,10 @@ export function createTelegramBot(opts: TelegramBotOptions) {
return;
}
if (groupPolicy === "allowlist") {
const effectiveGroupAllow = normalizeAllowFrom([
...(groupAllowFrom ?? []),
...storeAllowFrom,
]);
// For allowlist mode, the sender (msg.from.id) must be in allowFrom
const senderId = msg.from?.id;
if (senderId == null) {
@@ -427,7 +500,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
);
return;
}
if (!groupAllow.hasEntries) {
if (!effectiveGroupAllow.hasEntries) {
logVerbose(
"Blocked telegram group message (groupPolicy: allowlist, no groupAllowFrom)",
);
@@ -436,7 +509,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const senderUsername = msg.from?.username ?? "";
if (
!isSenderAllowed({
allow: groupAllow,
allow: effectiveGroupAllow,
senderId: String(senderId),
senderUsername,
})
@@ -510,7 +583,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const allMedia = media
? [{ path: media.path, contentType: media.contentType }]
: [];
await processMessage(ctx, allMedia);
await processMessage(ctx, allMedia, storeAllowFrom);
} catch (err) {
runtime.error?.(danger(`handler failed: ${String(err)}`));
}
@@ -538,7 +611,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
}
}
await processMessage(primaryEntry.ctx, allMedia);
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom);
} catch (err) {
runtime.error?.(danger(`media group handler failed: ${String(err)}`));
}

View File

@@ -0,0 +1,51 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
approveTelegramPairingCode,
listTelegramPairingRequests,
readTelegramAllowFromStore,
upsertTelegramPairingRequest,
} from "./pairing-store.js";
async function withTempStateDir<T>(fn: (stateDir: string) => Promise<T>) {
const previous = process.env.CLAWDBOT_STATE_DIR;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-pairing-"));
process.env.CLAWDBOT_STATE_DIR = dir;
try {
return await fn(dir);
} finally {
if (previous === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previous;
await fs.rm(dir, { recursive: true, force: true });
}
}
describe("telegram pairing store", () => {
it("creates pairing request and approves it into allow store", async () => {
await withTempStateDir(async () => {
const created = await upsertTelegramPairingRequest({
chatId: "123456789",
username: "ada",
});
expect(created.code).toBeTruthy();
const list = await listTelegramPairingRequests();
expect(list).toHaveLength(1);
expect(list[0]?.chatId).toBe("123456789");
expect(list[0]?.code).toBe(created.code);
const approved = await approveTelegramPairingCode({ code: created.code });
expect(approved?.chatId).toBe("123456789");
const listAfter = await listTelegramPairingRequests();
expect(listAfter).toHaveLength(0);
const allow = await readTelegramAllowFromStore();
expect(allow).toContain("123456789");
});
});
});

View File

@@ -0,0 +1,122 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
addProviderAllowFromStoreEntry,
approveProviderPairingCode,
listProviderPairingRequests,
readProviderAllowFromStore,
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
export type TelegramPairingListEntry = {
chatId: string;
username?: string;
firstName?: string;
lastName?: string;
code: string;
createdAt: string;
lastSeenAt: string;
};
const PROVIDER = "telegram" as const;
export async function readTelegramAllowFromStore(
env: NodeJS.ProcessEnv = process.env,
): Promise<string[]> {
return readProviderAllowFromStore(PROVIDER, env);
}
export async function addTelegramAllowFromStoreEntry(params: {
entry: string | number;
env?: NodeJS.ProcessEnv;
}): Promise<{ changed: boolean; allowFrom: string[] }> {
return addProviderAllowFromStoreEntry({
provider: PROVIDER,
entry: params.entry,
env: params.env,
});
}
export async function listTelegramPairingRequests(
env: NodeJS.ProcessEnv = process.env,
): Promise<TelegramPairingListEntry[]> {
const list = await listProviderPairingRequests(PROVIDER, env);
return list.map((r) => ({
chatId: r.id,
code: r.code,
createdAt: r.createdAt,
lastSeenAt: r.lastSeenAt,
username: r.meta?.username,
firstName: r.meta?.firstName,
lastName: r.meta?.lastName,
}));
}
export async function upsertTelegramPairingRequest(params: {
chatId: string | number;
username?: string;
firstName?: string;
lastName?: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ code: string; created: boolean }> {
return upsertProviderPairingRequest({
provider: PROVIDER,
id: String(params.chatId),
env: params.env,
meta: {
username: params.username,
firstName: params.firstName,
lastName: params.lastName,
},
});
}
export async function approveTelegramPairingCode(params: {
code: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ chatId: string; entry?: TelegramPairingListEntry } | null> {
const res = await approveProviderPairingCode({
provider: PROVIDER,
code: params.code,
env: params.env,
});
if (!res) return null;
const entry = res.entry
? {
chatId: res.entry.id,
code: res.entry.code,
createdAt: res.entry.createdAt,
lastSeenAt: res.entry.lastSeenAt,
username: res.entry.meta?.username,
firstName: res.entry.meta?.firstName,
lastName: res.entry.meta?.lastName,
}
: undefined;
return { chatId: res.id, entry };
}
export async function resolveTelegramEffectiveAllowFrom(params: {
cfg: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
}): Promise<{ dm: string[]; group: string[] }> {
const env = params.env ?? process.env;
const cfgAllowFrom = (params.cfg.telegram?.allowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean)
.map((v) => v.replace(/^(telegram|tg):/i, ""))
.filter((v) => v !== "*");
const cfgGroupAllowFrom = (params.cfg.telegram?.groupAllowFrom ?? [])
.map((v) => String(v).trim())
.filter(Boolean)
.map((v) => v.replace(/^(telegram|tg):/i, ""))
.filter((v) => v !== "*");
const storeAllowFrom = await readTelegramAllowFromStore(env);
const dm = Array.from(new Set([...cfgAllowFrom, ...storeAllowFrom]));
const group = Array.from(
new Set([
...(cfgGroupAllowFrom.length > 0 ? cfgGroupAllowFrom : cfgAllowFrom),
...storeAllowFrom,
]),
);
return { dm, group };
}

View File

@@ -5,6 +5,11 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
const upsertPairingRequestMock = vi
.fn()
.mockResolvedValue({ code: "PAIRCODE", created: true });
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
@@ -21,6 +26,13 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: (...args: unknown[]) =>
readAllowFromStoreMock(...args),
upsertProviderPairingRequest: (...args: unknown[]) =>
upsertPairingRequestMock(...args),
}));
const HOME = path.join(
os.tmpdir(),
`clawdbot-inbound-media-${crypto.randomUUID()}`,

View File

@@ -16,6 +16,10 @@ import { loadConfig } from "../config/config.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger, getChildLogger } from "../logging.js";
import { saveMediaBuffer } from "../media/store.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
import {
formatLocationText,
type NormalizedLocation,
@@ -168,16 +172,14 @@ export async function monitorWebInbox(options: {
// Filter unauthorized senders early to prevent wasted processing
// and potential session corruption from Bad MAC errors
const cfg = loadConfig();
const dmPolicy = cfg.whatsapp?.dmPolicy ?? "pairing";
const configuredAllowFrom = cfg.whatsapp?.allowFrom;
// Without user config, default to self-only DM access so the owner can talk to themselves
const defaultAllowFrom =
(!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164
? [selfE164]
: undefined;
const allowFrom =
configuredAllowFrom && configuredAllowFrom.length > 0
? configuredAllowFrom
: defaultAllowFrom;
const storeAllowFrom = await readProviderAllowFromStore("whatsapp").catch(
() => [],
);
const allowFrom = Array.from(
new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]),
);
const groupAllowFrom =
cfg.whatsapp?.groupAllowFrom ??
(configuredAllowFrom && configuredAllowFrom.length > 0
@@ -227,17 +229,54 @@ export async function monitorWebInbox(options: {
}
}
// DM allowlist filtering (unchanged behavior)
const allowlistEnabled =
!group && Array.isArray(allowFrom) && allowFrom.length > 0;
if (!isSamePhone && allowlistEnabled) {
const candidate = from;
if (!dmHasWildcard && !normalizedAllowFrom.includes(candidate)) {
logVerbose(
`Blocked unauthorized sender ${candidate} (not in allowFrom list)`,
);
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled"
if (!group) {
if (dmPolicy === "disabled") {
logVerbose("Blocked dm (dmPolicy: disabled)");
continue;
}
if (dmPolicy !== "open" && !isSamePhone) {
const candidate = from;
const allowed =
dmHasWildcard ||
(normalizedAllowFrom.length > 0 &&
normalizedAllowFrom.includes(candidate));
if (!allowed) {
if (dmPolicy === "pairing") {
const { code } = await upsertProviderPairingRequest({
provider: "whatsapp",
id: candidate,
meta: {
name: (msg.pushName ?? "").trim() || undefined,
},
});
logVerbose(
`whatsapp pairing request sender=${candidate} name=${msg.pushName ?? "unknown"} code=${code}`,
);
try {
await sock.sendMessage(remoteJid, {
text: [
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider whatsapp <code>",
].join("\n"),
});
} catch (err) {
logVerbose(
`whatsapp pairing reply failed for ${candidate}: ${String(err)}`,
);
}
} else {
logVerbose(
`Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`,
);
}
continue;
}
}
}
if (id && !isSelfChat) {

View File

@@ -19,6 +19,11 @@ const mockLoadConfig = vi.fn().mockReturnValue({
},
});
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
const upsertPairingRequestMock = vi
.fn()
.mockResolvedValue({ code: "PAIRCODE", created: true });
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
@@ -27,6 +32,13 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: (...args: unknown[]) =>
readAllowFromStoreMock(...args),
upsertProviderPairingRequest: (...args: unknown[]) =>
upsertPairingRequestMock(...args),
}));
vi.mock("./session.js", () => {
const { EventEmitter } = require("node:events");
const ev = new EventEmitter();
@@ -64,6 +76,11 @@ import { monitorWebInbox } from "./inbound.js";
describe("web monitor inbox", () => {
beforeEach(() => {
vi.clearAllMocks();
readAllowFromStoreMock.mockResolvedValue([]);
upsertPairingRequestMock.mockResolvedValue({
code: "PAIRCODE",
created: true,
});
});
afterEach(() => {
@@ -564,6 +581,10 @@ describe("web monitor inbox", () => {
expect(onMessage).not.toHaveBeenCalled();
// Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn).
expect(sock.readMessages).not.toHaveBeenCalled();
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
text: expect.stringContaining("Pairing code: PAIRCODE"),
});
// Reset mock for other tests
mockLoadConfig.mockReturnValue({
@@ -980,105 +1001,118 @@ describe("web monitor inbox", () => {
await listener.close();
});
});
it("defaults to self-only when no config is present", async () => {
// No config file => allowFrom should be derived from selfE164
mockLoadConfig.mockReturnValue({});
const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = await createWaSocket();
it("locks down when no config is present (pairing for unknown senders)", async () => {
// No config file => locked-down defaults apply (pairing for unknown senders)
mockLoadConfig.mockReturnValue({});
// Message from someone else should be blocked
const upsertBlocked = {
type: "notify",
messages: [
{
key: {
id: "no-config-1",
fromMe: false,
remoteJid: "999@s.whatsapp.net",
const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = await createWaSocket();
// Message from someone else should be blocked
const upsertBlocked = {
type: "notify",
messages: [
{
key: {
id: "no-config-1",
fromMe: false,
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: "ping" },
messageTimestamp: 1_700_000_000,
},
message: { conversation: "ping" },
messageTimestamp: 1_700_000_000,
},
],
};
],
};
sock.ev.emit("messages.upsert", upsertBlocked);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).not.toHaveBeenCalled();
sock.ev.emit("messages.upsert", upsertBlocked);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).not.toHaveBeenCalled();
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
text: expect.stringContaining("Pairing code: PAIRCODE"),
});
// Message from self should be allowed
const upsertSelf = {
type: "notify",
messages: [
{
key: {
id: "no-config-2",
fromMe: false,
remoteJid: "123@s.whatsapp.net",
// Message from self should be allowed
const upsertSelf = {
type: "notify",
messages: [
{
key: {
id: "no-config-2",
fromMe: false,
remoteJid: "123@s.whatsapp.net",
},
message: { conversation: "self ping" },
messageTimestamp: 1_700_000_001,
},
message: { conversation: "self ping" },
messageTimestamp: 1_700_000_001,
],
};
sock.ev.emit("messages.upsert", upsertSelf);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).toHaveBeenCalledTimes(1);
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({
body: "self ping",
from: "+123",
to: "+123",
}),
);
// Reset mock for other tests
mockLoadConfig.mockReturnValue({
whatsapp: {
allowFrom: ["*"],
},
],
};
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
});
sock.ev.emit("messages.upsert", upsertSelf);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).toHaveBeenCalledTimes(1);
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({ body: "self ping", from: "+123", to: "+123" }),
);
// Reset mock for other tests
mockLoadConfig.mockReturnValue({
whatsapp: {
allowFrom: ["*"],
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
await listener.close();
});
await listener.close();
});
it("handles append messages by marking them read but skipping auto-reply", async () => {
const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = await createWaSocket();
it("handles append messages by marking them read but skipping auto-reply", async () => {
const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = await createWaSocket();
const upsert = {
type: "append",
messages: [
{
key: {
id: "history1",
fromMe: false,
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: "old message" },
messageTimestamp: 1_700_000_000,
pushName: "History Sender",
},
],
};
const upsert = {
type: "append",
messages: [
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
// Verify it WAS marked as read
expect(sock.readMessages).toHaveBeenCalledWith([
{
key: { id: "history1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "old message" },
messageTimestamp: 1_700_000_000,
pushName: "History Sender",
remoteJid: "999@s.whatsapp.net",
id: "history1",
participant: undefined,
fromMe: false,
},
],
};
]);
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
// Verify it WAS NOT passed to onMessage
expect(onMessage).not.toHaveBeenCalled();
// Verify it WAS marked as read
expect(sock.readMessages).toHaveBeenCalledWith([
{
remoteJid: "999@s.whatsapp.net",
id: "history1",
participant: undefined,
fromMe: false,
},
]);
// Verify it WAS NOT passed to onMessage
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
await listener.close();
});
});