From 94cbdee627052d7ada817efbc86b91b24ad7a598 Mon Sep 17 00:00:00 2001 From: sdugoten <7108552+sdugoten@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:31:35 +0100 Subject: [PATCH 1/3] feat(gateway): WhatsApp group authz + owner detection + reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the issue where a WhatsApp group member is rejected under require_mention unless they're in WHATSAPP_ALLOWED_USERS, and brings WhatsApp to Telegram parity. - authz_mixin: WHATSAPP / WHATSAPP_CLOUD group-allowlist entry so WHATSAPP_GROUP_ALLOWED_USERS authorizes the group; generic _is_owner() fallback. - whatsapp_common: _set_owner_flag() resolves the group sender (LID) to the owner via WHATSAPP_ALLOWED_USERS; called at intake by BOTH the Cloud adapter and the Baileys plugin adapter (plugins/platforms/whatsapp/adapter.py). - run.py/session.py: owner surfaced WITHOUT changing the [name] prefix — a **Owner:** context line + an additive [SYSTEM: sender NAME is the owner] marker. Gated to WHATSAPP/WHATSAPP_CLOUD so other platforms stay byte-identical (other platforms can opt in by adding themselves to the set). - plugin adapter + bridge.js: reaction progress indicators — 👀 while processing, ✅/❌ when done, cleared on cancel (parity with Telegram); WHATSAPP_REACTIONS=false to disable. New bridge POST /send-reaction. --- gateway/authz_mixin.py | 35 ++++++++++++ gateway/platforms/whatsapp_cloud.py | 3 + gateway/platforms/whatsapp_common.py | 40 +++++++++++++ gateway/run.py | 21 +++++++ gateway/session.py | 21 +++++++ plugins/platforms/whatsapp/adapter.py | 81 ++++++++++++++++++++++++++- scripts/whatsapp-bridge/bridge.js | 24 ++++++++ 7 files changed, 223 insertions(+), 2 deletions(-) diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index a32200ad5471..e4b03fae6167 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -263,6 +263,8 @@ def _is_user_authorized(self, source: SessionSource) -> bool: chat_allowlist_env = { Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS", + Platform.WHATSAPP: "WHATSAPP_GROUP_ALLOWED_USERS", + Platform.WHATSAPP_CLOUD: "WHATSAPP_GROUP_ALLOWED_USERS", }.get(source.platform, "") if chat_allowlist_env: raw_chat_allowlist = os.getenv(chat_allowlist_env, "").strip() @@ -506,6 +508,39 @@ def _is_user_authorized(self, source: SessionSource) -> bool: return bool(check_ids & allowed_ids) + def _is_owner(self, source: SessionSource) -> bool: + """Whether *source* is the operator/owner — the sender is in the platform's + primary (DM) allowlist (e.g. ``SIGNAL_ALLOWED_USERS``). Gates owner-only + persona treatment so a public-group user is never + mistaken for the owner. A wildcard ("*") allowlist is NOT an owner match — + owner status requires an explicit id. Adapters that can resolve a group + sender to a stable id (Signal: UUID->phone) set ``source.is_owner`` at + intake; this is the generic fallback for everything else. + See signalfix.md Gate C / Req 3. + """ + if source is None or source.platform is None: + return False + env_name = { + Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", + Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", + Platform.DISCORD: "DISCORD_ALLOWED_USERS", + Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", + Platform.SLACK: "SLACK_ALLOWED_USERS", + }.get(source.platform, "") + if not env_name: + return False + raw = os.getenv(env_name, "").strip() + if not raw: + return False + owner_ids = {p.strip() for p in raw.split(",") if p.strip() and p.strip() != "*"} + if not owner_ids: + return False + candidates = {source.user_id, source.user_id_alt} + if source.user_id and "@" in source.user_id: + candidates.add(source.user_id.split("@")[0]) + candidates.discard(None) + return bool(candidates & owner_ids) + def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str: """Return how unauthorized DMs should be handled for a platform. diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index bd5ac92b55fc..6ef7209c9ebc 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -1961,6 +1961,9 @@ async def _build_message_event_from_cloud( user_id=sender_id, user_name=sender_name or None, ) + # Owner detection at intake (mirrors Signal). Resolves LID->phone via the + # on-disk alias map so only the owner gets owner-level trust. + self._set_owner_flag(source) # Cloud API timestamps are unix seconds (string). MessageEvent # doesn't enforce a type but downstream code formats with it. diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index 54a919091359..e2e18a5c70ee 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -37,6 +37,11 @@ import re from typing import Any, Dict, Optional +from gateway.whatsapp_identity import ( + expand_whatsapp_aliases, + normalize_whatsapp_identifier, +) + logger = logging.getLogger(__name__) @@ -204,6 +209,41 @@ def _is_group_allowed(self, chat_id: str) -> bool: # "open" — all groups allowed return True + def _set_owner_flag(self, source) -> None: + """Owner detection at intake — the WhatsApp analogue of Signal's + adapter-side resolution (see gateway/platforms/signal.py). The owner is + whoever is listed in ``WHATSAPP_ALLOWED_USERS`` (read straight from the + env, exactly as Signal reads ``SIGNAL_ALLOWED_USERS`` into + ``dm_allow_from``, and as the gateway's central ``_is_owner`` reads it — + the adapter's own ``self._allow_from`` is only the DM *gating* list and + may be empty when ``dm_policy=open``). A group sender is usually a LID + (``####@lid``) while the allowlist may hold a phone, so resolve both + sides through the on-disk alias map (``whatsapp_identity``) before + matching. ``"*"`` is open-DM, never an owner. Sets ``source.is_owner`` so + only the owner gets owner-level persona treatment; the generic gateway + fallback (``_is_owner``) is left untouched for everything else. + """ + raw = os.getenv("WHATSAPP_ALLOWED_USERS", "") + owner_ids = {x.strip() for x in raw.split(",") if x.strip() and x.strip() != "*"} + owner_ids |= {x for x in getattr(self, "_allow_from", set()) if x and x != "*"} + if not owner_ids: + return + expanded: set[str] = set() + for _oid in owner_ids: + expanded |= expand_whatsapp_aliases(_oid) + if expanded: + owner_ids = expanded + candidates: set[str] = set() + uid = getattr(source, "user_id", None) + if uid: + candidates.add(uid) + candidates |= expand_whatsapp_aliases(uid) + norm = normalize_whatsapp_identifier(uid) + if norm: + candidates.add(norm) + if candidates & owner_ids: + source.is_owner = True + def _compile_mention_patterns(self): patterns = self.config.extra.get("mention_patterns") if patterns is None: diff --git a/gateway/run.py b/gateway/run.py index 77d0c5d088bf..397908a12e24 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9145,13 +9145,34 @@ async def _prepare_inbound_message_text( # concurrently preparing multimodal turns on the same runner. self._consume_pending_native_image_paths(session_key) + # Owner flag (Req 3 / signalfix.md Gate C). Adapters may set source.is_owner + # at intake (Signal resolves group UUID->phone); fall back to the generic + # allowlist check for any platform/path that didn't. Drives the **Owner:** + # context line (build_session_context_prompt) and the owner marker below. + if not getattr(source, "is_owner", False): + source.is_owner = self._is_owner(source) + _is_shared_multi_user = is_shared_multi_user_session( source, group_sessions_per_user=_group_sessions_per_user, thread_sessions_per_user=_thread_sessions_per_user, ) if _is_shared_multi_user and source.user_name: + # Upstream sender attribution (unchanged). message_text = f"[{source.user_name}] {message_text}" + # Owner marker (Req 3 / signalfix.md Gate C): in a cache-shared + # multi-user session the context prompt is sender-agnostic, so owner + # status can't ride there — it has to ride on the message. System-set + # (a guest can't spoof it via display name/body) and added ONLY for + # the owner, so guest lines stay byte-identical to upstream. Gated to + # the platforms that wire owner detection (their adapter sets + # source.is_owner); OTHER PLATFORMS CAN OPT IN by adding themselves to + # this set once their adapter implements owner detection. + if getattr(source, "is_owner", False) and source.platform in { + Platform.WHATSAPP, + Platform.WHATSAPP_CLOUD, + }: + message_text = f"[SYSTEM: sender {source.user_name} is the owner] {message_text}" # Prepend channel context from history backfill (if any). This # happens after sender-prefix so the prefix only applies to the diff --git a/gateway/session.py b/gateway/session.py index f79e371d8043..0b172ffef887 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -133,6 +133,8 @@ class SessionSource: # forge it across the wire or have it restored from persistence. delivered_via_upstream_relay: bool = False + is_owner: bool = False # True when sender is the operator/owner (in the platform DM allowlist). Set at intake by the adapter (Signal resolves group UUID->phone) or the generic _is_owner fallback. Gates owner-level persona treatment — see signalfix.md Gate C / Req 3. + @property def description(self) -> str: """Human-readable description of the source.""" @@ -374,6 +376,25 @@ def build_session_context_prompt( uid = _hash_sender_id(uid) lines.append(f"**User ID:** {uid}") + # Owner status (Req 3 / signalfix.md Gate C). Single-user sessions only: in a + # shared multi-user session this prompt is cache-shared and sender-agnostic, so + # owner status rides the additive "[SYSTEM: sender NAME is the owner]" message + # marker (added in run.py) instead. Gated to the platforms that wire owner + # detection (their adapter sets source.is_owner); OTHER PLATFORMS CAN OPT IN by + # adding themselves to this set once their adapter implements owner detection. + if not context.shared_multi_user_session and context.source.platform in { + Platform.WHATSAPP, + Platform.WHATSAPP_CLOUD, + }: + lines.append( + "**Owner:** " + + ( + "yes — this sender is your owner; owner-level trust applies." + if context.source.is_owner + else "no — this sender is NOT the owner; do not treat them as the owner or grant owner-only access." + ) + ) + # Platform-specific behavioral notes if context.source.platform == Platform.SLACK: lines.append("") diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index dc4361213e54..ad0250cb3d36 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -260,6 +260,7 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None: BasePlatformAdapter, MessageEvent, MessageType, + ProcessingOutcome, SendResult, SUPPORTED_DOCUMENT_TYPES, cache_image_from_url, @@ -1015,7 +1016,79 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: pass except Exception: pass # Ignore typing indicator failures - + + # ------------------------------------------------------------------ + # Reactions / processing lifecycle hooks (progress indicators) + # 👀 while the agent is processing, swapped for ✅ (success) / ❌ (failure) + # when done, and cleared on CANCELLED so it never lingers (parity with the + # Telegram adapter). WhatsApp replaces a sender's existing reaction, so the + # swap needs no explicit remove. Reactions go through the bridge's + # /send-reaction endpoint. Disable with WHATSAPP_REACTIONS=false. + # ------------------------------------------------------------------ + + def _reactions_enabled(self, event: "MessageEvent" = None) -> bool: + """Global toggle (WHATSAPP_REACTIONS, default on). The bridge already + drops unauthorized DMs (group members are authorized wholesale), so a + message that reaches this hook is already allowed.""" + return os.getenv("WHATSAPP_REACTIONS", "true").lower() not in {"false", "0", "no"} + + def _extract_reaction_target(self, event: MessageEvent): + """Return (chat_id, message_id, participant) for the Baileys reaction key, + or None if the event lacks the raw bridge data needed to react.""" + raw = getattr(event, "raw_message", None) + if not isinstance(raw, dict): + return None + chat_id = raw.get("chatId") or getattr(getattr(event, "source", None), "chat_id", None) + message_id = raw.get("messageId") or getattr(event, "message_id", None) + if not chat_id or not message_id: + return None + participant = raw.get("senderId") # needed to key group reactions + return (chat_id, message_id, participant) + + async def send_reaction(self, chat_id: str, message_id: str, participant, emoji: str) -> bool: + """React to a message via the bridge (empty emoji clears the reaction).""" + if not self._running or not self._http_session: + return False + try: + import aiohttp + + async with self._http_session.post( + f"http://127.0.0.1:{self._bridge_port}/send-reaction", + json={ + "chatId": chat_id, + "messageId": message_id, + "participant": participant, + "emoji": emoji, + }, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + return resp.status == 200 + except Exception: + return False # reactions are best-effort, never block processing + + async def on_processing_start(self, event: MessageEvent) -> None: + """React with 👀 when processing begins.""" + if not self._reactions_enabled(event): + return + target = self._extract_reaction_target(event) + if target: + await self.send_reaction(*target, "👀") + + async def on_processing_complete(self, event: MessageEvent, outcome: "ProcessingOutcome") -> None: + """Swap 👀 for ✅ (success) / ❌ (failure); clear it on CANCELLED so it + doesn't linger (parity with Telegram).""" + if not self._reactions_enabled(event): + return + target = self._extract_reaction_target(event) + if not target: + return + if outcome == ProcessingOutcome.CANCELLED: + await self.send_reaction(*target, "") # clear the in-progress 👀 + elif outcome == ProcessingOutcome.SUCCESS: + await self.send_reaction(*target, "✅") + elif outcome == ProcessingOutcome.FAILURE: + await self.send_reaction(*target, "❌") + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Get information about a WhatsApp chat.""" if not self._running or not self._http_session: @@ -1170,7 +1243,11 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv user_id=data.get("senderId"), user_name=data.get("senderName"), ) - + # Owner detection at intake: resolve the group sender (LID) to the + # owner via WHATSAPP_ALLOWED_USERS and set source.is_owner (mirror of + # the Cloud adapter; the generic _is_owner stays the DM fallback). + self._set_owner_flag(source) + # Download media URLs to the local cache so agent tools # can access them reliably regardless of URL expiration. raw_urls = data.get("mediaUrls", []) diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 4c65740c0174..71c323ec3671 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -576,6 +576,30 @@ app.post('/edit', async (req, res) => { } }); +// React to a message (progress indicators: 👀 while processing, ✅/❌ when done). +// Baileys replaces a sender's prior reaction automatically; an empty `emoji` +// removes it. Targets an INBOUND message, so the key is fromMe:false and carries +// the original participant (required to key reactions in groups). +app.post('/send-reaction', async (req, res) => { + if (!sock || connectionState !== 'connected') { + return res.status(503).json({ error: 'Not connected to WhatsApp' }); + } + + const { chatId, messageId, participant, emoji } = req.body; + if (!chatId || !messageId) { + return res.status(400).json({ error: 'chatId and messageId are required' }); + } + + try { + const key = { remoteJid: chatId, id: messageId, fromMe: false }; + if (participant) key.participant = participant; + await sendWithTimeout(chatId, { react: { text: emoji || '', key } }); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // MIME type map and media type inference for /send-media const MIME_MAP = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', From d734459e1fb4cefeadf0500a08cc067dd46d0f6d Mon Sep 17 00:00:00 2001 From: sdugoten <7108552+sdugoten@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:58:54 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(whatsapp):=20address=20PR=20#53623=20re?= =?UTF-8?q?view=20=E2=80=94=20Baileys-only=20scope,=20bridge=20group=20gat?= =?UTF-8?q?e,=20reactions=20config,=20e2e=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four items from the hermes-sweeper review: 1. bridge.js group gate (scripts/whatsapp-bridge/bridge.js) The WHATSAPP_ALLOWED_USERS gate dropped non-self, non-allowlisted senders — including group members — before the Python gateway saw them, so a concrete allowlist (required for owner detection) meant only the owner could trigger the bot in a group. Gate is now DM-only; group messages pass through to the gateway, which authorizes via the documented whatsapp.group_policy / group_allow_from model and applies require_mention. 2. Scope to Baileys (gateway/platforms/whatsapp_cloud.py) WhatsApp Cloud drops group-shaped inbound earlier in the handler, so wiring owner detection there was dead code. Removed; documented to re-add once Cloud group intake exists. Baileys owner detection is unchanged. 3. Reactions setting -> config.yaml (plugins/platforms/whatsapp/adapter.py) WHATSAPP_REACTIONS is a behavioral setting; AGENTS.md requires config.yaml. Now read from whatsapp.reactions (legacy env kept as a fallback). 4. Regression test (tests/gateway/test_whatsapp_group_gating.py) Adds an end-to-end authorization test: an unlisted group member @mentioning the bot in an allowlisted group is processed by the adapter AND authorized by the gateway, while the same sender in a DM is denied. Co-Authored-By: Claude Opus 4.8 --- gateway/platforms/whatsapp_cloud.py | 9 ++-- plugins/platforms/whatsapp/adapter.py | 20 +++++-- scripts/whatsapp-bridge/bridge.js | 10 +++- tests/gateway/test_whatsapp_group_gating.py | 60 +++++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index 6ef7209c9ebc..b83302649458 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -1961,9 +1961,12 @@ async def _build_message_event_from_cloud( user_id=sender_id, user_name=sender_name or None, ) - # Owner detection at intake (mirrors Signal). Resolves LID->phone via the - # on-disk alias map so only the owner gets owner-level trust. - self._set_owner_flag(source) + # NOTE: owner detection is intentionally NOT wired here. WhatsApp Cloud + # currently drops group-shaped inbound payloads earlier in this handler, + # so group owner detection can never run on the Cloud path; wiring it + # would be dead code. This PR is scoped to the Baileys adapter, where + # _set_owner_flag runs at intake (plugins/platforms/whatsapp/adapter.py). + # Re-add here once Cloud group intake is implemented. # Cloud API timestamps are unix seconds (string). MessageEvent # doesn't enforce a type but downstream code formats with it. diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index ad0250cb3d36..e007ad177b55 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -361,6 +361,14 @@ def __init__(self, config: PlatformConfig): self._allow_from = self._coerce_allow_list(config.extra.get("allow_from") or config.extra.get("allowFrom")) self._group_policy = str(config.extra.get("group_policy") or os.getenv("WHATSAPP_GROUP_POLICY", "open")).strip().lower() self._group_allow_from = self._coerce_allow_list(config.extra.get("group_allow_from") or config.extra.get("groupAllowFrom")) + # Reactions toggle. Behavioral setting -> config.yaml `whatsapp.reactions` + # (AGENTS.md requires behavioral settings live in config.yaml, not env). + # The legacy WHATSAPP_REACTIONS env is still honored as a fallback for + # back-compat. Default on. + _reactions_cfg = config.extra.get("reactions") + if _reactions_cfg is None: + _reactions_cfg = os.getenv("WHATSAPP_REACTIONS", "true") + self._reactions_on = str(_reactions_cfg).strip().lower() not in {"false", "0", "no"} self._mention_patterns = self._compile_mention_patterns() self._message_queue: asyncio.Queue = asyncio.Queue() self._bridge_log_fh = None @@ -1023,14 +1031,16 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: # when done, and cleared on CANCELLED so it never lingers (parity with the # Telegram adapter). WhatsApp replaces a sender's existing reaction, so the # swap needs no explicit remove. Reactions go through the bridge's - # /send-reaction endpoint. Disable with WHATSAPP_REACTIONS=false. + # /send-reaction endpoint. Disable with config.yaml `whatsapp.reactions: false`. # ------------------------------------------------------------------ def _reactions_enabled(self, event: "MessageEvent" = None) -> bool: - """Global toggle (WHATSAPP_REACTIONS, default on). The bridge already - drops unauthorized DMs (group members are authorized wholesale), so a - message that reaches this hook is already allowed.""" - return os.getenv("WHATSAPP_REACTIONS", "true").lower() not in {"false", "0", "no"} + """Global toggle. Set via config.yaml `whatsapp.reactions` (default on; + legacy WHATSAPP_REACTIONS env honored as a fallback) — resolved once at + init into ``self._reactions_on``. The bridge already drops unauthorized + DMs (group members are authorized wholesale), so a message that reaches + this hook is already allowed.""" + return self._reactions_on def _extract_reaction_target(self, event: MessageEvent): """Return (chat_id, message_id, participant) for the Baileys reaction key, diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 71c323ec3671..9c7e6f119ef9 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -321,7 +321,15 @@ async function startSocket() { } catch {} continue; } - if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { + // The WHATSAPP_ALLOWED_USERS gate applies to DIRECT messages only. + // GROUP messages are passed through to the Python gateway, which + // authorizes the group via the documented whatsapp.group_policy / + // group_allow_from model and applies require_mention gating. Without + // this exception a concrete WHATSAPP_ALLOWED_USERS (required for owner + // detection — it can't be "*") would drop every non-owner group member + // here, before the gateway ever sees them, so only the owner could + // trigger the bot in a group. + if (!isGroup && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { try { console.log(JSON.stringify({ event: 'ignored', diff --git a/tests/gateway/test_whatsapp_group_gating.py b/tests/gateway/test_whatsapp_group_gating.py index cee3894d6e01..880773b1ecf9 100644 --- a/tests/gateway/test_whatsapp_group_gating.py +++ b/tests/gateway/test_whatsapp_group_gating.py @@ -370,3 +370,63 @@ def test_is_broadcast_chat_helper_recognizes_common_jids(): assert WhatsAppAdapter._is_broadcast_chat("120363001234567890@g.us") is False assert WhatsAppAdapter._is_broadcast_chat("") is False assert WhatsAppAdapter._is_broadcast_chat(None) is False # type: ignore[arg-type] + + +def test_unlisted_group_member_mention_is_authorized_e2e(monkeypatch): + """PR #53623 review — bridge -> adapter -> gateway authorization path. + + An UNLISTED group member (not in WHATSAPP_ALLOWED_USERS) who @mentions the + bot in an allowlisted group must reach the agent: + * the bridge passes group members through (its allowlist gate is DM-only), + * the adapter processes the @mention (identity-agnostic mention gating), + * the gateway authorizes the group via whatsapp.group_policy=allowlist. + Contrast: the same unlisted sender in a DM is denied. + """ + from types import SimpleNamespace + from unittest.mock import MagicMock + from gateway.config import GatewayConfig + from gateway.session import SessionSource + from gateway.run import GatewayRunner + + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15551230000") # owner only, concrete (not "*") + GROUP = "120363001234567890@g.us" + + # (1) Adapter: an unlisted member @mentions the bot -> processed, not dropped. + adapter = _make_adapter( + require_mention=True, + group_policy="allowlist", + group_allow_from=[GROUP], + ) + mentioned = _group_message( + "@bot please help", + chatId=GROUP, + senderId="6289999999999@s.whatsapp.net", # NOT the owner + mentionedIds=["15551230000@s.whatsapp.net"], # the bot + ) + assert adapter._should_process_message(mentioned) is True + + # (2) Gateway: the group source from that same unlisted sender is authorized + # (whatsapp.group_policy=allowlist), even though the sender is not in + # WHATSAPP_ALLOWED_USERS. + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.WHATSAPP: PlatformConfig(enabled=True, extra={"group_policy": "allowlist"})} + ) + runner.adapters = {Platform.WHATSAPP: SimpleNamespace(enforces_own_access_policy=True)} + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved.return_value = False + runner.pairing_store._is_rate_limited.return_value = False + + group_src = SessionSource( + platform=Platform.WHATSAPP, chat_type="group", chat_id=GROUP, + user_id="6289999999999", user_name="Guest", + ) + assert runner._is_user_authorized(group_src) is True + + # (3) Contrast: the same unlisted sender in a DM is NOT authorized. + dm_src = SessionSource( + platform=Platform.WHATSAPP, chat_type="dm", + chat_id="6289999999999@s.whatsapp.net", + user_id="6289999999999", user_name="Guest", + ) + assert runner._is_user_authorized(dm_src) is False From d14615ac7f9f7be85d2ded6f1b8206cd7abb70e7 Mon Sep 17 00:00:00 2001 From: sdugoten <7108552+sdugoten@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:16:04 +0100 Subject: [PATCH 3/3] feat(gateway): widen owner-marker gate to include Signal Signal DMs already resolve source.is_owner via the generic _is_owner() fallback (SIGNAL_ALLOWED_USERS), but the two owner-marker opt-in gates (the **Owner:** session-prompt line and the additive [SYSTEM: sender NAME is the owner] message marker) only listed WHATSAPP/WHATSAPP_CLOUD. Add Platform.SIGNAL to both gates so Signal gets the same owner disclosure as WhatsApp once is_owner is set, and add regression tests covering all three gated platforms plus the non-gated/shared-session cases. --- gateway/run.py | 9 ++- gateway/session.py | 7 ++- tests/gateway/test_session.py | 57 +++++++++++++++++ .../test_shared_group_sender_prefix.py | 62 +++++++++++++++++++ 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 3b790c65dc9f..491b38f19761 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10522,12 +10522,15 @@ async def _prepare_inbound_message_text( # status can't ride there — it has to ride on the message. System-set # (a guest can't spoof it via display name/body) and added ONLY for # the owner, so guest lines stay byte-identical to upstream. Gated to - # the platforms that wire owner detection (their adapter sets - # source.is_owner); OTHER PLATFORMS CAN OPT IN by adding themselves to - # this set once their adapter implements owner detection. + # the platforms that wire owner detection (WhatsApp's adapter sets + # source.is_owner directly; Signal DMs resolve it via the generic + # _is_owner() fallback against SIGNAL_ALLOWED_USERS). OTHER PLATFORMS + # CAN OPT IN by adding themselves to this set once their adapter + # implements owner detection. if getattr(source, "is_owner", False) and source.platform in { Platform.WHATSAPP, Platform.WHATSAPP_CLOUD, + Platform.SIGNAL, }: message_text = f"[SYSTEM: sender {source.user_name} is the owner] {message_text}" diff --git a/gateway/session.py b/gateway/session.py index 1885931005e6..a7ec03d2f3d8 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -498,11 +498,14 @@ def build_session_context_prompt( # shared multi-user session this prompt is cache-shared and sender-agnostic, so # owner status rides the additive "[SYSTEM: sender NAME is the owner]" message # marker (added in run.py) instead. Gated to the platforms that wire owner - # detection (their adapter sets source.is_owner); OTHER PLATFORMS CAN OPT IN by - # adding themselves to this set once their adapter implements owner detection. + # detection (WhatsApp's adapter sets source.is_owner directly; Signal DMs + # resolve it via the generic _is_owner() fallback against + # SIGNAL_ALLOWED_USERS). OTHER PLATFORMS CAN OPT IN by adding themselves to + # this set once their adapter implements owner detection. if not context.shared_multi_user_session and context.source.platform in { Platform.WHATSAPP, Platform.WHATSAPP_CLOUD, + Platform.SIGNAL, }: lines.append( "**Owner:** " diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 4f47d0ff638b..b836eafa29f4 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -386,6 +386,63 @@ def test_whatsapp_prompt(self): assert "WhatsApp" in prompt or "whatsapp" in prompt.lower() + @pytest.mark.parametrize("platform", [Platform.WHATSAPP, Platform.WHATSAPP_CLOUD, Platform.SIGNAL]) + def test_owner_line_shown_for_owner_gated_platforms(self, platform): + """The **Owner:** line must render for every platform gated into owner + detection (Req 3 / signalfix.md Gate C) — not just WhatsApp. Signal DMs + resolve ``is_owner`` via the generic ``_is_owner()`` fallback against + ``SIGNAL_ALLOWED_USERS``, so Signal must get the same prompt-level + owner disclosure as WhatsApp once ``source.is_owner`` is set.""" + config = GatewayConfig(platforms={platform: PlatformConfig(enabled=True, token="")}) + source = SessionSource( + platform=platform, + chat_id="dm-1", + chat_type="dm", + user_name="Some User", + is_owner=True, + ) + ctx = build_session_context(source, config) + prompt = build_session_context_prompt(ctx) + + assert "**Owner:**" in prompt + assert "yes" in prompt.lower() + + def test_owner_line_omitted_for_non_gated_platform(self): + """Platforms that haven't opted into owner detection must not show the + line at all — even if ``is_owner`` were somehow set.""" + config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}, + ) + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="dm-1", + chat_type="dm", + user_name="Some User", + is_owner=True, + ) + ctx = build_session_context(source, config) + prompt = build_session_context_prompt(ctx) + + assert "**Owner:**" not in prompt + + def test_owner_line_omitted_in_shared_multi_user_session(self): + """Owner status must not ride the cache-shared prompt in a multi-user + session for any gated platform — including Signal — since the marker + would be sender-agnostic and stale.""" + config = GatewayConfig(platforms={Platform.SIGNAL: PlatformConfig(enabled=True, token="")}) + source = SessionSource( + platform=Platform.SIGNAL, + chat_id="group-1", + chat_type="group", + thread_id="thread-1", + is_owner=True, + ) + ctx = build_session_context(source, config) + ctx.shared_multi_user_session = True + prompt = build_session_context_prompt(ctx) + + assert "**Owner:**" not in prompt + def test_multi_user_thread_prompt(self): """Shared thread sessions show multi-user note instead of single user.""" config = GatewayConfig( diff --git a/tests/gateway/test_shared_group_sender_prefix.py b/tests/gateway/test_shared_group_sender_prefix.py index 9f0e525f64fd..672f11a9ac00 100644 --- a/tests/gateway/test_shared_group_sender_prefix.py +++ b/tests/gateway/test_shared_group_sender_prefix.py @@ -68,3 +68,65 @@ async def test_preprocess_keeps_plain_text_for_default_group_sessions(): ) assert result == "hello" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform", [Platform.WHATSAPP, Platform.WHATSAPP_CLOUD, Platform.SIGNAL]) +async def test_owner_marker_added_for_owner_gated_platforms_in_shared_session(platform): + """The additive "[SYSTEM: sender NAME is the owner]" marker (Req 3 / + signalfix.md Gate C) must fire for every platform gated into owner + detection, not just WhatsApp — Signal opted in alongside WhatsApp/ + WhatsApp Cloud since Signal DMs resolve ``is_owner`` via the generic + ``_is_owner()`` fallback against ``SIGNAL_ALLOWED_USERS``.""" + runner = _make_runner( + GatewayConfig( + platforms={platform: PlatformConfig(enabled=True, token="")}, + group_sessions_per_user=False, + ) + ) + source = SessionSource( + platform=platform, + chat_id="group-1", + chat_name="Test Group", + chat_type="group", + user_name="Alice", + is_owner=True, + ) + event = MessageEvent(text="hello", source=source) + + result = await runner._prepare_inbound_message_text( + event=event, + source=source, + history=[], + ) + + assert result == "[SYSTEM: sender Alice is the owner] [Alice] hello" + + +@pytest.mark.asyncio +async def test_owner_marker_omitted_for_non_gated_platform_in_shared_session(): + """Platforms that haven't opted into owner detection get the plain sender + prefix only, even if ``is_owner`` were somehow set.""" + runner = _make_runner( + GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake")}, + group_sessions_per_user=False, + ) + ) + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="-1002285219667", + chat_name="Test Group", + chat_type="group", + user_name="Alice", + is_owner=True, + ) + event = MessageEvent(text="hello", source=source) + + result = await runner._prepare_inbound_message_text( + event=event, + source=source, + history=[], + ) + + assert result == "[Alice] hello"