diff --git a/CHANGELOG.md b/CHANGELOG.md index a88c16698..e867c310a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Telegram: retry long-polling conflicts with backoff to avoid fatal exits. - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos - WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415) +- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist - Agent system prompt: avoid automatic self-updates unless explicitly requested. - Onboarding: tighten QuickStart hint copy for configuring later. - Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic. diff --git a/README.md b/README.md index bca2c6712..c9f19ce2e 100644 --- a/README.md +++ b/README.md @@ -460,10 +460,10 @@ Thanks to all clawtributors: claude scald andranik-sahakyan nachx639 sircrumpet rafaelreis-r meaningfool ratulsarna lutr0 abhisekbasu1 emanuelst osolmaz kiranjd thewilloftheshadow CashWilliams manuelhettich minghinmatthewlam buddyh sheeek timkrase mcinteerj azade-c imfing petter-b RandyVentures jalehman obviyus Yurii Chukhlib dan-dr iamadig - manmal VACInc zats Django Navarro pcty-nextgen-service-account Syhids fcatuhe jayhickey jverdi mitschabaude-bot - oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC alejandro maza antons Asleep123 cash-echo-bot Clawd - conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna Jarvis jonasjancarik Jonathan D. Rhyne (DJ-D) Keith the Silly Goose Kit - kitze kkarimi loukotal mrdbstn MSch nexty5870 ngutman onutc prathamdby reeltimeapps - RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo Erik Manuel Maly Mourad Boustani - pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock + manmal VACInc zats Django Navarro pcty-nextgen-service-account Syhids erik-agens fcatuhe jayhickey jverdi + mitschabaude-bot oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC alejandro maza antons Asleep123 cash-echo-bot + Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna Jarvis jonasjancarik Jonathan D. Rhyne (DJ-D) Keith the Silly Goose + Kit kitze kkarimi loukotal mrdbstn MSch nexty5870 ngutman onutc prathamdby + reeltimeapps RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo Erik Manuel Maly + Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock

