refactor(signal): normalize sender identity

This commit is contained in:
Peter Steinberger
2026-01-08 23:06:38 +00:00
parent 9e9f2babeb
commit 7c7858a519
7 changed files with 223 additions and 53 deletions

114
src/signal/identity.ts Normal file
View File

@@ -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);
}

View File

@@ -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);
});

View File

@@ -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();

View File

@@ -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 | number>): 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,
});
}