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:
-
-
-
-
-
-
+
+
+
+
+
+
`
- 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,
});
}