diff --git a/docs/providers/signal.md b/docs/providers/signal.md index ff1ec8b9f..e3b0aeb99 100644 --- a/docs/providers/signal.md +++ b/docs/providers/signal.md @@ -49,6 +49,7 @@ DMs: - `clawdbot pairing list --provider signal` - `clawdbot pairing approve --provider signal ` - Pairing is the default token exchange for Signal DMs. Details: [Pairing](/start/pairing) +- UUID-only senders (from `sourceUuid`) are stored as `uuid:` in `signal.allowFrom`. Groups: - `signal.groupPolicy = open | allowlist | disabled`. @@ -85,7 +86,7 @@ Provider options: - `signal.ignoreStories`: ignore stories from the daemon. - `signal.sendReadReceipts`: forward read receipts. - `signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). -- `signal.allowFrom`: DM allowlist (E.164). `open` requires `"*"`. +- `signal.allowFrom`: DM allowlist (E.164 or `uuid:`). `open` requires `"*"`. - `signal.groupPolicy`: `open | allowlist | disabled` (default: open). - `signal.groupAllowFrom`: group sender allowlist. - `signal.textChunkLimit`: outbound chunk size (chars). diff --git a/src/signal/identity.ts b/src/signal/identity.ts new file mode 100644 index 000000000..3cae59c34 --- /dev/null +++ b/src/signal/identity.ts @@ -0,0 +1,114 @@ +import { normalizeE164 } from "../utils.js"; + +export type SignalSender = + | { kind: "phone"; raw: string; e164: string } + | { kind: "uuid"; raw: string }; + +type SignalAllowEntry = + | { kind: "any" } + | { kind: "phone"; e164: string } + | { kind: "uuid"; raw: string }; + +const UUID_HYPHENATED_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const UUID_COMPACT_RE = /^[0-9a-f]{32}$/i; + +function looksLikeUuid(value: string): boolean { + if (UUID_HYPHENATED_RE.test(value) || UUID_COMPACT_RE.test(value)) { + return true; + } + const compact = value.replace(/-/g, ""); + if (!/^[0-9a-f]+$/i.test(compact)) return false; + return /[a-f]/i.test(compact); +} + +function stripSignalPrefix(value: string): string { + return value.replace(/^signal:/i, "").trim(); +} + +export function resolveSignalSender(params: { + sourceNumber?: string | null; + sourceUuid?: string | null; +}): SignalSender | null { + const sourceNumber = params.sourceNumber?.trim(); + if (sourceNumber) { + return { + kind: "phone", + raw: sourceNumber, + e164: normalizeE164(sourceNumber), + }; + } + const sourceUuid = params.sourceUuid?.trim(); + if (sourceUuid) { + return { kind: "uuid", raw: sourceUuid }; + } + return null; +} + +export function formatSignalSenderId(sender: SignalSender): string { + return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`; +} + +export function formatSignalSenderDisplay(sender: SignalSender): string { + return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`; +} + +export function resolveSignalRecipient(sender: SignalSender): string { + return sender.kind === "phone" ? sender.e164 : sender.raw; +} + +export function resolveSignalPeerId(sender: SignalSender): string { + return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`; +} + +function parseSignalAllowEntry(entry: string): SignalAllowEntry | null { + const trimmed = entry.trim(); + if (!trimmed) return null; + if (trimmed === "*") return { kind: "any" }; + + const stripped = stripSignalPrefix(trimmed); + const lower = stripped.toLowerCase(); + if (lower.startsWith("uuid:")) { + const raw = stripped.slice("uuid:".length).trim(); + if (!raw) return null; + return { kind: "uuid", raw }; + } + + if (looksLikeUuid(stripped)) { + return { kind: "uuid", raw: stripped }; + } + + return { kind: "phone", e164: normalizeE164(stripped) }; +} + +export function isSignalSenderAllowed( + sender: SignalSender, + allowFrom: string[], +): boolean { + if (allowFrom.length === 0) return false; + const parsed = allowFrom + .map(parseSignalAllowEntry) + .filter((entry): entry is SignalAllowEntry => entry !== null); + if (parsed.some((entry) => entry.kind === "any")) return true; + return parsed.some((entry) => { + if (entry.kind === "phone" && sender.kind === "phone") { + return entry.e164 === sender.e164; + } + if (entry.kind === "uuid" && sender.kind === "uuid") { + return entry.raw === sender.raw; + } + return false; + }); +} + +export function isSignalGroupAllowed(params: { + groupPolicy: "open" | "disabled" | "allowlist"; + allowFrom: string[]; + sender: SignalSender; +}): boolean { + const { groupPolicy, allowFrom, sender } = params; + if (groupPolicy === "disabled") return false; + if (groupPolicy === "open") return true; + if (allowFrom.length === 0) return false; + return isSignalSenderAllowed(sender, allowFrom); +} diff --git a/src/signal/monitor.test.ts b/src/signal/monitor.test.ts index e99907922..0012166b4 100644 --- a/src/signal/monitor.test.ts +++ b/src/signal/monitor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isSignalGroupAllowed } from "./monitor.js"; +import { isSignalGroupAllowed } from "./identity.js"; describe("signal groupPolicy gating", () => { it("allows when policy is open", () => { @@ -8,7 +8,7 @@ describe("signal groupPolicy gating", () => { isSignalGroupAllowed({ groupPolicy: "open", allowFrom: [], - sender: "+15550001111", + sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" }, }), ).toBe(true); }); @@ -18,7 +18,7 @@ describe("signal groupPolicy gating", () => { isSignalGroupAllowed({ groupPolicy: "disabled", allowFrom: ["+15550001111"], - sender: "+15550001111", + sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" }, }), ).toBe(false); }); @@ -28,7 +28,7 @@ describe("signal groupPolicy gating", () => { isSignalGroupAllowed({ groupPolicy: "allowlist", allowFrom: [], - sender: "+15550001111", + sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" }, }), ).toBe(false); }); @@ -38,7 +38,7 @@ describe("signal groupPolicy gating", () => { isSignalGroupAllowed({ groupPolicy: "allowlist", allowFrom: ["+15550001111"], - sender: "+15550001111", + sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" }, }), ).toBe(true); }); @@ -48,7 +48,20 @@ describe("signal groupPolicy gating", () => { isSignalGroupAllowed({ groupPolicy: "allowlist", allowFrom: ["*"], - sender: "+15550002222", + sender: { kind: "phone", raw: "+15550002222", e164: "+15550002222" }, + }), + ).toBe(true); + }); + + it("allows allowlist when uuid sender matches", () => { + expect( + isSignalGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["uuid:123e4567-e89b-12d3-a456-426614174000"], + sender: { + kind: "uuid", + raw: "123e4567-e89b-12d3-a456-426614174000", + }, }), ).toBe(true); }); diff --git a/src/signal/monitor.tool-result.test.ts b/src/signal/monitor.tool-result.test.ts index 8e0be93ea..4748bd5ea 100644 --- a/src/signal/monitor.tool-result.test.ts +++ b/src/signal/monitor.tool-result.test.ts @@ -197,6 +197,48 @@ describe("monitorSignalProvider tool results", () => { expect(sendMock).toHaveBeenCalledTimes(1); }); + it("pairs uuid-only senders with a uuid allowlist entry", async () => { + config = { + ...config, + signal: { autoStart: false, dmPolicy: "pairing", allowFrom: [] }, + }; + const abortController = new AbortController(); + const uuid = "123e4567-e89b-12d3-a456-426614174000"; + + streamMock.mockImplementation(async ({ onEvent }) => { + const payload = { + envelope: { + sourceUuid: uuid, + sourceName: "Ada", + timestamp: 1, + dataMessage: { + message: "hello", + }, + }, + }; + await onEvent({ + event: "receive", + data: JSON.stringify(payload), + }); + abortController.abort(); + }); + + await monitorSignalProvider({ + autoStart: false, + baseUrl: "http://127.0.0.1:8080", + abortSignal: abortController.signal, + }); + + await flush(); + + expect(replyMock).not.toHaveBeenCalled(); + expect(upsertPairingRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ provider: "signal", id: `uuid:${uuid}` }), + ); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0]?.[0]).toBe(`signal:${uuid}`); + }); + it("reconnects after stream errors until aborted", async () => { vi.useFakeTimers(); const abortController = new AbortController(); diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 69f49dcc6..95a82120c 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -19,6 +19,14 @@ import { normalizeE164 } from "../utils.js"; import { resolveSignalAccount } from "./accounts.js"; import { signalCheck, signalRpcRequest } from "./client.js"; import { spawnSignalDaemon } from "./daemon.js"; +import { + formatSignalSenderDisplay, + formatSignalSenderId, + isSignalSenderAllowed, + resolveSignalPeerId, + resolveSignalRecipient, + resolveSignalSender, +} from "./identity.js"; import { sendMessageSignal } from "./send.js"; import { runSignalSseLoop } from "./sse-reconnect.js"; @@ -92,28 +100,6 @@ function normalizeAllowList(raw?: Array): string[] { return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean); } -function isAllowedSender(sender: string, allowFrom: string[]): boolean { - if (allowFrom.length === 0) return false; - if (allowFrom.includes("*")) return true; - const normalizedAllow = allowFrom - .map((entry) => entry.replace(/^signal:/i, "")) - .map((entry) => normalizeE164(entry)); - const normalizedSender = normalizeE164(sender); - return normalizedAllow.includes(normalizedSender); -} - -export function isSignalGroupAllowed(params: { - groupPolicy: "open" | "disabled" | "allowlist"; - allowFrom: string[]; - sender: string; -}): boolean { - const { groupPolicy, allowFrom, sender } = params; - if (groupPolicy === "disabled") return false; - if (groupPolicy === "open") return true; - if (allowFrom.length === 0) return false; - return isAllowedSender(sender, allowFrom); -} - async function waitForSignalDaemonReady(params: { baseUrl: string; abortSignal?: AbortSignal; @@ -320,11 +306,18 @@ export async function monitorSignalProvider( envelope.dataMessage ?? envelope.editMessage?.dataMessage; if (!dataMessage) return; - const sender = envelope.sourceNumber?.trim() || envelope.sourceUuid?.trim(); + const sender = resolveSignalSender(envelope); if (!sender) return; - if (account && envelope.sourceNumber && normalizeE164(envelope.sourceNumber) === normalizeE164(account)) { - return; + if (account && sender.kind === "phone") { + if (sender.e164 === normalizeE164(account)) { + return; + } } + const senderDisplay = formatSignalSenderDisplay(sender); + const senderRecipient = resolveSignalRecipient(sender); + const senderPeerId = resolveSignalPeerId(sender); + const senderAllowId = formatSignalSenderId(sender); + if (!senderRecipient) return; const groupId = dataMessage.groupInfo?.groupId ?? undefined; const groupName = dataMessage.groupInfo?.groupName ?? undefined; const isGroup = Boolean(groupId); @@ -334,13 +327,15 @@ export async function monitorSignalProvider( const effectiveDmAllow = [...allowFrom, ...storeAllowFrom]; const effectiveGroupAllow = [...groupAllowFrom, ...storeAllowFrom]; const dmAllowed = - dmPolicy === "open" ? true : isAllowedSender(sender, effectiveDmAllow); + dmPolicy === "open" + ? true + : isSignalSenderAllowed(sender, effectiveDmAllow); if (!isGroup) { if (dmPolicy === "disabled") return; if (!dmAllowed) { if (dmPolicy === "pairing") { - const senderId = normalizeE164(sender); + const senderId = senderAllowId; const { code, created } = await upsertProviderPairingRequest({ provider: "signal", id: senderId, @@ -352,7 +347,7 @@ export async function monitorSignalProvider( logVerbose(`signal pairing request sender=${senderId}`); try { await sendMessageSignal( - senderId, + `signal:${senderRecipient}`, [ "Clawdbot: access not configured.", "", @@ -376,7 +371,7 @@ export async function monitorSignalProvider( } } else { logVerbose( - `Blocked signal sender ${sender} (dmPolicy=${dmPolicy})`, + `Blocked signal sender ${senderDisplay} (dmPolicy=${dmPolicy})`, ); } return; @@ -393,9 +388,9 @@ export async function monitorSignalProvider( ); return; } - if (!isAllowedSender(sender, effectiveGroupAllow)) { + if (!isSignalSenderAllowed(sender, effectiveGroupAllow)) { logVerbose( - `Blocked signal group sender ${sender} (not in groupAllowFrom)`, + `Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`, ); return; } @@ -403,7 +398,7 @@ export async function monitorSignalProvider( const commandAuthorized = isGroup ? effectiveGroupAllow.length > 0 - ? isAllowedSender(sender, effectiveGroupAllow) + ? isSignalSenderAllowed(sender, effectiveGroupAllow) : true : dmAllowed; const messageText = (dataMessage.message ?? "").trim(); @@ -418,7 +413,7 @@ export async function monitorSignalProvider( baseUrl, account, attachment: firstAttachment, - sender, + sender: senderRecipient, groupId, maxBytes: mediaMaxBytes, }); @@ -445,7 +440,7 @@ export async function monitorSignalProvider( const fromLabel = isGroup ? `${groupName ?? "Signal Group"} id:${groupId}` - : `${envelope.sourceName ?? sender} id:${sender}`; + : `${envelope.sourceName ?? senderDisplay} id:${senderDisplay}`; const body = formatAgentEnvelope({ provider: "Signal", from: fromLabel, @@ -459,20 +454,24 @@ export async function monitorSignalProvider( accountId: accountInfo.accountId, peer: { kind: isGroup ? "group" : "dm", - id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender), + id: isGroup ? (groupId ?? "unknown") : senderPeerId, }, }); - const signalTo = isGroup ? `group:${groupId}` : `signal:${sender}`; + const signalTo = isGroup + ? `group:${groupId}` + : `signal:${senderRecipient}`; const ctxPayload = { Body: body, - From: isGroup ? `group:${groupId ?? "unknown"}` : `signal:${sender}`, + From: isGroup + ? `group:${groupId ?? "unknown"}` + : `signal:${senderRecipient}`, To: signalTo, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (groupName ?? undefined) : undefined, - SenderName: envelope.sourceName ?? sender, - SenderId: sender, + SenderName: envelope.sourceName ?? senderDisplay, + SenderId: senderDisplay, Provider: "signal" as const, Surface: "signal" as const, MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined, @@ -495,7 +494,7 @@ export async function monitorSignalProvider( storePath, sessionKey: route.mainSessionKey, provider: "signal", - to: normalizeE164(sender), + to: senderRecipient, accountId: route.accountId, }); }