{"repo":"NousResearch/hermes-agent","public_id":"2991","display_label":"WhatsApp LID-to-phone alias lookup corrections","summary":"Multiple issues and corrections addressing WhatsApp LID (Linked ID) not being mapped to phone numbers in gateway authorization, allowlist matching, and session directory lookups on modern platform layouts.","subsystem":"gateway","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3830},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-28T09:05:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285712},"fix_edges":[{"issue_number":2991,"pr_number":3830,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":36664,"pr_number":54083,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":2991,"title":"[WhatsApp] Gateway does not resolve LID to phone number for user authorization","url":null,"state":"closed"},{"kind":"issue","number":36664,"title":"[Bug]: WhatsApp LID sender silently dropped — alias resolver reads wrong session dir on modern platforms/ layout","url":null,"state":"closed"},{"kind":"pr","number":3097,"title":"fix(whatsapp): resolve LID to phone number in gateway authorization","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3e2c8c529","number":0},{"kind":"commit","value":"3e2c8c529bfeb6ac530bcff1884ce5dc11162e2d","number":0},{"kind":"commit","value":"62c14d551","number":0},{"kind":"commit","value":"62c14d5513469e27474fc9535fcdd4afa016646f","number":0},{"kind":"pr","value":"#3830","number":3830}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":3830,"title":"fix(whatsapp): resolve LID↔phone aliases in allowlist matching","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/whatsapp-bridge/allowlist.js","scripts/whatsapp-bridge/allowlist.test.mjs","scripts/whatsapp-bridge/bridge.js","tests/gateway/test_unauthorized_dm_behavior.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -225,6 +225,49 @@ def _ensure_ssl_certs() -> None:\n from gateway.delivery import DeliveryRouter\n from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType\n \n+\n+def _normalize_whatsapp_identifier(value: str) -> str:\n+    \"\"\"Strip WhatsApp JID/LID syntax down to its stable numeric identifier.\"\"\"\n+    return (\n+        str(value or \"\")\n+        .strip()\n+        .replace(\"+\", \"\", 1)\n+        .split(\":\", 1)[0]\n+        .split(\"@\", 1)[0]\n+    )\n+\n+\n+def _expand_whatsapp_auth_aliases(identifier: str) -> set:\n+    \"\"\"Resolve WhatsApp phone/LID aliases using bridge session mapping files.\"\"\"\n+    normalized = _normalize_whatsapp_identifier(identifier)\n+    if not normalized:\n+        return set()\n+\n+    session_dir = _hermes_home / \"whatsapp\" / \"session\"\n+    resolved = set()\n+    queue = [normalized]\n+\n+    while queue:\n+        current = queue.pop(0)\n+        if not current or current in resolved:\n+            continue\n+\n+        resolved.add(current)\n+        for suffix in (\"\", \"_reverse\"):\n+            mapping_path = session_dir / f\"lid-mapping-{current}{suffix}.json\"\n+            if not mapping_path.exists():\n+                continue\n+            try:\n+                mapped = _normalize_whatsapp_identifier(\n+                    json.loads(mapping_path.read_text(encoding=\"utf-8\"))\n+                )\n+            except Exception:\n+                continue\n+            if mapped and mapped not in resolved:\n+                queue.append(mapped)\n+\n+    return resolved\n+\n logger = logging.getLogger(__name__)\n \n # Sentinel placed into _running_agents immediately when a session starts","@@ -1539,10 +1582,23 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n         if global_allowlist:\n             allowed_ids.update(uid.strip() for uid in global_allowlist.split(\",\") if uid.strip())\n \n-        # WhatsApp JIDs have @s.whatsapp.net suffix — strip it for comparison\n         check_ids = {user_id}\n         if \"@\" in user_id:\n             check_ids.add(user_id.split(\"@\")[0])\n+\n+        # WhatsApp: resolve phone↔LID aliases from bridge session mapping files\n+        if source.platform == Platform.WHATSAPP:\n+            normalized_allowed_ids = set()\n+            for allowed_id in allowed_ids:\n+                normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))\n+            if normalized_allowed_ids:\n+                allowed_ids = normalized_allowed_ids\n+\n+            check_ids.update(_expand_whatsapp_auth_aliases(user_id))\n+            normalized_user_id = _normalize_whatsapp_identifier(user_id)\n+            if normalized_user_id:\n+                check_ids.add(normalized_user_id)\n+\n         return bool(check_ids & allowed_ids)\n \n     def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:"]},{"file":"scripts/whatsapp-bridge/allowlist.js","hunks":["@@ -0,0 +1,79 @@\n+import path from 'path';\n+import { existsSync, readFileSync } from 'fs';\n+\n+export function normalizeWhatsAppIdentifier(value) {\n+  return String(value || '')\n+    .trim()\n+    .replace(/:.*@/, '@')\n+    .replace(/@.*/, '')\n+    .replace(/^\\+/, '');\n+}\n+\n+export function parseAllowedUsers(rawValue) {\n+  return new Set(\n+    String(rawValue || '')\n+      .split(',')\n+      .map((value) => normalizeWhatsAppIdentifier(value))\n+      .filter(Boolean)\n+  );\n+}\n+\n+function readMappingFile(sessionDir, identifier, suffix = '') {\n+  const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);\n+  if (!existsSync(filePath)) {\n+    return null;\n+  }\n+\n+  try {\n+    const parsed = JSON.parse(readFileSync(filePath, 'utf8'));\n+    const normalized = normalizeWhatsAppIdentifier(parsed);\n+    return normalized || null;\n+  } catch {\n+    return null;\n+  }\n+}\n+\n+export function expandWhatsAppIdentifiers(identifier, sessionDir) {\n+  const normalized = normalizeWhatsAppIdentifier(identifier);\n+  if (!normalized) {\n+    return new Set();\n+  }\n+\n+  // Walk both phone->LID and LID->phone mapping files so allowlists can use\n+  // either form transparently in bot mode.\n+  const resolved = new Set();\n+  const queue = [normalized];\n+\n+  while (queue.length > 0) {\n+    const current = queue.shift();\n+    if (!current || resolved.has(current)) {\n+      continue;\n+    }\n+\n+    resolved.add(current);\n+\n+    for (const suffix of ['', '_reverse']) {\n+      const mapped = readMappingFile(sessionDir, current, suffix);\n+      if (mapped && !resolved.has(mapped)) {\n+        queue.push(mapped);\n+      }\n+    }\n+  }\n+\n+  return resolved;\n+}\n+\n+export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {\n+  if (!allowedUsers || allowedUsers.size === 0) {\n+    return true;\n+  }\n+\n+  const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);\n+  for (const alias of aliases) {\n+    if (allowedUsers.has(alias)) {\n+      return true;\n+    }\n+  }\n+\n+  return false;\n+}"]},{"file":"scripts/whatsapp-bridge/allowlist.test.mjs","hunks":["@@ -0,0 +1,47 @@\n+import test from 'node:test';\n+import assert from 'node:assert/strict';\n+import os from 'node:os';\n+import path from 'node:path';\n+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';\n+\n+import {\n+  expandWhatsAppIdentifiers,\n+  matchesAllowedUser,\n+  normalizeWhatsAppIdentifier,\n+  parseAllowedUsers,\n+} from './allowlist.js';\n+\n+test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => {\n+  assert.equal(normalizeWhatsAppIdentifier('[redacted:email]'), '19175395595');\n+  assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914');\n+  assert.equal(normalizeWhatsAppIdentifier('19175395595:[redacted:email]'), '19175395595');\n+});\n+\n+test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => {\n+  const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));\n+\n+  try {\n+    writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));\n+    writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));\n+\n+    const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir);\n+    assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']);\n+  } finally {\n+    rmSync(sessionDir, { recursive: true, force: true });\n+  }\n+});\n+\n+test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => {\n+  const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));\n+\n+  try {\n+    writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));\n+    writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));\n+\n+    const allowedUsers = parseAllowedUsers('+19175395595');\n+    assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);\n+    assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false);\n+  } finally {\n+    rmSync(sessionDir, { recursive: true, force: true });\n+  }\n+});"]},{"file":"scripts/whatsapp-bridge/bridge.js","hunks":["@@ -26,6 +26,7 @@ import path from 'path';\n import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';\n import { randomBytes } from 'crypto';\n import qrcode from 'qrcode-terminal';\n+import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';\n \n // Parse CLI args\n const args = process.argv.slice(2);","@@ -47,7 +48,7 @@ const DOCUMENT_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'docume\n const AUDIO_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'audio_cache');\n const PAIR_ONLY = args.includes('--pair-only');\n const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // \"bot\" or \"self-chat\"\n-const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean);\n+const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');\n const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\\n────────────\\n';\n const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined\n   ? DEFAULT_REPLY_PREFIX","@@ -190,10 +191,9 @@ async function startSocket() {\n         if (!isSelfChat) continue;\n       }\n \n-      // Check allowlist for messages from others (resolve LID → phone if needed)\n-      if (!msg.key.fromMe && ALLOWED_USERS.length > 0) {\n-        const resolvedNumber = lidToPhone[senderNumber] || senderNumber;\n-        if (!ALLOWED_USERS.includes(resolvedNumber)) continue;\n+      // Check allowlist for messages from others (resolve LID ↔ phone aliases)\n+      if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {\n+        continue;\n       }\n \n       // Extract message body","@@ -515,8 +515,8 @@ if (PAIR_ONLY) {\n   app.listen(PORT, '127.0.0.1', () => {\n     console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);\n     console.log(`📁 Session stored in: ${SESSION_DIR}`);\n-    if (ALLOWED_USERS.length > 0) {\n-      console.log(`🔒 Allowed users: ${ALLOWED_USERS.join(', ')}`);\n+    if (ALLOWED_USERS.size > 0) {\n+      console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);\n     } else {\n       console.log(`⚠️  No WHATSAPP_ALLOWED_USERS set — all messages will be processed`);\n     }"]},{"file":"tests/gateway/test_unauthorized_dm_behavior.py","hunks":["@@ -3,6 +3,7 @@\n \n import pytest\n \n+import gateway.run as gateway_run\n from gateway.config import GatewayConfig, Platform, PlatformConfig\n from gateway.platforms.base import MessageEvent\n from gateway.session import SessionSource","@@ -62,6 +63,32 @@ def _make_runner(platform: Platform, config: GatewayConfig):\n     return runner, adapter\n \n \n+def test_whatsapp_lid_user_matches_phone_allowlist_via_session_mapping(monkeypatch, tmp_path):\n+    _clear_auth_env(monkeypatch)\n+    monkeypatch.setenv(\"WHATSAPP_ALLOWED_USERS\", \"15550000001\")\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+\n+    session_dir = tmp_path / \"whatsapp\" / \"session\"\n+    session_dir.mkdir(parents=True)\n+    (session_dir / \"lid-mapping-15550000001.json\").write_text('\"900000000000001\"', encoding=\"utf-8\")\n+    (session_dir / \"lid-mapping-900000000000001_reverse.json\").write_text('\"15550000001\"', encoding=\"utf-8\")\n+\n+    runner, _adapter = _make_runner(\n+        Platform.WHATSAPP,\n+        GatewayConfig(platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}),\n+    )\n+\n+    source = SessionSource(\n+        platform=Platform.WHATSAPP,\n+        user_id=\"900000000000001@lid\",\n+        chat_id=\"900000000000001@lid\",\n+        user_name=\"tester\",\n+        chat_type=\"dm\",\n+    )\n+\n+    assert runner._is_user_authorized(source) is True\n+\n+\n @pytest.mark.asyncio\n async def test_unauthorized_dm_pairs_by_default(monkeypatch):\n     _clear_auth_env(monkeypatch)\n"]}]},{"kind":"pr","number":36665,"title":"fix(whatsapp): resolve LID aliases on modern platforms/ session layout","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"263ffec1b","number":0},{"kind":"pr","value":"#54083","number":54083}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":38712,"title":"fix(gateway): use get_hermes_dir for WhatsApp LID session path (#36664)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36664","number":36664},{"kind":"pr","value":"#54083","number":54083},{"kind":"pr","value":"#36665","number":36665}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":39823,"title":"fix(gateway): read WhatsApp LID mappings from modern session dir","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36665","number":36665},{"kind":"pr","value":"#38712","number":38712},{"kind":"pr","value":"#36665","number":36665}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":54083,"title":"fix(whatsapp): resolve LID aliases on modern platforms/ session layout (#36664)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/whatsapp_identity.py","infographic/whatsapp-lid-session-fix/infographic.png","tests/gateway/test_unauthorized_dm_behavior.py","tests/gateway/test_whatsapp_identity.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}