From 86ef0bbbf9aa6f92c86f6d3ce547bf6bc147f110 Mon Sep 17 00:00:00 2001 From: Jonatan Jonasson Date: Sat, 1 Aug 2026 12:50:32 +0000 Subject: [PATCH] fix(discord): isolate multiplex access policy per profile --- gateway/authz_mixin.py | 110 +- gateway/platforms/base.py | 18 +- gateway/run.py | 113 +- gateway/session.py | 29 + plugins/platforms/discord/adapter.py | 1217 ++++++++++--- .../test_discord_multiplex_access_policy.py | 1544 +++++++++++++++++ 6 files changed, 2715 insertions(+), 316 deletions(-) create mode 100644 tests/gateway/test_discord_multiplex_access_policy.py diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index be57b3f03ef21..b85164dbb5e0d 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -77,7 +77,7 @@ def _authorization_adapter( if not platform: return None profile_name = (profile or "").strip() or None - if profile_name and profile_name != "default": + if profile_name: active_profile = None active_profile_fn = getattr(self, "_active_profile_name", None) if callable(active_profile_fn): @@ -120,9 +120,12 @@ def _adapter_for_source(self, source: Optional[SessionSource]): return adapters.get(Platform.RELAY) # ``getattr`` guards test fixtures that build a bare source via # SimpleNamespace and omit ``profile`` (see AGENTS.md pitfall #17). + transport_profile = getattr(source, "transport_profile", None) return self._authorization_adapter( getattr(source, "platform", None), - getattr(source, "profile", None), + transport_profile + if isinstance(transport_profile, str) and transport_profile.strip() + else getattr(source, "profile", None), ) def _registered_transport_adapter(self, source: SessionSource): @@ -160,6 +163,9 @@ def _adapter_profile_for_source(self, source: SessionSource) -> Optional[str]: ).items(): if adapter is profile_adapters.get(platform): return profile + transport_profile = getattr(source, "transport_profile", None) + if isinstance(transport_profile, str) and transport_profile.strip(): + return transport_profile return getattr(source, "profile", None) def _adapter_authorization_is_upstream( @@ -340,18 +346,20 @@ def _adapter_group_has_sender_allowlist( return False def _pairing_store_for(self, source: "SessionSource"): - """Pick the per-profile PairingStore for a source, falling back to global. + """Pick the PairingStore owned by ``source`` without crossing profiles. - In a multiplexing gateway, each profile owns its own pairing whitelist - so isolation is preserved. When the source has no profile (single- - profile gateway, or a path that hasn't stamped profile yet) or the - profile isn't registered, fall back to ``self.pairing_store`` (the - global default) so existing behavior is preserved. + A multiplex source with an explicit profile must resolve to that + profile's store. Missing registration fails closed. Unstamped and + single-profile sources retain the legacy global fallback. """ per_profile = getattr(self, "pairing_stores", None) or {} profile = getattr(source, "profile", None) - if profile and profile in per_profile: - return per_profile[profile] + if profile: + if profile in per_profile: + return per_profile[profile] + config = getattr(self, "config", None) + if bool(getattr(config, "multiplex_profiles", False)): + return None return getattr(self, "pairing_store", None) def _is_user_authorized(self, source: SessionSource) -> bool: @@ -469,12 +477,92 @@ def _is_user_authorized(self, source: SessionSource) -> bool: } if getattr(source, "is_bot", False): allow_bots_var = platform_allow_bots_map.get(source.platform) - if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}: + # Discord owns this decision when a concrete adapter is available; + # bare-runner/direct tests retain the historical env fallback. + adapter_owns_bot_policy = False + if source.platform == Platform.DISCORD: + bot_adapter = self._authorization_adapter( + source.platform, adapter_profile + ) + adapter_owns_bot_policy = callable( + getattr(type(bot_adapter), "_authorization_policy_allows", None) + ) + if ( + not adapter_owns_bot_policy + and allow_bots_var + and os.getenv(allow_bots_var, "none").lower().strip() + in {"mentions", "all"} + ): return True if not user_id: return False + # Discord adapters capture every user/role/channel/global fallback and + # pairing callback under their owning profile scope. Treat that snapshot + # as the complete authorization decision at this second gateway gate; + # reopening process globals or applying pairing again here could bypass + # an adapter-level hard channel denial. + adapter = self._authorization_adapter(source.platform, adapter_profile) + policy_authorizer = getattr( + type(adapter), "_authorization_policy_allows", None + ) + if callable(policy_authorizer): + channel_keys = { + str(value).strip() + for value in getattr(source, "authorization_channel_keys", []) + if str(value).strip() + } + for value in (source.chat_id, getattr(source, "parent_chat_id", None)): + if value: + channel_keys.add(str(value).strip()) + chat_name = str(getattr(source, "chat_name", "") or "").strip() + if chat_name: + channel_keys.add(chat_name) + leaf_name = chat_name.rsplit(" / ", 1)[-1].strip() + if leaf_name: + channel_keys.add(leaf_name) + bare_name = leaf_name.removeprefix("#") + if bare_name: + channel_keys.update({bare_name, f"#{bare_name}"}) + role_authorized = getattr(source, "role_authorized", False) is True + if not role_authorized: + role_checker = getattr(type(adapter), "_source_has_allowed_role", None) + if callable(role_checker): + try: + role_authorized = bool( + role_checker( + adapter, + user_id, + guild_id=( + getattr(source, "scope_id", None) + or getattr(source, "guild_id", None) + ), + is_dm=getattr(source, "chat_type", "dm") == "dm", + ) + ) + except Exception: + role_authorized = False + try: + return bool( + policy_authorizer( + adapter, + user_id, + chat_id=source.chat_id, + channel_keys=channel_keys, + is_dm=getattr(source, "chat_type", "dm") == "dm", + role_authorized=role_authorized, + is_bot=getattr(source, "is_bot", False) is True, + ) + ) + except Exception: + logger.warning( + "Adapter-local authorization check failed for %s", + source.platform.value, + exc_info=True, + ) + return False + platform_env_map = { Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", Platform.DISCORD: "DISCORD_ALLOWED_USERS", diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d7654ff6c147a..a01431e496558 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -541,7 +541,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple, Union +from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple, Union, Iterable from enum import Enum from pathlib import Path as _Path @@ -2747,6 +2747,9 @@ def set_status_text(self, chat_id: str, text: Optional[str]) -> None: def __init__(self, config: PlatformConfig, platform: Platform): self.config = config self.platform = platform + # Transport-owning profile, stamped by GatewayRunner before connect. + # Persisted through SessionSource separately from runtime routing. + self._gateway_profile_name: Optional[str] = None self._message_handler: Optional[MessageHandler] = None # Optional gateway-supplied fan-out for platform-native emoji # reaction events (see ``set_reaction_handler``). @@ -6567,6 +6570,7 @@ def build_source( scope_id: Optional[str] = None, guild_id: Optional[str] = None, parent_chat_id: Optional[str] = None, + authorization_channel_keys: Optional[Iterable[str]] = None, message_id: Optional[str] = None, role_authorized: bool = False, auto_thread_created: bool = False, @@ -6605,6 +6609,9 @@ def build_source( scope_id=str(scope_id) if scope_id else None, guild_id=str(guild_id) if guild_id else None, parent_chat_id=str(parent_chat_id) if parent_chat_id else None, + authorization_channel_keys=[ + str(value) for value in (authorization_channel_keys or []) + ], message_id=str(message_id) if message_id else None, ) ) @@ -6629,15 +6636,18 @@ def build_source( scope_id=str(scope_id) if scope_id else None, guild_id=str(guild_id) if guild_id else None, parent_chat_id=str(parent_chat_id) if parent_chat_id else None, + authorization_channel_keys=[ + str(value) for value in (authorization_channel_keys or []) + ], message_id=str(message_id) if message_id else None, profile=profile, + transport_profile=getattr(self, "_gateway_profile_name", None), role_authorized=role_authorized, auto_thread_created=auto_thread_created, auto_thread_initial_name=auto_thread_initial_name, ) - # In-process transport provenance is deliberately not serialized by - # SessionSource.to_dict(). The live receiving adapter is authoritative - # for this turn even when profile_routes selects a different runtime. + # Retain the exact live adapter for this process. ``transport_profile`` + # above is the serialized fallback used after restart. source._transport_adapter_ref = weakref.ref(self) return source diff --git a/gateway/run.py b/gateway/run.py index 43853f9b66b80..699e4a650596b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10623,8 +10623,8 @@ async def start(self) -> bool: _multiplex_skipped_platforms.append(platform) continue enabled_platform_count += 1 - - adapter = self._create_adapter(platform, platform_config) + + adapter = self._create_primary_adapter(platform, platform_config) if not adapter: # Distinguish between missing builtin deps and missing plugin _pval = platform.value @@ -10639,6 +10639,8 @@ async def start(self) -> bool: logger.warning("No adapter available for %s", _pval) continue + adapter._gateway_profile_name = self._active_profile_name() + self._wire_adapter_voice_callbacks(adapter) # Set up message + fatal error handlers adapter.set_message_handler(self._handle_message) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) @@ -10649,6 +10651,9 @@ async def start(self) -> bool: _set_reaction(self._handle_reaction_event) adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id) adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform)) + self._bind_adapter_pairing_check( + adapter, getattr(self, "pairing_store", None) + ) adapter._busy_text_mode = self._busy_text_mode # Try to connect @@ -10668,10 +10673,8 @@ async def start(self) -> bool: if success: self.adapters[platform] = adapter self._sync_voice_mode_state_to_adapter(adapter) - # Wire voice input callback at connect time so voice - # transcription is forwarded without requiring /voice join. - if hasattr(adapter, "_voice_input_callback"): - adapter._voice_input_callback = self._handle_voice_channel_input + # Refresh concrete-adapter voice ownership after connect. + self._wire_adapter_voice_callbacks(adapter) connected_count += 1 self._update_platform_runtime_status( platform.value, @@ -11732,7 +11735,7 @@ async def _platform_reconnect_watcher(self) -> None: adapter = None try: - adapter = self._create_adapter(platform, platform_config) + adapter = self._create_primary_adapter(platform, platform_config) if not adapter: logger.warning( "Reconnect %s: adapter creation returned None, removing from retry queue", @@ -11741,6 +11744,8 @@ async def _platform_reconnect_watcher(self) -> None: del self._failed_platforms[platform] continue + adapter._gateway_profile_name = self._active_profile_name() + self._wire_adapter_voice_callbacks(adapter) adapter.set_message_handler(self._handle_message) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) adapter.set_session_store(self.session_store) @@ -11750,6 +11755,9 @@ async def _platform_reconnect_watcher(self) -> None: _set_reaction(self._handle_reaction_event) adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id) adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform)) + self._bind_adapter_pairing_check( + adapter, getattr(self, "pairing_store", None) + ) adapter._busy_text_mode = self._busy_text_mode # Reconnect after an outage: preserve the platform's @@ -11761,9 +11769,8 @@ async def _platform_reconnect_watcher(self) -> None: if success: self.adapters[platform] = adapter self._sync_voice_mode_state_to_adapter(adapter) - # Wire voice input callback on reconnect as well (#60623). - if hasattr(adapter, "_voice_input_callback"): - adapter._voice_input_callback = self._handle_voice_channel_input + # Refresh concrete-adapter voice ownership after reconnect. + self._wire_adapter_voice_callbacks(adapter) self.delivery_router.adapters = self.adapters del self._failed_platforms[platform] self._update_platform_runtime_status( @@ -12674,6 +12681,43 @@ async def _start_one_profile_adapters( await self._safe_adapter_disconnect(adapter, platform) return connected + @staticmethod + def _bind_adapter_pairing_check( + adapter: BasePlatformAdapter, + pairing_store: Any, + ) -> None: + """Give pairing-aware adapters their owning profile's store.""" + setter = getattr(adapter, "set_pairing_check", None) + if not callable(setter): + return + if pairing_store is None: + setter(None) + return + platform_name = adapter.platform.value + setter( + lambda user_id, store=pairing_store, name=platform_name: bool( + store.is_approved(name, user_id) + ) + ) + + def _wire_adapter_voice_callbacks(self, adapter: Any) -> None: + """Bind voice callbacks to the concrete adapter that owns the transport.""" + if hasattr(adapter, "_voice_input_callback"): + async def _voice_input(guild_id: int, user_id: int, transcript: str): + return await self._handle_voice_channel_input( + guild_id, user_id, transcript, adapter=adapter + ) + + setattr(adapter, "_voice_input_callback", _voice_input) + if hasattr(adapter, "_on_voice_disconnect"): + setattr( + adapter, + "_on_voice_disconnect", + lambda chat_id: self._handle_voice_timeout_cleanup( + chat_id, adapter=adapter + ), + ) + def _configure_profile_adapter( self, adapter: BasePlatformAdapter, @@ -12681,6 +12725,8 @@ def _configure_profile_adapter( platform: Platform, ) -> None: """Install the profile-scoped handlers shared by startup and reconnect.""" + adapter._gateway_profile_name = profile_name + self._wire_adapter_voice_callbacks(adapter) adapter.set_message_handler(self._make_profile_message_handler(profile_name)) adapter.set_fatal_error_handler( self._make_profile_fatal_error_handler(profile_name, platform) @@ -12694,6 +12740,19 @@ def _configure_profile_adapter( adapter.set_authorization_check( self._make_adapter_auth_check(platform, profile_name=profile_name) ) + pairing_setter = getattr(adapter, "set_pairing_check", None) + if callable(pairing_setter): + pairing_stores = getattr(self, "pairing_stores", None) + if not isinstance(pairing_stores, dict): + pairing_stores = {} + self.pairing_stores = pairing_stores + pairing_store = pairing_stores.get(profile_name) + if pairing_store is None: + from gateway.pairing import PairingStore + + pairing_store = PairingStore(profile=profile_name) + pairing_stores[profile_name] = pairing_store + self._bind_adapter_pairing_check(adapter, pairing_store) adapter._busy_text_mode = self._busy_text_mode async def _run_secondary_profile_reconnect( @@ -12972,6 +13031,17 @@ def _adapter_credential_fingerprint(adapter: Any) -> Optional[str]: import hashlib return hashlib.sha256(("hermes-mux:" + token).encode("utf-8")).hexdigest()[:16] + def _create_primary_adapter( + self, + platform: Platform, + config: Any, + ) -> Optional[BasePlatformAdapter]: + """Create a primary adapter under its active profile scope in multiplex.""" + if getattr(self.config, "multiplex_profiles", False): + with _profile_runtime_scope(get_hermes_home()): + return self._create_adapter(platform, config) + return self._create_adapter(platform, config) + def _create_adapter( self, platform: Platform, @@ -18052,10 +18122,7 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str: # Wire callbacks BEFORE join so voice input arriving immediately # after connection is not lost. - if hasattr(adapter, "_voice_input_callback"): - adapter._voice_input_callback = self._handle_voice_channel_input - if hasattr(adapter, "_on_voice_disconnect"): - adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup + self._wire_adapter_voice_callbacks(adapter) # Let the adapter's inactivity timer see the live voice-reply mode so it # doesn't disconnect a deliberately text-only (/voice off) session. if hasattr(adapter, "_voice_mode_getter"): @@ -18114,14 +18181,16 @@ async def _handle_voice_channel_leave(self, event: MessageEvent) -> str: adapter._voice_input_callback = None return "Left voice channel." - def _handle_voice_timeout_cleanup(self, chat_id: str) -> None: + def _handle_voice_timeout_cleanup( + self, chat_id: str, *, adapter: Optional[Any] = None + ) -> None: """Called by the adapter when a voice channel times out. Cleans up runner-side voice_mode state that the adapter cannot reach. """ self._voice_mode[self._voice_key(Platform.DISCORD, chat_id)] = "off" self._save_voice_modes() - adapter = self.adapters.get(Platform.DISCORD) + adapter = adapter or self.adapters.get(Platform.DISCORD) self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) def _is_duplicate_voice_transcript(self, guild_id: int, user_id: int, transcript: str) -> bool: @@ -18166,14 +18235,19 @@ def _is_duplicate_voice_transcript(self, guild_id: int, user_id: int, transcript return False async def _handle_voice_channel_input( - self, guild_id: int, user_id: int, transcript: str + self, + guild_id: int, + user_id: int, + transcript: str, + *, + adapter: Optional[Any] = None, ): """Handle transcribed voice from a user in a voice channel. Creates a synthetic MessageEvent and processes it through the adapter's full message pipeline (session, typing, agent, TTS reply). """ - adapter = self.adapters.get(Platform.DISCORD) + adapter = adapter or self.adapters.get(Platform.DISCORD) if not adapter: return @@ -18189,12 +18263,15 @@ async def _handle_voice_channel_input( source.user_id = str(user_id) source.user_name = str(user_id) else: + transport_profile = getattr(adapter, "_gateway_profile_name", None) source = SessionSource( platform=Platform.DISCORD, chat_id=str(text_ch_id), user_id=str(user_id), user_name=str(user_id), chat_type="channel", + profile=transport_profile, + transport_profile=transport_profile, ) # Check authorization before processing voice input diff --git a/gateway/session.py b/gateway/session.py index cdddace0751a4..6dfc414d6c6d3 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -175,6 +175,10 @@ class SessionSource: scope_id: Optional[str] = None guild_id: Optional[str] = None # @deprecated legacy alias for scope_id (D-Q2.5) parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread + # Adapter-validated channel identifiers used by authorization gates (IDs, + # bare names, #names, and parent equivalents). Persisted so a restored + # session is checked against the same channel context as live ingress. + authorization_channel_keys: List[str] = field(default_factory=list) message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react) role_authorized: bool = False # True when adapter granted access via role (not user ID) # Profile this inbound message is routed to in a multiplexing gateway @@ -182,6 +186,12 @@ class SessionSource: # None => the gateway's active/default profile. Drives both session-key # namespacing and the per-turn config/credential scope. profile: Optional[str] = None + # Profile that owns the transport adapter/bot which received this event. + # This can differ from ``profile`` when profile_routes sends an inbound + # message to another runtime. Persist it so restored delivery and policy + # checks do not silently switch bot credentials or authorization domains. + # None preserves legacy sources that predate transport provenance. + transport_profile: Optional[str] = None # Discord auto-thread metadata. Newly auto-created Discord threads start # with a fast placeholder title from the raw message, then the gateway can @@ -250,6 +260,8 @@ def to_dict(self) -> Dict[str, Any]: d["user_id_alt"] = self.user_id_alt if self.chat_id_alt: d["chat_id_alt"] = self.chat_id_alt + if self.is_bot: + d["is_bot"] = True # D-Q2.5 dual-write: emit BOTH the canonical `scope_id` and the # deprecated `guild_id` alias (mirrored in __post_init__) so a connector # on either side of the migration resolves the scope. Drop `guild_id` @@ -260,10 +272,16 @@ def to_dict(self) -> Dict[str, Any]: d["guild_id"] = scope if self.parent_chat_id: d["parent_chat_id"] = self.parent_chat_id + if self.authorization_channel_keys: + d["authorization_channel_keys"] = sorted( + {str(value) for value in self.authorization_channel_keys if str(value)} + ) if self.message_id: d["message_id"] = self.message_id if self.profile: d["profile"] = self.profile + if self.transport_profile: + d["transport_profile"] = self.transport_profile if self.auto_thread_created: d["auto_thread_created"] = True if self.auto_thread_initial_name: @@ -283,12 +301,23 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": chat_topic=data.get("chat_topic"), user_id_alt=data.get("user_id_alt"), chat_id_alt=data.get("chat_id_alt"), + is_bot=data.get("is_bot") is True, # D-Q2.5 dual-read: prefer the canonical `scope_id`, fall back to the # deprecated `guild_id` alias (a peer not yet migrated still sends it). scope_id=data.get("scope_id", data.get("guild_id")), parent_chat_id=data.get("parent_chat_id"), + authorization_channel_keys=( + [ + str(value) + for value in data.get("authorization_channel_keys", []) + if str(value) + ] + if isinstance(data.get("authorization_channel_keys"), list) + else [] + ), message_id=data.get("message_id"), profile=data.get("profile"), + transport_profile=data.get("transport_profile"), auto_thread_created=bool(data.get("auto_thread_created", False)), auto_thread_initial_name=data.get("auto_thread_initial_name"), ) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 5fab2307c30a8..35738f03ea03d 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -25,12 +25,14 @@ import time from collections import defaultdict from contextlib import suppress -from typing import Callable, Dict, List, Optional, Any, Tuple +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Any, Tuple, Iterable from urllib.parse import urljoin from agent.async_utils import ( consume_detached_task_result as _consume_background_task_result, ) +from agent.secret_scope import current_secret_scope, get_secret, is_multiplex_active logger = logging.getLogger(__name__) @@ -345,6 +347,303 @@ def _clean_discord_id(entry: str) -> str: return entry.strip() +def _coerce_access_values(raw: Any) -> set[str]: + """Normalize a comma-separated or sequence access-policy value.""" + if raw is None: + return set() + if isinstance(raw, str): + values = raw.split(",") + elif isinstance(raw, (list, tuple, set, frozenset)): + values = raw + else: + values = (raw,) + return {str(value).strip() for value in values if str(value).strip()} + + +def _access_bool(raw: Any) -> bool: + return str(raw or "").strip().lower() in {"true", "1", "yes"} + + +def _legacy_on_bool(raw: Any) -> bool: + """Parse a flag that historically accepts ``on``.""" + return str(raw or "").strip().lower() in {"true", "1", "yes", "on"} + + +def _default_true_bool(raw: Any) -> bool: + """Parse a default-on flag while preserving explicit false values.""" + if isinstance(raw, bool): + return raw + if raw is None: + return True + text = str(raw).strip().lower() + if not text: + return True + return text not in {"false", "0", "no", "off"} + + +@dataclass +class DiscordAccessPolicy: + """Authorization inputs captured for one Discord adapter/profile.""" + + allowed_user_ids: set[str] = field(default_factory=set) + allowed_role_ids: set[int] = field(default_factory=set) + allowed_channel_keys: set[str] = field(default_factory=set) + ignored_channel_keys: set[str] = field(default_factory=set) + dm_role_auth_guild_id: Optional[int] = None + free_response_channel_keys: set[str] = field(default_factory=set) + require_mention: bool = True + thread_require_mention: bool = False + ignore_no_mention: bool = True + history_backfill_enabled: bool = True + history_backfill_limit: int = 50 + approval_mentions: bool = False + missed_message_backfill_enabled: bool = False + missed_message_backfill_channels: set[str] = field(default_factory=set) + missed_message_backfill_channels_configured: bool = False + missed_message_backfill_window_seconds: float = 21600.0 + missed_message_backfill_limit: int = 100 + missed_message_backfill_max_dispatches: int = 10 + allow_all_users: bool = False + gateway_allowed_user_ids: set[str] = field(default_factory=set) + gateway_allow_all_users: bool = False + allow_bots: str = "none" + bots_require_inline_mention: bool = False + pairing_check: Optional[Callable[[str], bool]] = field( + default=None, repr=False, compare=False + ) + + @classmethod + def from_config(cls, config: "PlatformConfig") -> "DiscordAccessPolicy": + """Capture policy while the adapter's profile secret scope is active.""" + extra = config.extra if isinstance(getattr(config, "extra", None), dict) else {} + + def _profile_value(env_name: str, extra_key: Optional[str] = None) -> Any: + # Preserve env-over-YAML precedence without confusing a present + # empty value with an absent value. Multiplex primary adapters may + # be constructed after their config-loading scope has exited, so + # the scoped value is also persisted in ``extra`` by + # ``_apply_yaml_config`` below. + scope = current_secret_scope() + if scope is not None and env_name in scope: + return scope[env_name] + if not is_multiplex_active() and env_name in os.environ: + return get_secret(env_name, "") + if extra_key is not None and extra_key in extra: + return extra[extra_key] + if is_multiplex_active(): + return "" + return get_secret(env_name, "") or "" + + def _profile_bool( + env_name: str, + extra_key: str, + *, + default: bool, + env_parser: Callable[[Any], bool], + config_parser: Callable[[Any], bool], + ) -> bool: + """Preserve the setting's legacy env/config parsing by source.""" + scope = current_secret_scope() + if scope is not None and env_name in scope: + return env_parser(scope[env_name]) + if not is_multiplex_active() and env_name in os.environ: + return env_parser(get_secret(env_name, str(default))) + if extra_key in extra: + value = extra[extra_key] + # Scoped env values handed off by _apply_yaml_config are + # normalized to bool before the creating scope exits. + if isinstance(value, bool): + return value + return config_parser(value) + if is_multiplex_active(): + return default + return env_parser(get_secret(env_name, str(default))) + + backfill_extra = extra.get("missed_message_backfill") + if not isinstance(backfill_extra, dict): + backfill_extra = {} + + def _backfill_value(env_name: str, key: str, default: Any) -> Any: + scope = current_secret_scope() + if scope is not None and env_name in scope: + return scope[env_name] + if key in backfill_extra: + return backfill_extra[key] + if not is_multiplex_active() and env_name in os.environ: + return get_secret(env_name, str(default)) + if is_multiplex_active(): + return default + return get_secret(env_name, str(default)) + + def _bounded_float(raw: Any, default: float, minimum: float) -> float: + try: + value = float(raw) + except (TypeError, ValueError): + value = default + return max(minimum, value) + + def _bounded_int(raw: Any, default: int, minimum: int, maximum: int) -> int: + try: + value = int(raw) + except (TypeError, ValueError): + value = default + return max(minimum, min(value, maximum)) + + def _int_or_default(raw: Any, default: int) -> int: + try: + return int(raw) + except (TypeError, ValueError): + return default + + def _positive_int_or_none(raw: Any) -> Optional[int]: + try: + value = int(raw) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + allowed_users = { + _clean_discord_id(value) + for value in _coerce_access_values( + _profile_value("DISCORD_ALLOWED_USERS", "allow_from") + ) + } + allowed_roles = { + int(value) + for value in _coerce_access_values( + _profile_value("DISCORD_ALLOWED_ROLES", "allowed_roles") + ) + if value.isdigit() + } + allow_bots = str( + _profile_value("DISCORD_ALLOW_BOTS", "allow_bots") or "none" + ).strip().lower() + if allow_bots not in {"none", "mentions", "all"}: + allow_bots = "none" + return cls( + allowed_user_ids=allowed_users, + allowed_role_ids=allowed_roles, + allowed_channel_keys=_coerce_access_values( + _profile_value("DISCORD_ALLOWED_CHANNELS", "allowed_channels") + ), + ignored_channel_keys=_coerce_access_values( + _profile_value("DISCORD_IGNORED_CHANNELS", "ignored_channels") + ), + dm_role_auth_guild_id=_positive_int_or_none( + extra.get("dm_role_auth_guild") + ), + free_response_channel_keys=_coerce_access_values( + _profile_value( + "DISCORD_FREE_RESPONSE_CHANNELS", "free_response_channels" + ) + ), + require_mention=_profile_bool( + "DISCORD_REQUIRE_MENTION", + "require_mention", + default=True, + env_parser=_default_true_bool, + config_parser=_default_true_bool, + ), + thread_require_mention=_profile_bool( + "DISCORD_THREAD_REQUIRE_MENTION", + "thread_require_mention", + default=False, + env_parser=_legacy_on_bool, + config_parser=_default_true_bool, + ), + ignore_no_mention=_profile_bool( + "DISCORD_IGNORE_NO_MENTION", + "ignore_no_mention", + default=True, + env_parser=_access_bool, + config_parser=_access_bool, + ), + history_backfill_enabled=_profile_bool( + "DISCORD_HISTORY_BACKFILL", + "history_backfill", + default=True, + env_parser=_access_bool, + config_parser=_default_true_bool, + ), + history_backfill_limit=_int_or_default( + _profile_value( + "DISCORD_HISTORY_BACKFILL_LIMIT", "history_backfill_limit" + ), + 50, + ), + approval_mentions=_profile_bool( + "DISCORD_APPROVAL_MENTIONS", + "approval_mentions", + default=False, + env_parser=_legacy_on_bool, + config_parser=_legacy_on_bool, + ), + missed_message_backfill_enabled=_legacy_on_bool( + _backfill_value("DISCORD_MISSED_MESSAGE_BACKFILL", "enabled", False) + ), + missed_message_backfill_channels=_coerce_access_values( + _backfill_value( + "DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", "channels", "" + ) + ), + missed_message_backfill_channels_configured=( + "channels" in backfill_extra + ), + missed_message_backfill_window_seconds=_bounded_float( + _backfill_value( + "DISCORD_MISSED_MESSAGE_BACKFILL_WINDOW_SECONDS", + "window_seconds", + 21600, + ), + 21600.0, + 60.0, + ), + missed_message_backfill_limit=_bounded_int( + _backfill_value( + "DISCORD_MISSED_MESSAGE_BACKFILL_LIMIT", "limit", 100 + ), + 100, + 1, + 500, + ), + missed_message_backfill_max_dispatches=_bounded_int( + _backfill_value( + "DISCORD_MISSED_MESSAGE_BACKFILL_MAX_DISPATCHES", + "max_dispatches", + 10, + ), + 10, + 1, + 100, + ), + allow_all_users=_profile_bool( + "DISCORD_ALLOW_ALL_USERS", + "allow_all_users", + default=False, + env_parser=_access_bool, + config_parser=_access_bool, + ), + gateway_allowed_user_ids=_coerce_access_values( + _profile_value("GATEWAY_ALLOWED_USERS", "gateway_allowed_users") + ), + gateway_allow_all_users=_profile_bool( + "GATEWAY_ALLOW_ALL_USERS", + "gateway_allow_all_users", + default=False, + env_parser=_access_bool, + config_parser=_access_bool, + ), + allow_bots=allow_bots, + bots_require_inline_mention=_profile_bool( + "DISCORD_BOTS_REQUIRE_INLINE_MENTION", + "bots_require_inline_mention", + default=False, + env_parser=_legacy_on_bool, + config_parser=_legacy_on_bool, + ), + ) + + def check_discord_requirements() -> bool: """Check if Discord dependencies are available. @@ -916,8 +1215,11 @@ def __init__(self, config: PlatformConfig): super().__init__(config, Platform.DISCORD) self._client: Optional[commands.Bot] = None self._ready_event = asyncio.Event() - self._allowed_user_ids: set = set() # For button approval authorization - self._allowed_role_ids: set = set() # For DISCORD_ALLOWED_ROLES filtering + self._access_policy = DiscordAccessPolicy.from_config(config) + # Keep these long-standing attributes as aliases for internal callers + # and third-party tests while the policy remains the source of truth. + self._allowed_user_ids = self._access_policy.allowed_user_ids + self._allowed_role_ids = self._access_policy.allowed_role_ids self.gateway_runner = None # Set by gateway/run.py for cross-platform delivery # Voice channel state (per-guild) self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient @@ -1106,6 +1408,19 @@ async def _notify() -> None: asyncio.create_task(_notify()) + def _discord_members_intent_required(self) -> bool: + """Return whether this adapter needs Discord's privileged members intent. + + Legacy single-profile adapters refresh at connect time, matching the + prior environment-driven behavior. Multiplex adapters retain their + constructor-captured snapshot. + """ + policy = self._discord_access_policy() + return any( + entry != "*" and not entry.isdigit() + for entry in policy.allowed_user_ids + ) or bool(policy.allowed_role_ids) + async def connect(self, *, is_reconnect: bool = False) -> bool: """Connect to Discord and start receiving events.""" if not DISCORD_AVAILABLE: @@ -1154,22 +1469,9 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: if not self._acquire_platform_lock('discord-bot-token', self.config.token, 'Discord bot token'): return False - # Parse allowed user entries (may contain usernames or IDs) - allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "") - if allowed_env: - self._allowed_user_ids = { - _clean_discord_id(uid) for uid in allowed_env.split(",") - if uid.strip() - } - - # Parse DISCORD_ALLOWED_ROLES — comma-separated role IDs. - # Users with ANY of these roles can interact with the bot. - roles_env = os.getenv("DISCORD_ALLOWED_ROLES", "") - if roles_env: - self._allowed_role_ids = { - int(rid.strip()) for rid in roles_env.split(",") - if rid.strip().isdigit() - } + # Access policy was captured in __init__ while this adapter's + # profile secret scope was active. Never re-read process globals + # here: multiplexed adapters share one process. # Set up intents. # Message Content is required for normal text replies. @@ -1182,15 +1484,10 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: intents.message_content = True intents.dm_messages = True intents.guild_messages = True - intents.members = ( - # ``"*"`` is the open-mode wildcard (honored in _is_allowed_user), - # not a username to resolve, so it must not pull in the privileged - # Server Members intent — exactly the migrate-from-OpenClaw path - # the wildcard fix targets would otherwise silently fail to come - # online when Members Intent isn't enabled in the Developer Portal. - any(entry != "*" and not entry.isdigit() for entry in self._allowed_user_ids) - or bool(self._allowed_role_ids) # Need members intent for role lookup - ) + # ``"*"`` is the open-mode wildcard, not a username to resolve. + # Legacy single-profile configuration is intentionally refreshed by + # this helper; multiplex policy snapshots must never consult globals. + intents.members = self._discord_members_intent_required() intents.voice_states = True # Resolve proxy (DISCORD_PROXY > generic env vars > macOS system proxy) @@ -1341,7 +1638,7 @@ def _discord_message_admission( role_authorized = False if getattr(message.author, "bot", False): - allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() + allow_bots = self._discord_access_policy().allow_bots if allow_bots == "none": return False, False if allow_bots == "mentions" and not self._self_is_explicitly_mentioned(message): @@ -1369,7 +1666,12 @@ def _discord_message_admission( ): self._warn_if_fail_closed_default() return False, False - role_authorized = bool(getattr(self, "_allowed_role_ids", set())) + role_authorized = self._has_allowed_role( + str(message.author.id), + message.author, + guild=msg_guild, + is_dm=is_dm, + ) raw_self_mention = self._self_is_explicitly_mentioned(message) if not isinstance(message.channel, discord.DMChannel) and ( @@ -1381,9 +1683,7 @@ def _discord_message_admission( ) if other_bots_mentioned and not raw_self_mention: return False, False - ignore_no_mention = os.getenv( - "DISCORD_IGNORE_NO_MENTION", "true" - ).lower() in {"true", "1", "yes"} + ignore_no_mention = self._discord_ignore_no_mention() if ignore_no_mention and not raw_self_mention and not other_bots_mentioned: parent_id = None if hasattr(message.channel, "parent_id") and message.channel.parent_id: @@ -1991,14 +2291,7 @@ async def _run_post_connect_initialization(self) -> None: def _missed_message_backfill_enabled(self) -> bool: """Whether to reconcile Discord messages missed while the gateway was down.""" - configured = self.config.extra.get("missed_message_backfill") - if isinstance(configured, dict) and "enabled" in configured: - value = configured["enabled"] - if isinstance(value, str): - return value.strip().lower() in ("true", "1", "yes", "on") - return bool(value) - raw = os.getenv("DISCORD_MISSED_MESSAGE_BACKFILL", "false") - return str(raw).strip().lower() in ("true", "1", "yes", "on") + return self._discord_access_policy().missed_message_backfill_enabled def _missed_message_backfill_channels(self) -> set[str]: """Channels to scan for missed messages after Discord reconnects. @@ -2008,62 +2301,21 @@ def _missed_message_backfill_channels(self) -> set[str]: Operators can set ``channels: "*"`` to scan every reachable text channel, but the safe default is scoped. """ - configured = self.config.extra.get("missed_message_backfill") - if isinstance(configured, dict) and "channels" in configured: - raw = configured.get("channels") - if isinstance(raw, list): - return {str(item).strip() for item in raw if str(item).strip()} - raw = str(raw or "") - if raw.strip(): - return {item.strip() for item in raw.split(",") if item.strip()} - raw = os.getenv("DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", "") - if not raw.strip(): - allowed = { - item.strip() - for item in os.getenv("DISCORD_ALLOWED_CHANNELS", "").split(",") - if item.strip() - } - return allowed | self._discord_free_response_channels() - return {item.strip() for item in raw.split(",") if item.strip()} + policy = self._discord_access_policy() + if policy.missed_message_backfill_channels_configured: + return set(policy.missed_message_backfill_channels) + if policy.missed_message_backfill_channels: + return set(policy.missed_message_backfill_channels) + return set(policy.allowed_channel_keys) | set(policy.free_response_channel_keys) def _missed_message_backfill_window_seconds(self) -> float: - configured = self.config.extra.get("missed_message_backfill") - raw = ( - configured.get("window_seconds", 21600) - if isinstance(configured, dict) - else os.getenv("DISCORD_MISSED_MESSAGE_BACKFILL_WINDOW_SECONDS", "21600") - ) - try: - value = float(raw) - except (TypeError, ValueError): - value = 21600.0 - return max(60.0, value) + return self._discord_access_policy().missed_message_backfill_window_seconds def _missed_message_backfill_limit(self) -> int: - configured = self.config.extra.get("missed_message_backfill") - raw = ( - configured.get("limit", 100) - if isinstance(configured, dict) - else os.getenv("DISCORD_MISSED_MESSAGE_BACKFILL_LIMIT", "100") - ) - try: - value = int(raw) - except (TypeError, ValueError): - value = 100 - return max(1, min(value, 500)) + return self._discord_access_policy().missed_message_backfill_limit def _missed_message_backfill_max_dispatches(self) -> int: - configured = self.config.extra.get("missed_message_backfill") - raw = ( - configured.get("max_dispatches", 10) - if isinstance(configured, dict) - else os.getenv("DISCORD_MISSED_MESSAGE_BACKFILL_MAX_DISPATCHES", "10") - ) - try: - value = int(raw) - except (TypeError, ValueError): - value = 10 - return max(1, min(value, 100)) + return self._discord_access_policy().missed_message_backfill_max_dispatches def _ensure_missed_message_backfill_task(self) -> asyncio.Task: """Return the active recovery task, or start one when none is running.""" @@ -4411,14 +4663,129 @@ async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: byte except OSError: pass + def _discord_access_policy(self) -> DiscordAccessPolicy: + """Return local policy while preserving legacy live config reads.""" + previous = getattr(self, "_access_policy", None) + if not is_multiplex_active(): + config = getattr(self, "config", None) or PlatformConfig(enabled=True) + policy = DiscordAccessPolicy.from_config(config) + alias_users = set(getattr(self, "_allowed_user_ids", set()) or set()) + alias_roles = set(getattr(self, "_allowed_role_ids", set()) or set()) + if previous is not None: + policy.pairing_check = previous.pairing_check + extra = ( + config.extra + if isinstance(getattr(config, "extra", None), dict) + else {} + ) + has_live_users = ( + "DISCORD_ALLOWED_USERS" in os.environ or "allow_from" in extra + ) + has_live_roles = ( + "DISCORD_ALLOWED_ROLES" in os.environ or "allowed_roles" in extra + ) + if alias_users and not has_live_users: + policy.allowed_user_ids = alias_users + if alias_roles and not has_live_roles: + policy.allowed_role_ids = alias_roles + self._access_policy = policy + self._allowed_user_ids = policy.allowed_user_ids + self._allowed_role_ids = policy.allowed_role_ids + return policy + + policy = previous + if policy is None: + if hasattr(self, "config"): + policy = DiscordAccessPolicy.from_config(self.config) + else: + policy = DiscordAccessPolicy( + allowed_user_ids=set( + getattr(self, "_allowed_user_ids", set()) or set() + ), + allowed_role_ids=set( + getattr(self, "_allowed_role_ids", set()) or set() + ), + ) + self._access_policy = policy + return policy + + def _authorization_policy_allows( + self, + user_id: str, + *, + chat_id: Optional[str] = None, + channel_keys: Optional[Iterable[str]] = None, + is_dm: bool = False, + role_authorized: bool = False, + is_bot: bool = False, + ) -> bool: + """Apply the captured Discord policy at the gateway's second auth gate. + + Channel restrictions are hard boundaries: neither user, role, bot, nor + pairing grants may bypass an explicit ignored channel or an allowed- + channel miss. + """ + policy = self._discord_access_policy() + uid = _clean_discord_id(user_id) + resolved_channel_keys = { + str(value).strip() + for value in (channel_keys or set()) + if str(value).strip() + } + if chat_id: + raw_chat_id = str(chat_id).strip() + if raw_chat_id: + resolved_channel_keys.add(raw_chat_id) + cleaned_chat_id = _clean_discord_id(raw_chat_id) + if cleaned_chat_id: + resolved_channel_keys.add(cleaned_chat_id) + + channel_allowed = False + if not is_dm: + ignored = policy.ignored_channel_keys + if "*" in ignored or bool(resolved_channel_keys & ignored): + return False + + allowed_channels = policy.allowed_channel_keys + if allowed_channels: + channel_allowed = "*" in allowed_channels or bool( + resolved_channel_keys & allowed_channels + ) + if not channel_allowed: + return False + + if is_bot: + return policy.allow_bots in {"mentions", "all"} + if policy.allow_all_users or policy.gateway_allow_all_users: + return True + if "*" in policy.allowed_user_ids or uid in policy.allowed_user_ids: + return True + if ( + "*" in policy.gateway_allowed_user_ids + or uid in policy.gateway_allowed_user_ids + ): + return True + if role_authorized: + return True + if self._is_pairing_approved_user(uid): + return True + if not policy.allowed_user_ids and not policy.allowed_role_ids: + return channel_allowed + return False + + def set_pairing_check( + self, callback: Optional[Callable[[str], bool]] + ) -> None: + """Bind pairing authorization to this adapter's owning profile.""" + self._access_policy.pairing_check = callback + def _discord_channel_ids_allowed(self, channel_ids: set[str]) -> bool: - """True when *channel_ids* intersect ``DISCORD_ALLOWED_CHANNELS``.""" + """True when *channel_ids* intersect the profile's allowed channels.""" if not channel_ids: return False - allowed_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "").strip() - if not allowed_raw: + allowed = self._discord_access_policy().allowed_channel_keys + if not allowed: return False - allowed = {c.strip() for c in allowed_raw.split(",") if c.strip()} if "*" in allowed: return True return bool(channel_ids & allowed) @@ -4428,6 +4795,14 @@ def _is_pairing_approved_user(self, user_id: str) -> bool: user_id = str(user_id or "").strip() if not user_id: return False + policy = self._discord_access_policy() + if policy.pairing_check is not None: + try: + return bool(policy.pairing_check(user_id)) + except Exception: + return False + if is_multiplex_active(): + return False try: from gateway.pairing import PairingStore @@ -4435,6 +4810,69 @@ def _is_pairing_approved_user(self, user_id: str) -> bool: except Exception: return False + def _has_allowed_role( + self, + user_id: str, + author=None, + *, + guild=None, + is_dm: bool = False, + ) -> bool: + """Return whether the sender actually matches this profile's role policy.""" + allowed_roles = self._discord_access_policy().allowed_role_ids + if not allowed_roles: + return False + if is_dm or guild is None: + if is_multiplex_active(): + dm_guild_id = self._discord_access_policy().dm_role_auth_guild_id + else: + dm_guild_id = _read_dm_role_auth_guild() + if dm_guild_id is None or self._client is None: + return False + dm_guild = self._client.get_guild(dm_guild_id) + if dm_guild is None: + return False + try: + uid_int = int(user_id) + except (TypeError, ValueError): + return False + member = dm_guild.get_member(uid_int) + roles = getattr(member, "roles", None) or [] if member else [] + return any(getattr(role, "id", None) in allowed_roles for role in roles) + + direct_roles = getattr(author, "roles", None) if author is not None else None + author_guild = getattr(author, "guild", None) + if direct_roles and (author_guild is None or author_guild.id == guild.id): + if any(getattr(role, "id", None) in allowed_roles for role in direct_roles): + return True + try: + uid_int = int(user_id) + except (TypeError, ValueError): + return False + member = guild.get_member(uid_int) + roles = getattr(member, "roles", None) or [] if member else [] + return any(getattr(role, "id", None) in allowed_roles for role in roles) + + def _source_has_allowed_role( + self, + user_id: str, + *, + guild_id: Optional[str] = None, + is_dm: bool = False, + ) -> bool: + """Revalidate a persisted source against current cached role membership.""" + if is_dm: + return self._has_allowed_role(user_id, is_dm=True) + if self._client is None or not guild_id: + return False + try: + guild = self._client.get_guild(int(guild_id)) + except (TypeError, ValueError): + return False + if guild is None: + return False + return self._has_allowed_role(user_id, guild=guild) + def _is_allowed_user( self, user_id: str, @@ -4471,8 +4909,9 @@ def _is_allowed_user( # ``getattr`` fallbacks here guard against test fixtures that build # an adapter via ``object.__new__(DiscordAdapter)`` and skip __init__ # (see AGENTS.md pitfall #17 — same pattern as gateway.run). - allowed_users = getattr(self, "_allowed_user_ids", set()) - allowed_roles = getattr(self, "_allowed_role_ids", set()) + policy = self._discord_access_policy() + allowed_users = policy.allowed_user_ids + allowed_roles = policy.allowed_role_ids has_users = bool(allowed_users) has_roles = bool(allowed_roles) @@ -4483,10 +4922,18 @@ def _is_allowed_user( if self._is_pairing_approved_user(user_id): return True + gateway_users = policy.gateway_allowed_user_ids + cleaned_user_id = _clean_discord_id(user_id) + if ( + policy.gateway_allow_all_users + or "*" in gateway_users + or cleaned_user_id in gateway_users + ): + return True + if not has_users and not has_roles: - if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: - return True - if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + policy = self._discord_access_policy() + if policy.allow_all_users or policy.gateway_allow_all_users: return True # Channel-scoped guild access requires validated channel context. # Do not treat DISCORD_ALLOWED_CHANNELS alone as a user-wide bypass @@ -4509,61 +4956,25 @@ def _is_allowed_user( if not has_roles: return False - # DM path: roles require explicit opt-in via - # ``discord.dm_role_auth_guild`` in config.yaml. Without this, a - # user with the configured role in ANY mutual guild could DM the - # bot and bypass the allowlist (cross-guild leakage). - if is_dm or guild is None: - dm_guild_id = _read_dm_role_auth_guild() - if dm_guild_id is None: - return False - if self._client is None: - return False - dm_guild = self._client.get_guild(dm_guild_id) - if dm_guild is None: - return False - try: - uid_int = int(user_id) - except (TypeError, ValueError): - return False - m = dm_guild.get_member(uid_int) - if m is None: - return False - m_roles = getattr(m, "roles", None) or [] - return any(getattr(r, "id", None) in allowed_roles for r in m_roles) - - # Guild path: role check is scoped to THIS guild only. - # 1) Prefer the direct Member object passed in (correct guild by construction). - direct_roles = getattr(author, "roles", None) if author is not None else None - author_guild = getattr(author, "guild", None) - if direct_roles and (author_guild is None or author_guild.id == guild.id): - if any(getattr(r, "id", None) in allowed_roles for r in direct_roles): - return True - # 2) Fallback: resolve the Member in the message's guild only — NEVER - # scan other mutual guilds (that is the cross-guild bypass bug). - try: - uid_int = int(user_id) - except (TypeError, ValueError): - return False - m = guild.get_member(uid_int) - if m is None: - return False - m_roles = getattr(m, "roles", None) or [] - return any(getattr(r, "id", None) in allowed_roles for r in m_roles) + return self._has_allowed_role( + user_id, + author, + guild=guild, + is_dm=is_dm, + ) def _warn_if_fail_closed_default(self) -> None: """Log once when Discord is rejecting traffic with no allowlist set.""" if getattr(self, "_warned_fail_closed_default", False): return - allowed_users = getattr(self, "_allowed_user_ids", set()) or set() - allowed_roles = getattr(self, "_allowed_role_ids", set()) or set() + policy = self._discord_access_policy() + allowed_users = policy.allowed_user_ids + allowed_roles = policy.allowed_role_ids if allowed_users or allowed_roles: return - if os.getenv("DISCORD_ALLOWED_CHANNELS", "").strip(): + if policy.allowed_channel_keys: return - if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: - return - if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + if policy.allow_all_users or policy.gateway_allow_all_users: return self._warned_fail_closed_default = True logger.warning( @@ -4613,6 +5024,7 @@ def _evaluate_slash_authorization( """ chan_obj = getattr(interaction, "channel", None) in_dm = isinstance(chan_obj, discord.DMChannel) if chan_obj is not None else False + policy = self._discord_access_policy() channel_ids: set = set() channel_keys: set = set() @@ -4642,9 +5054,8 @@ def _evaluate_slash_authorization( else None, ) - allowed_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "") - if allowed_raw: - allowed = {c.strip() for c in allowed_raw.split(",") if c.strip()} + allowed = policy.allowed_channel_keys + if allowed: if "*" not in allowed: if not channel_ids: # Channel policy is configured but the interaction @@ -4659,16 +5070,15 @@ def _evaluate_slash_authorization( # Ignored beats allowed: even when a thread's parent channel # is on the allowlist, an explicit DISCORD_IGNORED_CHANNELS # entry on the thread or its parent rejects the interaction. - ignored_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "") - if ignored_raw and channel_ids: - ignored = {c.strip() for c in ignored_raw.split(",") if c.strip()} + ignored = policy.ignored_channel_keys + if ignored and channel_ids: if "*" in ignored or (channel_keys & ignored): return (False, "channel in DISCORD_IGNORED_CHANNELS") # ── User / role allowlist (mirrors on_message line 681) ── user = getattr(interaction, "user", None) - allowed_users = getattr(self, "_allowed_user_ids", set()) or set() - allowed_roles = getattr(self, "_allowed_role_ids", set()) or set() + allowed_users = policy.allowed_user_ids + allowed_roles = policy.allowed_role_ids if user is None or getattr(user, "id", None) is None: # No identifiable user — fail closed even with allow-all opt-in. # Downstream slash handlers (_build_slash_event, etc.) require @@ -5126,8 +5536,9 @@ async def _resolve_allowed_usernames(self) -> None: Resolve non-numeric entries in DISCORD_ALLOWED_USERS to Discord user IDs. Users can specify usernames (e.g. "teknium") or display names instead of - raw numeric IDs. After resolution, the env var and internal set are updated - so authorization checks work with IDs only. + raw numeric IDs. After resolution, the adapter-local policy is updated so + authorization checks use IDs only. Single-profile mode also preserves the + legacy process-environment rewrite; multiplex mode never does. """ if not self._allowed_user_ids or not self._client: return @@ -5188,11 +5599,14 @@ async def _resolve_allowed_usernames(self) -> None: if to_resolve: print(f"[{self.name}] Could not resolve usernames: {', '.join(to_resolve)}") - # Update internal set and env var so gateway auth checks use IDs + # Update the adapter-local snapshot. Process-global write-back is kept + # only for legacy single-profile callers; it would leak between profiles. self._allowed_user_ids = numeric_ids - os.environ["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids)) + self._access_policy.allowed_user_ids = set(numeric_ids) + if not is_multiplex_active(): + os.environ["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids)) if resolved_count: - print(f"[{self.name}] Updated DISCORD_ALLOWED_USERS with {resolved_count} resolved ID(s)") + print(f"[{self.name}] Resolved {resolved_count} allowed username(s) to Discord IDs") def format_message(self, content: str) -> str: """Format message for Discord. @@ -5834,6 +6248,23 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes # Get channel topic (if available). # For forum threads, inherit the parent forum's topic. chat_topic = self._get_effective_topic(interaction.channel, is_thread=is_thread) + parent_chat_id = ( + self._get_parent_channel_id(interaction.channel) if is_thread else None + ) + channel_keys = ( + self._discord_channel_keys_from_channel( + interaction.channel, parent_chat_id + ) + if not is_dm + else set() + ) + guild = getattr(interaction, "guild", None) + role_authorized = self._has_allowed_role( + str(interaction.user.id), + interaction.user, + guild=guild, + is_dm=is_dm, + ) source = self.build_source( chat_id=str(interaction.channel_id), @@ -5843,6 +6274,10 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes user_name=interaction.user.display_name, thread_id=thread_id, chat_topic=chat_topic, + guild_id=str(guild.id) if guild else None, + parent_chat_id=parent_chat_id, + authorization_channel_keys=channel_keys, + role_authorized=role_authorized, ) msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT @@ -5928,6 +6363,20 @@ async def _dispatch_thread_session( # Inherit forum topic when the thread was created inside a forum channel. _chan = getattr(interaction, "channel", None) chat_topic = self._get_effective_topic(_chan, is_thread=True) if _chan else None + _parent_channel = self._thread_parent_channel(_chan) + _parent_id = str(getattr(_parent_channel, "id", "") or "") + channel_keys = self._discord_channel_keys_from_channel( + _parent_channel or _chan + ) + channel_keys.update({thread_id, thread_name, f"#{thread_name}"}) + guild = getattr(interaction, "guild", None) + guild_id = getattr(guild, "id", None) + role_authorized = self._has_allowed_role( + str(interaction.user.id), + interaction.user, + guild=guild, + is_dm=False, + ) source = self.build_source( chat_id=thread_id, @@ -5937,10 +6386,12 @@ async def _dispatch_thread_session( user_name=interaction.user.display_name, thread_id=thread_id, chat_topic=chat_topic, + guild_id=str(guild_id) if guild_id is not None else None, + parent_chat_id=_parent_id or None, + authorization_channel_keys=channel_keys, + role_authorized=role_authorized, ) - _parent_channel = self._thread_parent_channel(getattr(interaction, "channel", None)) - _parent_id = str(getattr(_parent_channel, "id", "") or "") _skills = self._resolve_channel_skills(thread_id, _parent_id or None) _channel_prompt = self._resolve_channel_prompt(thread_id, _parent_id or None) event = MessageEvent( @@ -5972,12 +6423,7 @@ def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None) def _discord_require_mention(self) -> bool: """Return whether Discord channel messages require a bot mention.""" - configured = self.config.extra.get("require_mention") - if configured is not None: - if isinstance(configured, str): - return configured.lower() not in {"false", "0", "no", "off"} - return bool(configured) - return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"} + return self._discord_access_policy().require_mention def _discord_allow_any_attachment(self) -> bool: """Return whether Discord attachments bypass the SUPPORTED_DOCUMENT_TYPES allowlist. @@ -6040,22 +6486,12 @@ def _discord_free_response_channels(self) -> set: A single ``"*"`` entry (either from a list or a comma-separated string) is preserved in the returned set so callers can short-circuit on wildcard membership, consistent with ``allowed_channels``. + + Non-list scalar values are accepted as well. YAML may parse a bare + numeric channel ID as an integer, so policy capture normalizes it to a + string before this method returns the adapter-local snapshot. """ - raw = self.config.extra.get("free_response_channels") - if raw is None: - raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "") - if isinstance(raw, list): - return {str(part).strip() for part in raw if str(part).strip()} - # Coerce non-list scalars (str/int/float) to str before splitting. - # YAML parses a bare numeric value such as - # `free_response_channels: 1491973769726791812` as int, which was - # previously falling through the isinstance(str) branch and silently - # returning an empty set. str() here accepts whatever scalar the YAML - # loader hands us without changing existing string/CSV semantics. - s = str(raw).strip() if raw is not None else "" - if s: - return {part.strip() for part in s.split(",") if part.strip()} - return set() + return set(self._discord_access_policy().free_response_channel_keys) def _raw_mentioned_user_ids(self, message: Any) -> set: """Extract Discord user-mention IDs directly from raw message content. @@ -6107,17 +6543,7 @@ def _discord_bots_require_inline_mention(self) -> bool: Config: ``discord.bots_require_inline_mention`` (or env ``DISCORD_BOTS_REQUIRE_INLINE_MENTION``). """ - configured = self.config.extra.get("bots_require_inline_mention") - if configured is not None: - if isinstance(configured, str): - return configured.lower() in {"true", "1", "yes", "on"} - return bool(configured) - return os.getenv("DISCORD_BOTS_REQUIRE_INLINE_MENTION", "false").lower() in { - "true", - "1", - "yes", - "on", - } + return self._discord_access_policy().bots_require_inline_mention def _discord_channel_keys(self, message: Any, parent_channel_id: Optional[str] = None) -> set[str]: """Return channel identifiers accepted by Discord channel config gates. @@ -6175,21 +6601,15 @@ def _discord_thread_require_mention(self) -> bool: one to only fire on explicit @mention, avoiding bot-to-bot loops or unwanted cross-replies. """ - configured = self.config.extra.get("thread_require_mention") - if configured is not None: - if isinstance(configured, str): - return configured.lower() not in {"false", "0", "no", "off"} - return bool(configured) - return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} + return self._discord_access_policy().thread_require_mention + + def _discord_ignore_no_mention(self) -> bool: + """Whether mixed-mention messages without this bot should be ignored.""" + return self._discord_access_policy().ignore_no_mention def _discord_history_backfill(self) -> bool: """Return whether history backfill is enabled for shared sessions.""" - configured = self.config.extra.get("history_backfill") - if configured is not None: - if isinstance(configured, str): - return configured.lower() not in {"false", "0", "no", "off"} - return bool(configured) - return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in {"true", "1", "yes"} + return self._discord_access_policy().history_backfill_enabled def _discord_history_backfill_limit(self) -> int: """Return the max number of messages to scan backwards for context. @@ -6199,17 +6619,7 @@ def _discord_history_backfill_limit(self) -> int: limit is a safety cap for cold starts and long gaps where no prior bot message exists in recent history. """ - configured = self.config.extra.get("history_backfill_limit") - if configured is not None: - try: - return int(configured) - except (ValueError, TypeError): - pass - raw = os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT", "50") - try: - return int(raw) - except (ValueError, TypeError): - return 50 + return self._discord_access_policy().history_backfill_limit async def _fetch_channel_context( self, @@ -6243,7 +6653,7 @@ async def _fetch_channel_context( return "" # Determine which bot messages to include in context - allow_bots_raw = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() + allow_bots_raw = self._discord_access_policy().allow_bots include_other_bots = allow_bots_raw != "none" # Use the in-memory cache to narrow the fetch window on hot paths. @@ -6775,13 +7185,17 @@ def _self_contained_prompt_content( def _approval_mention_content(self) -> Optional[str]: """Return user mentions for approval prompts when explicitly enabled. - Gated on ``discord.approval_mentions`` in config.yaml (bridged to the - ``DISCORD_APPROVAL_MENTIONS`` env var). Only numeric allowlist entries - can be mentioned; default off avoids surprise pings. + Gated on this adapter's captured ``discord.approval_mentions`` value. + Only numeric allowlist entries can be mentioned; default off avoids + surprise pings. """ - if not _env_bool("DISCORD_APPROVAL_MENTIONS", False): + if not self._discord_access_policy().approval_mentions: return None - user_ids = sorted(uid for uid in self._allowed_user_ids if str(uid).isdigit()) + user_ids = sorted( + uid + for uid in self._discord_access_policy().allowed_user_ids + if str(uid).isdigit() + ) if not user_ids: return None return " ".join(f"<@{uid}>" for uid in user_ids) @@ -6864,6 +7278,7 @@ async def send_exec_approval( session_key=session_key, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, + access_policy=self._discord_access_policy(), require_admin=require_admin, admin_user_ids=admin_user_ids, allow_permanent=allow_permanent, @@ -6924,6 +7339,7 @@ async def send_slash_confirm( confirm_id=confirm_id, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, + access_policy=self._discord_access_policy(), ) msg = await channel.send(content=content, embed=embed, view=view) @@ -7031,6 +7447,7 @@ def _flatten_choice(c): clarify_id=clarify_id, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, + access_policy=self._discord_access_policy(), ) else: embed.add_field( @@ -7087,6 +7504,7 @@ async def send_update_prompt( session_key=session_key, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, + access_policy=self._discord_access_policy(), ) # Mirror the prompt in plain content — embeds are invisible on # some clients (see send_exec_approval). @@ -7153,6 +7571,7 @@ async def send_model_picker( on_model_selected=on_model_selected, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, + access_policy=self._discord_access_policy(), ) msg = await channel.send(embed=embed, view=view) @@ -7201,6 +7620,7 @@ async def send_choice_picker( on_choice_selected=on_choice_selected, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, + access_policy=self._discord_access_policy(), ) msg = await channel.send(embed=embed, view=view) @@ -7444,23 +7864,28 @@ async def _handle_message( normalized_content = normalized_content.replace(f"<@{self._client.user.id}>", "").strip() normalized_content = normalized_content.replace(f"<@!{self._client.user.id}>", "").strip() message.content = normalized_content - if not isinstance(message.channel, discord.DMChannel): + is_dm = isinstance(message.channel, discord.DMChannel) + channel_keys: set[str] = set() + require_mention = False + is_free_channel = False + in_bot_thread = False + if not is_dm: channel_ids = {str(message.channel.id)} if parent_channel_id: channel_ids.add(parent_channel_id) channel_keys = self._discord_channel_keys(message, parent_channel_id) + policy = self._discord_access_policy() + # Check allowed channels - if set, only respond in these channels - allowed_channels_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "") - if allowed_channels_raw: - allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()} + allowed_channels = policy.allowed_channel_keys + if allowed_channels: if "*" not in allowed_channels and not (channel_keys & allowed_channels): logger.debug("[%s] Ignoring message in non-allowed channel: %s", self.name, channel_keys) return False # Check ignored channels - never respond even when mentioned - ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "") - ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()} + ignored_channels = policy.ignored_channel_keys if "*" in ignored_channels or (channel_keys & ignored_channels): logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_keys) return False @@ -7614,6 +8039,7 @@ async def _handle_message( is_bot=getattr(message.author, "bot", False), guild_id=str(guild.id) if guild else None, parent_chat_id=parent_channel_id, + authorization_channel_keys=channel_keys if not is_dm else set(), message_id=str(message.id), role_authorized=role_authorized, auto_thread_created=auto_threaded_channel is not None, @@ -7987,6 +8413,7 @@ def _component_check_auth( interaction, allowed_user_ids: Optional[set], allowed_role_ids: Optional[set], + access_policy: Optional[DiscordAccessPolicy] = None, ) -> bool: """Shared user-or-role OR semantics for component view button clicks. @@ -8010,18 +8437,69 @@ def _component_check_auth( if user is None or getattr(user, "id", None) is None: return False - if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: - return True - if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + if access_policy is None: + # Backward-compatible path for direct helper/view construction in + # single-profile callers. Multiplex direct construction has no owning + # profile snapshot and therefore fails closed instead of reading globals. + if is_multiplex_active(): + access_policy = DiscordAccessPolicy() + else: + access_policy = DiscordAccessPolicy( + allow_all_users=_access_bool( + get_secret("DISCORD_ALLOW_ALL_USERS", "") + ), + gateway_allowed_user_ids=_coerce_access_values( + get_secret("GATEWAY_ALLOWED_USERS", "") + ), + gateway_allow_all_users=_access_bool( + get_secret("GATEWAY_ALLOW_ALL_USERS", "") + ), + ) + + channel = getattr(interaction, "channel", None) + channel_keys: set[str] = set() + for candidate in (channel, getattr(channel, "parent", None)): + if candidate is None: + continue + candidate_id = getattr(candidate, "id", None) + if candidate_id is not None: + channel_keys.add(str(candidate_id).strip()) + candidate_name = str(getattr(candidate, "name", "") or "").strip() + if candidate_name: + bare_name = candidate_name.removeprefix("#") + channel_keys.update({bare_name, f"#{bare_name}"}) + interaction_channel_id = getattr(interaction, "channel_id", None) + if interaction_channel_id is not None: + channel_keys.add(str(interaction_channel_id).strip()) + + is_dm = bool( + discord is not None + and channel is not None + and isinstance(channel, discord.DMChannel) + ) + has_channel_boundaries = bool( + access_policy.allowed_channel_keys or access_policy.ignored_channel_keys + ) + if has_channel_boundaries and not is_dm and (channel is None or not channel_keys): + return False + channel_allowed = False + if channel_keys and not is_dm: + if "*" in access_policy.ignored_channel_keys or bool( + channel_keys & access_policy.ignored_channel_keys + ): + return False + if access_policy.allowed_channel_keys and not ( + "*" in access_policy.allowed_channel_keys + or bool(channel_keys & access_policy.allowed_channel_keys) + ): + return False + channel_allowed = bool(access_policy.allowed_channel_keys) + + if access_policy.allow_all_users or access_policy.gateway_allow_all_users: return True user_set = {str(uid).strip() for uid in (allowed_user_ids or set()) if str(uid).strip()} - global_allowed = { - uid.strip() - for uid in os.getenv("GATEWAY_ALLOWED_USERS", "").split(",") - if uid.strip() - } - user_set.update(global_allowed) + user_set.update(access_policy.gateway_allowed_user_ids) role_set = set(allowed_role_ids or set()) has_users = bool(user_set) has_roles = bool(role_set) @@ -8051,17 +8529,27 @@ def _component_check_auth( if user_role_ids & role_set: return True - # Check pairing store — mirrors ``authz_mixin._check_authorization`` - # so users approved via ``hermes pairing approve`` can interact with - # component buttons even without DISCORD_ALLOWED_USERS set. + # Pairing is injected by GatewayRunner so multiplex views use the owning + # profile's store. Retain the historical global-store fallback only for + # single-profile/direct construction. if uid: - try: - from gateway.pairing import PairingStore - store = PairingStore() - if store.is_approved("discord", uid): - return True - except Exception: - pass + if access_policy.pairing_check is not None: + try: + if access_policy.pairing_check(uid): + return True + except Exception: + return False + elif not is_multiplex_active(): + try: + from gateway.pairing import PairingStore + + if PairingStore().is_approved("discord", uid): + return True + except Exception: + pass + + if not has_users and not has_roles: + return channel_allowed return False @@ -8134,11 +8622,13 @@ def __init__( allow_permanent: bool = True, allow_session: bool = True, smart_denied: bool = False, + access_policy: Optional[DiscordAccessPolicy] = None, ): super().__init__(timeout=_read_discord_prompt_timeout()) self.session_key = session_key self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() + self.access_policy = access_policy # Opt-in admin gate for exec approval (default off → user-scope, # the v0.16-restored behavior). When on, the clicker must be in # ``admin_user_ids`` on top of passing the base admission check. @@ -8164,7 +8654,10 @@ def _check_auth(self, interaction: discord.Interaction) -> bool: can approve (logged once so the misconfiguration is visible). """ if not _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, + interaction, + self.allowed_user_ids, + self.allowed_role_ids, + self.access_policy, ): return False if not self.require_admin: @@ -8301,17 +8794,22 @@ def __init__( confirm_id: str, allowed_user_ids: set, allowed_role_ids: Optional[set] = None, + access_policy: Optional[DiscordAccessPolicy] = None, ): super().__init__(timeout=_read_discord_prompt_timeout()) self.session_key = session_key self.confirm_id = confirm_id self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() + self.access_policy = access_policy self.resolved = False def _check_auth(self, interaction: discord.Interaction) -> bool: return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, + interaction, + self.allowed_user_ids, + self.allowed_role_ids, + self.access_policy, ) async def _resolve( @@ -8406,16 +8904,21 @@ def __init__( session_key: str, allowed_user_ids: set, allowed_role_ids: Optional[set] = None, + access_policy: Optional[DiscordAccessPolicy] = None, ): super().__init__(timeout=_read_discord_prompt_timeout()) self.session_key = session_key self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() + self.access_policy = access_policy self.resolved = False def _check_auth(self, interaction: discord.Interaction) -> bool: return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, + interaction, + self.allowed_user_ids, + self.allowed_role_ids, + self.access_policy, ) async def _respond( @@ -8505,6 +9008,7 @@ def __init__( on_model_selected, allowed_user_ids: set, allowed_role_ids: Optional[set] = None, + access_policy: Optional[DiscordAccessPolicy] = None, ): super().__init__(timeout=120) self.providers = providers @@ -8514,6 +9018,7 @@ def __init__( self.on_model_selected = on_model_selected self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() + self.access_policy = access_policy self.resolved = False self._selected_provider: str = "" self._pending_expensive_model: str = "" @@ -8522,7 +9027,10 @@ def __init__( def _check_auth(self, interaction: discord.Interaction) -> bool: return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, + interaction, + self.allowed_user_ids, + self.allowed_role_ids, + self.access_policy, ) def _build_provider_select(self): @@ -8832,12 +9340,14 @@ def __init__( on_choice_selected, allowed_user_ids: set, allowed_role_ids: Optional[set] = None, + access_policy: Optional[DiscordAccessPolicy] = None, ): super().__init__(timeout=120) self.choices = list(choices)[:25] # Discord select cap self.on_choice_selected = on_choice_selected self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() + self.access_policy = access_policy self.resolved = False self._message = None @@ -8862,7 +9372,10 @@ def __init__( def _check_auth(self, interaction: discord.Interaction) -> bool: return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, + interaction, + self.allowed_user_ids, + self.allowed_role_ids, + self.access_policy, ) async def _on_select(self, interaction: discord.Interaction): @@ -8930,12 +9443,14 @@ def __init__( clarify_id: str, allowed_user_ids: set, allowed_role_ids: Optional[set] = None, + access_policy: Optional[DiscordAccessPolicy] = None, ): super().__init__(timeout=_read_discord_prompt_timeout()) self.choices = list(choices)[:24] self.clarify_id = clarify_id self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() + self.access_policy = access_policy self.resolved = False for index, choice in enumerate(self.choices): @@ -8999,7 +9514,10 @@ def __init__( def _check_auth(self, interaction: "discord.Interaction") -> bool: return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, + interaction, + self.allowed_user_ids, + self.allowed_role_ids, + self.access_policy, ) def _make_choice_callback(self, index: int, choice: str): @@ -9657,11 +10175,25 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: remains only for existing callers that construct adapters without config extras. Returns canonical WebSocket liveness settings to seed that extra. """ - if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): + scope = current_secret_scope() + legacy_env_bridge = scope is None and not is_multiplex_active() + if ( + legacy_env_bridge + and "require_mention" in discord_cfg + and not os.getenv("DISCORD_REQUIRE_MENTION") + ): os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower() - if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"): + if ( + legacy_env_bridge + and "thread_require_mention" in discord_cfg + and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION") + ): os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower() - if "bots_require_inline_mention" in discord_cfg and not os.getenv("DISCORD_BOTS_REQUIRE_INLINE_MENTION"): + if ( + legacy_env_bridge + and "bots_require_inline_mention" in discord_cfg + and not os.getenv("DISCORD_BOTS_REQUIRE_INLINE_MENTION") + ): os.environ["DISCORD_BOTS_REQUIRE_INLINE_MENTION"] = str(discord_cfg["bots_require_inline_mention"]).lower() platforms_cfg = yaml_cfg.get("platforms") platform_extra_cfg = {} @@ -9671,45 +10203,158 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: candidate_extra = discord_platform_cfg.get("extra") if isinstance(candidate_extra, dict): platform_extra_cfg = candidate_extra + seeded_extra = {} + + def _yaml_policy_value(key: str) -> tuple[bool, Any]: + if key in discord_cfg: + return True, discord_cfg[key] + if key in platform_extra_cfg: + return True, platform_extra_cfg[key] + return False, None + + # Persist the effective profile-local policy while configuration is loaded + # under that profile's secret scope. Primary adapter construction and + # reconnect happen after the scope exits, so the config object is the stable + # hand-off boundary. Presence checks deliberately preserve explicit empty + # strings/lists and explicit false booleans. + scoped_bool_parsers = { + "DISCORD_REQUIRE_MENTION": _default_true_bool, + "DISCORD_IGNORE_NO_MENTION": _access_bool, + "DISCORD_THREAD_REQUIRE_MENTION": _legacy_on_bool, + "DISCORD_ALLOW_ALL_USERS": _access_bool, + "DISCORD_HISTORY_BACKFILL": _access_bool, + "DISCORD_APPROVAL_MENTIONS": _legacy_on_bool, + "DISCORD_BOTS_REQUIRE_INLINE_MENTION": _legacy_on_bool, + "GATEWAY_ALLOW_ALL_USERS": _access_bool, + } + for env_name, extra_key, yaml_key in ( + ("DISCORD_ALLOWED_USERS", "allow_from", "allow_from"), + ("DISCORD_ALLOWED_ROLES", "allowed_roles", "allowed_roles"), + ("DISCORD_ALLOWED_CHANNELS", "allowed_channels", "allowed_channels"), + ("DISCORD_IGNORED_CHANNELS", "ignored_channels", "ignored_channels"), + ( + "DISCORD_FREE_RESPONSE_CHANNELS", + "free_response_channels", + "free_response_channels", + ), + ("DISCORD_REQUIRE_MENTION", "require_mention", "require_mention"), + ( + "DISCORD_IGNORE_NO_MENTION", + "ignore_no_mention", + "ignore_no_mention", + ), + ( + "DISCORD_THREAD_REQUIRE_MENTION", + "thread_require_mention", + "thread_require_mention", + ), + ("DISCORD_ALLOW_ALL_USERS", "allow_all_users", "allow_all_users"), + ("DISCORD_ALLOW_BOTS", "allow_bots", "allow_bots"), + ( + "DISCORD_HISTORY_BACKFILL", + "history_backfill", + "history_backfill", + ), + ( + "DISCORD_HISTORY_BACKFILL_LIMIT", + "history_backfill_limit", + "history_backfill_limit", + ), + ( + "DISCORD_APPROVAL_MENTIONS", + "approval_mentions", + "approval_mentions", + ), + ( + "DISCORD_BOTS_REQUIRE_INLINE_MENTION", + "bots_require_inline_mention", + "bots_require_inline_mention", + ), + ("GATEWAY_ALLOWED_USERS", "gateway_allowed_users", None), + ("GATEWAY_ALLOW_ALL_USERS", "gateway_allow_all_users", None), + ): + if scope is not None and env_name in scope: + value = scope[env_name] + parser = scoped_bool_parsers.get(env_name) + seeded_extra[extra_key] = parser(value) if parser else value + continue + if yaml_key is not None: + present, value = _yaml_policy_value(yaml_key) + if present: + seeded_extra[extra_key] = value + + dm_guild_present, dm_guild_value = _yaml_policy_value("dm_role_auth_guild") + if dm_guild_present: + seeded_extra["dm_role_auth_guild"] = dm_guild_value + allowed_users_cfg = ( discord_cfg["allow_from"] if "allow_from" in discord_cfg else platform_extra_cfg.get("allow_from") ) - if allowed_users_cfg is not None and not os.getenv("DISCORD_ALLOWED_USERS"): - if isinstance(allowed_users_cfg, list): - allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg) - os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg) + if allowed_users_cfg is not None: + seeded_extra.setdefault("allow_from", allowed_users_cfg) + if legacy_env_bridge and not os.getenv("DISCORD_ALLOWED_USERS"): + allowed_users_env = allowed_users_cfg + if isinstance(allowed_users_env, list): + allowed_users_env = ",".join(str(v) for v in allowed_users_env) + os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_env) approval_mentions_cfg = ( discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg else platform_extra_cfg.get("approval_mentions") ) - if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"): - os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower() + if approval_mentions_cfg is not None: + seeded_extra.setdefault("approval_mentions", approval_mentions_cfg) + if legacy_env_bridge and not os.getenv("DISCORD_APPROVAL_MENTIONS"): + os.environ["DISCORD_APPROVAL_MENTIONS"] = str( + approval_mentions_cfg + ).lower() frc = discord_cfg.get("free_response_channels") - if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): - if isinstance(frc, list): - frc = ",".join(str(v) for v in frc) - os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) + if frc is not None: + seeded_extra.setdefault("free_response_channels", frc) + if legacy_env_bridge and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): + free_response_env = frc + if isinstance(free_response_env, list): + free_response_env = ",".join(str(v) for v in free_response_env) + os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(free_response_env) if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"): os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower() - seeded_extra = {} - backfill_cfg = discord_cfg.get("missed_message_backfill") - if isinstance(backfill_cfg, dict): - seeded_extra["missed_message_backfill"] = dict(backfill_cfg) + backfill_cfg = ( + discord_cfg["missed_message_backfill"] + if "missed_message_backfill" in discord_cfg + else platform_extra_cfg.get("missed_message_backfill") + ) + seeded_backfill = dict(backfill_cfg) if isinstance(backfill_cfg, dict) else {} + for env_name, key in ( + ("DISCORD_MISSED_MESSAGE_BACKFILL", "enabled"), + ("DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", "channels"), + ("DISCORD_MISSED_MESSAGE_BACKFILL_WINDOW_SECONDS", "window_seconds"), + ("DISCORD_MISSED_MESSAGE_BACKFILL_LIMIT", "limit"), + ("DISCORD_MISSED_MESSAGE_BACKFILL_MAX_DISPATCHES", "max_dispatches"), + ): + if scope is not None and env_name in scope: + seeded_backfill[key] = scope[env_name] + if seeded_backfill: + seeded_extra["missed_message_backfill"] = seeded_backfill # ignored_channels: channels where bot never responds (even when mentioned) ic = discord_cfg.get("ignored_channels") - if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"): - if isinstance(ic, list): - ic = ",".join(str(v) for v in ic) - os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic) + if ic is not None: + seeded_extra.setdefault("ignored_channels", ic) + if legacy_env_bridge and not os.getenv("DISCORD_IGNORED_CHANNELS"): + ignored_channels_env = ic + if isinstance(ignored_channels_env, list): + ignored_channels_env = ",".join(str(v) for v in ignored_channels_env) + os.environ["DISCORD_IGNORED_CHANNELS"] = str(ignored_channels_env) # allowed_channels: if set, bot ONLY responds in these channels (whitelist) ac = discord_cfg.get("allowed_channels") - if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"): - if isinstance(ac, list): - ac = ",".join(str(v) for v in ac) - os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac) + if ac is not None: + seeded_extra.setdefault("allowed_channels", ac) + if legacy_env_bridge and not os.getenv("DISCORD_ALLOWED_CHANNELS"): + allowed_channels_env = ac + if isinstance(allowed_channels_env, list): + allowed_channels_env = ",".join(str(v) for v in allowed_channels_env) + os.environ["DISCORD_ALLOWED_CHANNELS"] = str(allowed_channels_env) # no_thread_channels: channels where bot responds directly without creating thread ntc = discord_cfg.get("no_thread_channels") if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"): @@ -9719,11 +10364,17 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: # history_backfill: recover missed channel messages for shared sessions # when require_mention is active. Fetches messages between bot turns # and prepends them to the user message for context. - if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"): - os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower() + if "history_backfill" in discord_cfg: + seeded_extra.setdefault("history_backfill", discord_cfg["history_backfill"]) + if legacy_env_bridge and not os.getenv("DISCORD_HISTORY_BACKFILL"): + os.environ["DISCORD_HISTORY_BACKFILL"] = str( + discord_cfg["history_backfill"] + ).lower() hbl = discord_cfg.get("history_backfill_limit") - if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"): - os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl) + if hbl is not None: + seeded_extra.setdefault("history_backfill_limit", hbl) + if legacy_env_bridge and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"): + os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl) # allow_mentions: granular control over what the bot can ping. # Safe defaults (no @everyone/roles) are applied in the adapter; # these YAML keys only override when set and let users opt back diff --git a/tests/gateway/test_discord_multiplex_access_policy.py b/tests/gateway/test_discord_multiplex_access_policy.py new file mode 100644 index 0000000000000..803403fa7f79c --- /dev/null +++ b/tests/gateway/test_discord_multiplex_access_policy.py @@ -0,0 +1,1544 @@ +"""Regression tests for Discord authorization isolation in multiplex mode.""" + +from __future__ import annotations + +import os +import weakref +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agent import secret_scope as ss +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.session import SessionSource +import plugins.platforms.discord.adapter as discord_adapter_module +from plugins.platforms.discord.adapter import ( + ChoicePickerView, + ClarifyChoiceView, + DiscordAdapter, + ExecApprovalView, + ModelPickerView, + SlashConfirmView, + UpdatePromptView, + _apply_yaml_config, + discord, +) + + +@pytest.fixture(autouse=True) +def _isolate_multiplex_state(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + for name in ( + "DISCORD_ALLOWED_USERS", + "DISCORD_ALLOWED_ROLES", + "DISCORD_ALLOWED_CHANNELS", + "DISCORD_IGNORED_CHANNELS", + "DISCORD_ALLOW_ALL_USERS", + "DISCORD_ALLOW_BOTS", + "DISCORD_BOTS_REQUIRE_INLINE_MENTION", + "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_REQUIRE_MENTION", + "DISCORD_THREAD_REQUIRE_MENTION", + "DISCORD_MISSED_MESSAGE_BACKFILL", + "DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", + "DISCORD_MISSED_MESSAGE_BACKFILL_WINDOW_SECONDS", + "DISCORD_MISSED_MESSAGE_BACKFILL_LIMIT", + "DISCORD_MISSED_MESSAGE_BACKFILL_MAX_DISPATCHES", + "GATEWAY_ALLOWED_USERS", + "GATEWAY_ALLOW_ALL_USERS", + ): + monkeypatch.delenv(name, raising=False) + ss.set_multiplex_active(False) + yield + ss.set_multiplex_active(False) + + +def _adapter_in_scope( + secrets: dict[str, str], *, extra: dict | None = None +) -> DiscordAdapter: + token = ss.set_secret_scope(secrets) + try: + return DiscordAdapter( + PlatformConfig(enabled=True, token="test-token", extra=extra or {}) + ) + finally: + ss.reset_secret_scope(token) + + +def _slash_interaction(user_id: str, channel_id: str) -> SimpleNamespace: + guild = SimpleNamespace( + id=400000000000000001, + get_member=lambda _uid: None, + ) + return SimpleNamespace( + user=SimpleNamespace(id=int(user_id), roles=[]), + guild=guild, + guild_id=guild.id, + channel_id=int(channel_id), + channel=SimpleNamespace(id=int(channel_id)), + ) + + +def _message(user_id: str, channel_id: str) -> SimpleNamespace: + guild = SimpleNamespace( + id=400000000000000001, + name="Synthetic Guild", + get_member=lambda _uid: None, + ) + channel = SimpleNamespace( + id=int(channel_id), + name=f"channel-{channel_id}", + guild=guild, + topic=None, + ) + return SimpleNamespace( + id=500000000000000001, + content="hello", + mentions=[], + attachments=[], + reference=None, + created_at=datetime.now(timezone.utc), + guild=guild, + channel=channel, + author=SimpleNamespace( + id=int(user_id), + display_name="Synthetic User", + name="synthetic-user", + roles=[], + guild=guild, + bot=False, + ), + ) + + +def _prepare_message_adapter(adapter: DiscordAdapter) -> AsyncMock: + adapter._client = SimpleNamespace( + user=SimpleNamespace(id=900000000000000001, name="Synthetic Bot") + ) + adapter._text_batch_delay_seconds = 0 + handler = AsyncMock() + adapter.handle_message = handler + return handler + + +def test_profile_user_allowlists_are_isolated_in_either_startup_order(): + """Each adapter must snapshot its own profile policy, never process globals.""" + ss.set_multiplex_active(True) + profile_a = {"DISCORD_ALLOWED_USERS": "100000000000000001"} + profile_b = {"DISCORD_ALLOWED_USERS": "100000000000000002"} + + for first, second in ((profile_a, profile_b), (profile_b, profile_a)): + first_adapter = _adapter_in_scope(first) + second_adapter = _adapter_in_scope(second) + + first_user = first["DISCORD_ALLOWED_USERS"] + second_user = second["DISCORD_ALLOWED_USERS"] + assert first_adapter._is_allowed_user(first_user) is True + assert first_adapter._is_allowed_user(second_user) is False + assert second_adapter._is_allowed_user(second_user) is True + assert second_adapter._is_allowed_user(first_user) is False + + +def test_profile_role_channel_and_allow_all_policies_are_isolated(): + """Every Discord admission input must remain adapter-local.""" + ss.set_multiplex_active(True) + profile_a = { + "DISCORD_ALLOWED_ROLES": "200000000000000001", + "DISCORD_ALLOWED_CHANNELS": "300000000000000001", + "DISCORD_FREE_RESPONSE_CHANNELS": "300000000000000021", + } + profile_b = { + "DISCORD_ALLOWED_ROLES": "200000000000000002", + "DISCORD_ALLOWED_CHANNELS": "300000000000000002", + "DISCORD_FREE_RESPONSE_CHANNELS": "300000000000000022", + } + + adapter_a = _adapter_in_scope(profile_a) + adapter_b = _adapter_in_scope(profile_b) + guild = SimpleNamespace(id=400000000000000001, get_member=lambda _uid: None) + member_a = SimpleNamespace( + id=100000000000000001, + guild=guild, + roles=[SimpleNamespace(id=200000000000000001)], + ) + member_b = SimpleNamespace( + id=100000000000000002, + guild=guild, + roles=[SimpleNamespace(id=200000000000000002)], + ) + + assert ( + adapter_a._is_allowed_user(str(member_a.id), author=member_a, guild=guild) + is True + ) + assert ( + adapter_a._is_allowed_user(str(member_b.id), author=member_b, guild=guild) + is False + ) + assert ( + adapter_b._is_allowed_user(str(member_b.id), author=member_b, guild=guild) + is True + ) + assert ( + adapter_b._is_allowed_user(str(member_a.id), author=member_a, guild=guild) + is False + ) + assert ( + adapter_a._discord_channel_ids_allowed({profile_a["DISCORD_ALLOWED_CHANNELS"]}) + is True + ) + assert ( + adapter_a._discord_channel_ids_allowed({profile_b["DISCORD_ALLOWED_CHANNELS"]}) + is False + ) + assert ( + adapter_b._discord_channel_ids_allowed({profile_b["DISCORD_ALLOWED_CHANNELS"]}) + is True + ) + assert ( + adapter_b._discord_channel_ids_allowed({profile_a["DISCORD_ALLOWED_CHANNELS"]}) + is False + ) + assert adapter_a._discord_free_response_channels() == { + profile_a["DISCORD_FREE_RESPONSE_CHANNELS"] + } + assert adapter_b._discord_free_response_channels() == { + profile_b["DISCORD_FREE_RESPONSE_CHANNELS"] + } + + discord_open = _adapter_in_scope({"DISCORD_ALLOW_ALL_USERS": "true"}) + gateway_open = _adapter_in_scope({"GATEWAY_ALLOW_ALL_USERS": "yes"}) + closed = _adapter_in_scope({}) + assert discord_open._is_allowed_user("100000000000000099") is True + assert gateway_open._is_allowed_user("100000000000000099") is True + assert closed._is_allowed_user("100000000000000099") is False + + +def test_yaml_policy_does_not_mutate_shared_env_in_multiplex(monkeypatch): + """Scoped/multiplex config loading must hand off via extras only.""" + policy_env = { + "DISCORD_ALLOWED_USERS": "100000000000000001", + "DISCORD_FREE_RESPONSE_CHANNELS": "300000000000000021", + "DISCORD_ALLOWED_CHANNELS": "300000000000000001", + "DISCORD_IGNORED_CHANNELS": "300000000000000011", + "DISCORD_REQUIRE_MENTION": "false", + "DISCORD_THREAD_REQUIRE_MENTION": "true", + "DISCORD_BOTS_REQUIRE_INLINE_MENTION": "yes", + } + for name in policy_env: + monkeypatch.delenv(name, raising=False) + + ss.set_multiplex_active(True) + token = ss.set_secret_scope({}) + try: + extra = _apply_yaml_config( + {}, + { + "allow_from": [policy_env["DISCORD_ALLOWED_USERS"]], + "free_response_channels": [ + policy_env["DISCORD_FREE_RESPONSE_CHANNELS"] + ], + "allowed_channels": [policy_env["DISCORD_ALLOWED_CHANNELS"]], + "ignored_channels": [policy_env["DISCORD_IGNORED_CHANNELS"]], + "require_mention": False, + "thread_require_mention": True, + "bots_require_inline_mention": True, + }, + ) + finally: + ss.reset_secret_scope(token) + + assert extra is not None + assert all(name not in os.environ for name in policy_env) + + ss.set_multiplex_active(False) + _apply_yaml_config({}, {"allow_from": ["100000000000000099"]}) + assert os.environ["DISCORD_ALLOWED_USERS"] == "100000000000000099" + + +def test_mention_history_and_approval_controls_are_profile_local(monkeypatch): + """Non-user Discord admission/history controls must not leak through env.""" + env_names = ( + "DISCORD_IGNORE_NO_MENTION", + "DISCORD_HISTORY_BACKFILL", + "DISCORD_HISTORY_BACKFILL_LIMIT", + "DISCORD_APPROVAL_MENTIONS", + ) + for name in env_names: + monkeypatch.delenv(name, raising=False) + + ss.set_multiplex_active(True) + token = ss.set_secret_scope({}) + try: + extra_a = _apply_yaml_config( + {}, + { + "allow_from": ["100000000000000001"], + "ignore_no_mention": True, + "history_backfill": True, + "history_backfill_limit": 17, + "approval_mentions": True, + }, + ) + extra_b = _apply_yaml_config( + {}, + { + "allow_from": ["100000000000000002"], + "ignore_no_mention": False, + "history_backfill": False, + "history_backfill_limit": 3, + "approval_mentions": False, + }, + ) + finally: + ss.reset_secret_scope(token) + + assert all(name not in os.environ for name in env_names) + assert extra_a is not None + assert extra_b is not None + + adapter_a = _adapter_in_scope({}, extra=extra_a) + adapter_b = _adapter_in_scope({}, extra=extra_b) + assert adapter_a._discord_ignore_no_mention() is True + assert adapter_b._discord_ignore_no_mention() is False + assert adapter_a._discord_history_backfill() is True + assert adapter_b._discord_history_backfill() is False + assert adapter_a._discord_history_backfill_limit() == 17 + assert adapter_b._discord_history_backfill_limit() == 3 + assert adapter_a._approval_mention_content() == "<@100000000000000001>" + assert adapter_b._approval_mention_content() is None + + +def test_scoped_env_policy_survives_unscoped_primary_adapter_creation(monkeypatch): + """Primary startup must retain scoped .env policy after config loading.""" + scoped_policy = { + "DISCORD_ALLOWED_USERS": "100000000000000001", + "DISCORD_ALLOWED_ROLES": "200000000000000001", + "DISCORD_ALLOWED_CHANNELS": "300000000000000001", + "DISCORD_IGNORED_CHANNELS": "300000000000000011", + "DISCORD_FREE_RESPONSE_CHANNELS": "300000000000000021", + "DISCORD_ALLOW_ALL_USERS": "false", + "DISCORD_ALLOW_BOTS": "none", + "DISCORD_BOTS_REQUIRE_INLINE_MENTION": "yes", + "DISCORD_REQUIRE_MENTION": "false", + "DISCORD_THREAD_REQUIRE_MENTION": "true", + "GATEWAY_ALLOWED_USERS": "100000000000000009", + "GATEWAY_ALLOW_ALL_USERS": "false", + "DISCORD_MISSED_MESSAGE_BACKFILL": "true", + "DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS": "300000000000000031", + "DISCORD_MISSED_MESSAGE_BACKFILL_WINDOW_SECONDS": "120", + "DISCORD_MISSED_MESSAGE_BACKFILL_LIMIT": "7", + "DISCORD_MISSED_MESSAGE_BACKFILL_MAX_DISPATCHES": "3", + } + token = ss.set_secret_scope(scoped_policy) + try: + seeded_extra = _apply_yaml_config( + {}, + { + "allow_from": ["100000000000000099"], + "allowed_roles": ["200000000000000099"], + "allowed_channels": ["300000000000000099"], + "ignored_channels": ["300000000000000098"], + "free_response_channels": ["300000000000000097"], + "allow_all_users": True, + "allow_bots": "all", + "bots_require_inline_mention": False, + "require_mention": True, + "thread_require_mention": False, + }, + ) + finally: + ss.reset_secret_scope(token) + + ss.set_multiplex_active(True) + adapter = DiscordAdapter( + PlatformConfig(enabled=True, token="test-token", extra=seeded_extra or {}) + ) + + assert adapter._access_policy.allowed_user_ids == { + scoped_policy["DISCORD_ALLOWED_USERS"] + } + assert adapter._access_policy.allowed_role_ids == { + int(scoped_policy["DISCORD_ALLOWED_ROLES"]) + } + assert adapter._access_policy.allowed_channel_keys == { + scoped_policy["DISCORD_ALLOWED_CHANNELS"] + } + assert adapter._access_policy.ignored_channel_keys == { + scoped_policy["DISCORD_IGNORED_CHANNELS"] + } + assert adapter._access_policy.free_response_channel_keys == { + scoped_policy["DISCORD_FREE_RESPONSE_CHANNELS"] + } + assert adapter._access_policy.gateway_allowed_user_ids == { + scoped_policy["GATEWAY_ALLOWED_USERS"] + } + assert adapter._access_policy.allow_all_users is False + assert adapter._access_policy.gateway_allow_all_users is False + assert adapter._access_policy.allow_bots == "none" + assert adapter._access_policy.bots_require_inline_mention is True + assert adapter._discord_require_mention() is False + assert adapter._discord_thread_require_mention() is True + assert adapter._missed_message_backfill_enabled() is True + assert adapter._missed_message_backfill_channels() == {"300000000000000031"} + assert adapter._missed_message_backfill_window_seconds() == 120 + assert adapter._missed_message_backfill_limit() == 7 + assert adapter._missed_message_backfill_max_dispatches() == 3 + + +def test_scoped_explicit_empty_policy_shadows_process_globals(monkeypatch): + """An explicit empty profile value must not inherit a permissive global.""" + for name in ( + "DISCORD_ALLOWED_USERS", + "DISCORD_ALLOWED_ROLES", + "DISCORD_ALLOWED_CHANNELS", + "DISCORD_IGNORED_CHANNELS", + "DISCORD_FREE_RESPONSE_CHANNELS", + "GATEWAY_ALLOWED_USERS", + ): + monkeypatch.setenv(name, "*") + monkeypatch.setenv("DISCORD_ALLOW_ALL_USERS", "true") + monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true") + + scoped_policy = { + "DISCORD_ALLOWED_USERS": "", + "DISCORD_ALLOWED_ROLES": "", + "DISCORD_ALLOWED_CHANNELS": "", + "DISCORD_IGNORED_CHANNELS": "", + "DISCORD_FREE_RESPONSE_CHANNELS": "", + "DISCORD_ALLOW_ALL_USERS": "false", + "GATEWAY_ALLOWED_USERS": "", + "GATEWAY_ALLOW_ALL_USERS": "false", + } + token = ss.set_secret_scope(scoped_policy) + try: + seeded_extra = _apply_yaml_config( + {}, + { + "allow_from": ["100000000000000099"], + "allowed_channels": ["300000000000000099"], + }, + ) + finally: + ss.reset_secret_scope(token) + + ss.set_multiplex_active(True) + adapter = DiscordAdapter( + PlatformConfig(enabled=True, token="test-token", extra=seeded_extra or {}) + ) + + assert adapter._access_policy.allowed_user_ids == set() + assert adapter._access_policy.allowed_role_ids == set() + assert adapter._access_policy.allowed_channel_keys == set() + assert adapter._access_policy.ignored_channel_keys == set() + assert adapter._access_policy.free_response_channel_keys == set() + assert adapter._access_policy.gateway_allowed_user_ids == set() + assert adapter._access_policy.allow_all_users is False + assert adapter._access_policy.gateway_allow_all_users is False + + +def test_bot_identity_survives_session_source_round_trip(): + """Persistence must not downgrade a Discord bot to a human principal.""" + source = SessionSource( + platform=Platform.DISCORD, + chat_id="300000000000000001", + user_id="100000000000000001", + is_bot=True, + authorization_channel_keys=[ + "300000000000000001", + "synthetic-room", + "#synthetic-room", + ], + ) + + payload = source.to_dict() + + assert payload["is_bot"] is True + restored = SessionSource.from_dict(payload) + assert restored.is_bot is True + assert set(restored.authorization_channel_keys) == { + "300000000000000001", + "synthetic-room", + "#synthetic-room", + } + payload.pop("is_bot") + assert SessionSource.from_dict(payload).is_bot is False + + +def test_restored_role_only_source_revalidates_against_owning_adapter(): + """Restoration must check current membership instead of persisting a grant.""" + from gateway.run import GatewayRunner + + ss.set_multiplex_active(True) + adapter = _adapter_in_scope({"DISCORD_ALLOWED_ROLES": "200000000000000001"}) + member = SimpleNamespace( + roles=[SimpleNamespace(id=200000000000000001)] + ) + guild = SimpleNamespace( + get_member=lambda user_id: ( + member if user_id == 100000000000000001 else None + ) + ) + adapter._client = SimpleNamespace( + get_guild=lambda guild_id: ( + guild if guild_id == 300000000000000001 else None + ) + ) + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner.adapters = {} + runner._profile_adapters = {"profile-one": {Platform.DISCORD: adapter}} + runner._active_profile_name = lambda: "default" + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved.return_value = False + runner.pairing_stores = {} + + source = SessionSource( + platform=Platform.DISCORD, + chat_id="300000000000000001", + chat_type="group", + user_id="100000000000000001", + scope_id="300000000000000001", + role_authorized=True, + transport_profile="profile-one", + ) + payload = source.to_dict() + assert "role_authorized" not in payload + restored = SessionSource.from_dict(payload) + assert restored.role_authorized is False + assert runner._is_user_authorized(restored) is True + + member.roles = [] + assert runner._is_user_authorized(restored) is False + + +def test_restored_secondary_dm_role_uses_owning_profile_guild(): + """Restored DM role checks must not read the active profile's guild config.""" + from gateway.run import GatewayRunner + + ss.set_multiplex_active(True) + role_id = 200000000000000001 + owner_guild_id = 300000000000000001 + active_guild_id = 300000000000000099 + adapter = _adapter_in_scope( + {"DISCORD_ALLOWED_ROLES": str(role_id)}, + extra={"dm_role_auth_guild": owner_guild_id}, + ) + owner_member = SimpleNamespace(roles=[SimpleNamespace(id=role_id)]) + owner_guild = SimpleNamespace( + get_member=lambda user_id: ( + owner_member if user_id == 100000000000000001 else None + ) + ) + active_guild = SimpleNamespace(get_member=lambda _user_id: None) + adapter._client = SimpleNamespace( + get_guild=lambda guild_id: { + owner_guild_id: owner_guild, + active_guild_id: active_guild, + }.get(guild_id) + ) + + hermes_home = Path(os.environ["HERMES_HOME"]) + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + f"discord:\n dm_role_auth_guild: {active_guild_id}\n" + ) + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner.adapters = {} + runner._profile_adapters = {"profile-one": {Platform.DISCORD: adapter}} + runner._active_profile_name = lambda: "default" + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved.return_value = False + runner.pairing_stores = {} + source = SessionSource( + platform=Platform.DISCORD, + chat_id="100000000000000001", + chat_type="dm", + user_id="100000000000000001", + role_authorized=True, + transport_profile="profile-one", + ) + + restored = SessionSource.from_dict(source.to_dict()) + assert restored.role_authorized is False + assert runner._is_user_authorized(restored) is True + + +def test_single_profile_dm_role_guild_refreshes_live_config(monkeypatch): + """Legacy DM role checks must observe guild changes without a restart.""" + role_id = 200000000000000001 + old_guild_id = 300000000000000001 + new_guild_id = 300000000000000002 + old_user_id = 100000000000000001 + new_user_id = 100000000000000002 + monkeypatch.setenv("DISCORD_ALLOWED_ROLES", str(role_id)) + hermes_home = Path(os.environ["HERMES_HOME"]) + hermes_home.mkdir(parents=True, exist_ok=True) + config_path = hermes_home / "config.yaml" + config_path.write_text( + f"discord:\n dm_role_auth_guild: {old_guild_id}\n", + encoding="utf-8", + ) + adapter = DiscordAdapter( + PlatformConfig( + enabled=True, + token="test-token", + extra={"dm_role_auth_guild": old_guild_id}, + ) + ) + role = SimpleNamespace(id=role_id) + old_guild = SimpleNamespace( + get_member=lambda user_id: ( + SimpleNamespace(roles=[role]) if user_id == old_user_id else None + ) + ) + new_guild = SimpleNamespace( + get_member=lambda user_id: ( + SimpleNamespace(roles=[role]) if user_id == new_user_id else None + ) + ) + adapter._client = SimpleNamespace( + get_guild=lambda guild_id: { + old_guild_id: old_guild, + new_guild_id: new_guild, + }.get(guild_id) + ) + + assert adapter._has_allowed_role(str(old_user_id), is_dm=True) is True + config_path.write_text( + f"discord:\n dm_role_auth_guild: {new_guild_id}\n", + encoding="utf-8", + ) + + assert adapter._has_allowed_role(str(old_user_id), is_dm=True) is False + assert adapter._has_allowed_role(str(new_user_id), is_dm=True) is True + + +def test_gateway_auth_uses_the_secondary_discord_adapter_policy(monkeypatch): + """The downstream gateway gate must not reopen process-global Discord auth.""" + from gateway.run import GatewayRunner + + ss.set_multiplex_active(True) + monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100000000000000099") + monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "100000000000000098") + + adapter = _adapter_in_scope({ + "DISCORD_ALLOWED_USERS": "100000000000000001", + "GATEWAY_ALLOWED_USERS": "100000000000000009", + }) + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner.adapters = {} + runner._profile_adapters = {"profile-one": {Platform.DISCORD: adapter}} + runner._active_profile_name = lambda: "default" + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved.return_value = False + runner.pairing_stores = {} + + own_source = SessionSource( + platform=Platform.DISCORD, + user_id="100000000000000001", + chat_id="300000000000000001", + chat_type="group", + profile="profile-one", + ) + foreign_source = SessionSource( + platform=Platform.DISCORD, + user_id="100000000000000099", + chat_id="300000000000000001", + chat_type="group", + profile="profile-one", + ) + + assert runner._is_user_authorized(own_source) is True + assert runner._is_user_authorized(foreign_source) is False + + +def test_real_profile_loader_and_factory_isolate_two_discord_policies(tmp_path): + """Real config loading and adapter creation must retain each profile's policy.""" + from gateway.config import load_gateway_config + from gateway.run import GatewayRunner, _profile_runtime_scope + + ss.set_multiplex_active(True) + adapters = [] + profile_specs = ( + ( + "profile-one", + "100000000000000001", + "300000000000000001", + 400000000000000001, + ), + ( + "profile-two", + "100000000000000002", + "300000000000000002", + 400000000000000002, + ), + ) + for profile_name, user_id, channel_id, dm_guild_id in profile_specs: + home = tmp_path / profile_name + home.mkdir(parents=True) + (home / ".env").write_text( + f"DISCORD_BOT_TOKEN=synthetic-{profile_name}-token\n", + encoding="utf-8", + ) + (home / "config.yaml").write_text( + "gateway:\n" + " multiplex_profiles: true\n" + "discord:\n" + " enabled: true\n" + f" allow_from: '{user_id}'\n" + f" allowed_channels: '{channel_id}'\n" + f" dm_role_auth_guild: {dm_guild_id}\n", + encoding="utf-8", + ) + + with _profile_runtime_scope(home): + config = load_gateway_config() + runner = object.__new__(GatewayRunner) + runner.config = config + adapter = runner._create_adapter( + Platform.DISCORD, + config.platforms[Platform.DISCORD], + ) + assert adapter is not None + assert adapter.platform == Platform.DISCORD + assert callable(getattr(adapter, "_authorization_policy_allows", None)) + adapters.append(adapter) + + first, second = adapters + assert first._is_allowed_user("100000000000000001", is_dm=True) is True + assert first._is_allowed_user("100000000000000002", is_dm=True) is False + assert second._is_allowed_user("100000000000000002", is_dm=True) is True + assert second._is_allowed_user("100000000000000001", is_dm=True) is False + assert first._access_policy.allowed_channel_keys == {"300000000000000001"} + assert second._access_policy.allowed_channel_keys == {"300000000000000002"} + assert first._access_policy.dm_role_auth_guild_id == 400000000000000001 + assert second._access_policy.dm_role_auth_guild_id == 400000000000000002 + assert "DISCORD_ALLOWED_USERS" not in os.environ + + +@pytest.mark.asyncio +async def test_interactive_view_producers_pass_the_owning_policy(monkeypatch): + """Every Discord send_* producer must construct its view with local policy.""" + import plugins.platforms.discord.adapter as discord_adapter_module + + ss.set_multiplex_active(True) + adapter = _adapter_in_scope({"DISCORD_ALLOWED_USERS": "100000000000000001"}) + channel = SimpleNamespace( + send=AsyncMock(return_value=SimpleNamespace(id=300000000000000099)) + ) + adapter._client = SimpleNamespace( + get_channel=lambda _channel_id: channel, + fetch_channel=AsyncMock(return_value=channel), + ) + + captured = {} + + def install_spy(class_name): + def factory(*args, **kwargs): + captured[class_name] = {"args": args, **kwargs} + return SimpleNamespace(_message=None) + + monkeypatch.setattr(discord_adapter_module, class_name, factory) + + for class_name in ( + "ExecApprovalView", + "SlashConfirmView", + "ClarifyChoiceView", + "UpdatePromptView", + "ModelPickerView", + "ChoicePickerView", + ): + install_spy(class_name) + + assert ( + await adapter.send_exec_approval("300000000000000001", "true", "session-a") + ).success + assert ( + await adapter.send_slash_confirm( + "300000000000000001", + "Confirm", + "Continue?", + "session-a", + "confirm-a", + ) + ).success + assert ( + await adapter.send_clarify( + "300000000000000001", + "Choose", + ["A"], + "clarify-a", + "session-a", + ) + ).success + assert ( + await adapter.send_update_prompt( + "300000000000000001", "Update?", session_key="session-a" + ) + ).success + assert ( + await adapter.send_model_picker( + "300000000000000001", + [{"slug": "synthetic", "name": "Synthetic", "models": []}], + "synthetic/model", + "synthetic", + "session-a", + AsyncMock(), + ) + ).success + assert ( + await adapter.send_choice_picker( + "300000000000000001", + "Choose", + [{"label": "A", "value": "a"}], + "session-a", + AsyncMock(), + ) + ).success + + assert set(captured) == { + "ExecApprovalView", + "SlashConfirmView", + "ClarifyChoiceView", + "UpdatePromptView", + "ModelPickerView", + "ChoicePickerView", + } + assert all( + constructor["access_policy"] is adapter._access_policy + for constructor in captured.values() + ) + + +def test_multiplex_missing_mention_settings_use_profile_safe_defaults(monkeypatch): + """Absent profile mention settings must not inherit process globals.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") + ss.set_multiplex_active(True) + + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + + assert adapter._discord_require_mention() is True + assert adapter._discord_thread_require_mention() is False + + +def test_multiplex_missing_backfill_settings_ignore_process_globals(monkeypatch): + """Absent recovery config must use local defaults, not another profile's env.""" + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL", "true") + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", "300000000000000099") + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL_WINDOW_SECONDS", "60") + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL_LIMIT", "1") + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL_MAX_DISPATCHES", "1") + ss.set_multiplex_active(True) + adapter = _adapter_in_scope({ + "DISCORD_ALLOWED_CHANNELS": "300000000000000001", + "DISCORD_FREE_RESPONSE_CHANNELS": "300000000000000021", + }) + + assert adapter._missed_message_backfill_enabled() is False + assert adapter._missed_message_backfill_channels() == { + "300000000000000001", + "300000000000000021", + } + assert adapter._missed_message_backfill_window_seconds() == 21600 + assert adapter._missed_message_backfill_limit() == 100 + assert adapter._missed_message_backfill_max_dispatches() == 10 + + +def test_bot_policy_is_profile_local(): + """Bot admission and inline-mention policy must not leak between adapters.""" + ss.set_multiplex_active(True) + permissive = _adapter_in_scope({ + "DISCORD_ALLOW_BOTS": "all", + "DISCORD_BOTS_REQUIRE_INLINE_MENTION": "false", + }) + restrictive = _adapter_in_scope({ + "DISCORD_ALLOW_BOTS": "none", + "DISCORD_BOTS_REQUIRE_INLINE_MENTION": "true", + }) + legacy_on = _adapter_in_scope({ + "DISCORD_BOTS_REQUIRE_INLINE_MENTION": "on", + }) + + assert ( + permissive._authorization_policy_allows("100000000000000001", is_bot=True) + is True + ) + assert ( + restrictive._authorization_policy_allows("100000000000000001", is_bot=True) + is False + ) + assert permissive._discord_bots_require_inline_mention() is False + assert restrictive._discord_bots_require_inline_mention() is True + assert legacy_on._discord_bots_require_inline_mention() is True + + +def test_gateway_user_grants_pass_discord_ingress(): + """Gateway union grants must not be dropped by Discord's earlier gate.""" + ss.set_multiplex_active(True) + allowed = _adapter_in_scope({ + "GATEWAY_ALLOWED_USERS": "100000000000000001", + }) + open_gateway = _adapter_in_scope({ + "GATEWAY_ALLOW_ALL_USERS": "true", + }) + + assert allowed._is_allowed_user("100000000000000001", is_dm=True) is True + assert allowed._is_allowed_user("100000000000000002", is_dm=True) is False + assert open_gateway._is_allowed_user("100000000000000002", is_dm=True) is True + + +def test_pairing_check_is_profile_local_for_messages_and_components(): + """A pairing grant belongs only to the adapter/view's owning profile.""" + ss.set_multiplex_active(True) + paired = _adapter_in_scope({}) + unpaired = _adapter_in_scope({}) + paired.set_pairing_check(lambda uid: uid == "100000000000000001") + unpaired.set_pairing_check(lambda _uid: False) + + assert paired._is_allowed_user("100000000000000001") is True + assert unpaired._is_allowed_user("100000000000000001") is False + + paired_view = ExecApprovalView( + "session-a", set(), access_policy=paired._access_policy + ) + unpaired_view = ExecApprovalView( + "session-b", set(), access_policy=unpaired._access_policy + ) + interaction = SimpleNamespace(user=SimpleNamespace(id=100000000000000001)) + + assert paired_view._check_auth(interaction) is True + assert unpaired_view._check_auth(interaction) is False + + +def test_component_channel_hard_denial_precedes_user_grant(): + """An allowed user cannot operate a view in an explicitly ignored channel.""" + ss.set_multiplex_active(True) + adapter = _adapter_in_scope({ + "DISCORD_ALLOWED_USERS": "100000000000000001", + "DISCORD_IGNORED_CHANNELS": "300000000000000001", + }) + view = ExecApprovalView( + "session-a", + {"100000000000000001"}, + access_policy=adapter._access_policy, + ) + interaction = SimpleNamespace( + user=SimpleNamespace(id=100000000000000001, roles=[]), + guild_id=300000000000000099, + channel_id=300000000000000001, + channel=SimpleNamespace( + id=300000000000000001, + name="ignored-room", + parent=None, + ), + ) + + assert view._check_auth(interaction) is False + + +def test_component_denies_when_restricted_channel_cannot_be_resolved(): + """A raw channel ID cannot prove that an ignored parent boundary is absent.""" + ss.set_multiplex_active(True) + adapter = _adapter_in_scope({ + "DISCORD_ALLOWED_USERS": "100000000000000001", + "DISCORD_IGNORED_CHANNELS": "300000000000000001", + }) + view = ExecApprovalView( + "session-a", + {"100000000000000001"}, + access_policy=adapter._access_policy, + ) + interaction = SimpleNamespace( + user=SimpleNamespace(id=100000000000000001, roles=[]), + guild_id=300000000000000099, + channel_id=300000000000000002, + channel=None, + ) + + assert view._check_auth(interaction) is False + + +def test_component_allows_channel_only_policy_without_identity_grants(): + """A channel-only policy must keep views usable in an admitted channel.""" + ss.set_multiplex_active(True) + adapter = _adapter_in_scope({ + "DISCORD_ALLOWED_CHANNELS": "300000000000000001", + }) + view = ExecApprovalView( + "session-a", + set(), + access_policy=adapter._access_policy, + ) + interaction = SimpleNamespace( + user=SimpleNamespace(id=100000000000000001, roles=[]), + guild_id=300000000000000099, + channel_id=300000000000000001, + channel=SimpleNamespace( + id=300000000000000001, + name="allowed-room", + parent=None, + ), + ) + + assert view._check_auth(interaction) is True + + +def test_profile_adapter_configuration_binds_only_the_owning_pairing_store(): + """The shared adapter-configuration helper binds each owning pairing store.""" + from gateway.run import GatewayRunner + + ss.set_multiplex_active(True) + adapter_a = _adapter_in_scope({}) + adapter_b = _adapter_in_scope({}) + store_a = MagicMock() + store_b = MagicMock() + store_a.is_approved.side_effect = lambda platform, user: ( + platform == "discord" and user == "100000000000000001" + ) + store_b.is_approved.side_effect = lambda platform, user: ( + platform == "discord" and user == "100000000000000002" + ) + runner = object.__new__(GatewayRunner) + runner.pairing_stores = {"profile-a": store_a, "profile-b": store_b} + runner.session_store = MagicMock() + runner._busy_text_mode = "queue" + runner._make_profile_message_handler = MagicMock(return_value=AsyncMock()) + runner._make_profile_fatal_error_handler = MagicMock(return_value=AsyncMock()) + runner._handle_active_session_busy_message = AsyncMock() + runner._handle_reaction_event = AsyncMock() + runner._recover_telegram_topic_thread_id = AsyncMock() + runner._make_adapter_auth_check = MagicMock(return_value=MagicMock()) + + runner._configure_profile_adapter(adapter_a, "profile-a", Platform.DISCORD) + runner._configure_profile_adapter(adapter_b, "profile-b", Platform.DISCORD) + + assert adapter_a._is_allowed_user("100000000000000001") is True + assert adapter_a._is_allowed_user("100000000000000002") is False + assert adapter_b._is_allowed_user("100000000000000002") is True + assert adapter_b._is_allowed_user("100000000000000001") is False + + +@pytest.mark.asyncio +async def test_secondary_voice_callback_is_bound_to_owning_adapter(): + """Secondary voice input must not look up or execute through the primary bot.""" + from gateway.run import GatewayRunner + + adapter_a = _adapter_in_scope({"DISCORD_ALLOW_ALL_USERS": "true"}) + adapter_b = _adapter_in_scope({"DISCORD_ALLOW_ALL_USERS": "true"}) + runner = object.__new__(GatewayRunner) + runner.pairing_stores = {"profile-a": MagicMock(), "profile-b": MagicMock()} + runner.session_store = MagicMock() + runner._busy_text_mode = "queue" + runner._make_profile_message_handler = MagicMock(return_value=AsyncMock()) + runner._make_profile_fatal_error_handler = MagicMock(return_value=AsyncMock()) + runner._handle_active_session_busy_message = AsyncMock() + runner._handle_reaction_event = AsyncMock() + runner._recover_telegram_topic_thread_id = AsyncMock() + runner._make_adapter_auth_check = MagicMock(return_value=MagicMock()) + runner._is_user_authorized = MagicMock(return_value=True) + runner._is_duplicate_voice_transcript = MagicMock(return_value=False) + + for adapter, profile in ((adapter_a, "profile-a"), (adapter_b, "profile-b")): + adapter._voice_text_channels = {400000000000000001: 300000000000000001} + adapter._voice_sources = {} + adapter._client = SimpleNamespace(get_channel=lambda _channel_id: None) + adapter._resolve_channel_prompt = MagicMock(return_value=None) + adapter.handle_message = AsyncMock() + runner._configure_profile_adapter(adapter, profile, Platform.DISCORD) + + assert callable(adapter_a._voice_input_callback) + assert callable(adapter_b._voice_input_callback) + assert adapter_a._voice_input_callback is not adapter_b._voice_input_callback + + await adapter_a._voice_input_callback( + 400000000000000001, 100000000000000001, "synthetic transcript" + ) + + adapter_a.handle_message.assert_awaited_once() + adapter_b.handle_message.assert_not_awaited() + event = adapter_a.handle_message.await_args.args[0] + assert event.source.profile == "profile-a" + assert event.source.transport_profile == "profile-a" + + +def test_single_profile_refresh_replaces_initialized_user_and_role_aliases( + monkeypatch, +): + """Live legacy allowlist changes must replace, not restore, stale aliases.""" + old_user = "100000000000000001" + new_user = "100000000000000002" + old_role = "200000000000000001" + new_role = "200000000000000002" + monkeypatch.setenv("DISCORD_ALLOWED_USERS", old_user) + monkeypatch.setenv("DISCORD_ALLOWED_ROLES", old_role) + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + + monkeypatch.setenv("DISCORD_ALLOWED_USERS", new_user) + monkeypatch.setenv("DISCORD_ALLOWED_ROLES", new_role) + policy = adapter._discord_access_policy() + + assert policy.allowed_user_ids == {new_user} + assert policy.allowed_role_ids == {int(new_role)} + + +def test_single_profile_backfill_yaml_precedes_legacy_environment(monkeypatch): + """Explicit YAML backfill settings remain authoritative over legacy env.""" + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL", "true") + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", "env-channel") + monkeypatch.setenv("DISCORD_MISSED_MESSAGE_BACKFILL_LIMIT", "400") + adapter = DiscordAdapter( + PlatformConfig( + enabled=True, + token="test-token", + extra={ + "missed_message_backfill": { + "enabled": False, + "channels": ["yaml-channel"], + "limit": 7, + } + }, + ) + ) + + assert adapter._missed_message_backfill_enabled() is False + assert adapter._missed_message_backfill_channels() == {"yaml-channel"} + assert adapter._missed_message_backfill_limit() == 7 + + +def test_explicit_empty_backfill_channels_do_not_expand_to_policy_channels(): + """An explicit empty recovery channel list must disable channel scanning.""" + adapter = DiscordAdapter( + PlatformConfig( + enabled=True, + token="test-token", + extra={ + "allowed_channels": ["allowed-channel"], + "free_response_channels": ["free-channel"], + "missed_message_backfill": {"channels": []}, + }, + ) + ) + + assert adapter._missed_message_backfill_channels() == set() + + +def test_members_intent_refreshes_late_legacy_roles_without_multiplex_leak( + monkeypatch, +): + """Legacy connect-time config stays dynamic; multiplex snapshots stay fixed.""" + role_id = "200000000000000001" + + ss.set_multiplex_active(False) + legacy_adapter = _adapter_in_scope({}) + monkeypatch.setenv("DISCORD_ALLOWED_ROLES", role_id) + assert legacy_adapter._discord_members_intent_required() is True + + monkeypatch.delenv("DISCORD_ALLOWED_ROLES") + ss.set_multiplex_active(True) + multiplex_adapter = _adapter_in_scope({}) + monkeypatch.setenv("DISCORD_ALLOWED_ROLES", role_id) + assert multiplex_adapter._discord_members_intent_required() is False + + +def test_message_admission_marks_only_an_actual_role_match_authorized(): + """A user-ID grant must not be mislabeled as a role grant downstream.""" + ss.set_multiplex_active(True) + adapter = _adapter_in_scope({ + "DISCORD_ALLOWED_USERS": "100000000000000001", + "DISCORD_ALLOWED_ROLES": "200000000000000001", + }) + guild = SimpleNamespace(id=400000000000000001, get_member=lambda _uid: None) + author = SimpleNamespace( + id=100000000000000001, + bot=False, + guild=guild, + roles=[SimpleNamespace(id=200000000000000099)], + ) + adapter._client = SimpleNamespace(user=SimpleNamespace(id=900000000000000001)) + message = SimpleNamespace( + id=500000000000000001, + type=discord.MessageType.default, + author=author, + guild=guild, + channel=SimpleNamespace(id=300000000000000001), + mentions=[], + content="hello", + ) + + admitted, role_authorized = adapter._discord_message_admission(message, claim=False) + + assert admitted is True + assert role_authorized is False + + +def test_slash_channel_policy_is_profile_local(): + """Slash authorization must use the adapter's own allow/ignore channels.""" + ss.set_multiplex_active(True) + user_id = "100000000000000001" + allowed_a = "300000000000000001" + allowed_b = "300000000000000002" + ignored_a = "300000000000000011" + ignored_b = "300000000000000012" + adapter_a = _adapter_in_scope({ + "DISCORD_ALLOWED_USERS": user_id, + "DISCORD_ALLOWED_CHANNELS": f"{allowed_a},{ignored_a}", + "DISCORD_IGNORED_CHANNELS": ignored_a, + }) + adapter_b = _adapter_in_scope({ + "DISCORD_ALLOWED_USERS": user_id, + "DISCORD_ALLOWED_CHANNELS": f"{allowed_b},{ignored_b}", + "DISCORD_IGNORED_CHANNELS": ignored_b, + }) + + assert adapter_a._evaluate_slash_authorization( + _slash_interaction(user_id, allowed_a) + ) == (True, None) + assert ( + adapter_a._evaluate_slash_authorization(_slash_interaction(user_id, allowed_b))[ + 0 + ] + is False + ) + assert ( + adapter_a._evaluate_slash_authorization(_slash_interaction(user_id, ignored_a))[ + 0 + ] + is False + ) + + assert adapter_b._evaluate_slash_authorization( + _slash_interaction(user_id, allowed_b) + ) == (True, None) + assert ( + adapter_b._evaluate_slash_authorization(_slash_interaction(user_id, allowed_a))[ + 0 + ] + is False + ) + assert ( + adapter_b._evaluate_slash_authorization(_slash_interaction(user_id, ignored_b))[ + 0 + ] + is False + ) + + +@pytest.mark.asyncio +async def test_message_channel_policy_is_profile_local(monkeypatch): + """Normal messages must use the adapter's captured channel policy.""" + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + ss.set_multiplex_active(True) + user_id = "100000000000000001" + allowed_a = "300000000000000001" + allowed_b = "300000000000000002" + ignored_a = "300000000000000011" + ignored_b = "300000000000000012" + adapter_a = _adapter_in_scope({ + "DISCORD_ALLOWED_USERS": user_id, + "DISCORD_ALLOWED_CHANNELS": f"{allowed_a},{ignored_a}", + "DISCORD_IGNORED_CHANNELS": ignored_a, + "DISCORD_REQUIRE_MENTION": "false", + }) + adapter_b = _adapter_in_scope({ + "DISCORD_ALLOWED_USERS": user_id, + "DISCORD_ALLOWED_CHANNELS": f"{allowed_b},{ignored_b}", + "DISCORD_IGNORED_CHANNELS": ignored_b, + "DISCORD_REQUIRE_MENTION": "false", + }) + + for adapter, own, foreign, ignored in ( + (adapter_a, allowed_a, allowed_b, ignored_a), + (adapter_b, allowed_b, allowed_a, ignored_b), + ): + handler = _prepare_message_adapter(adapter) + await adapter._handle_message(_message(user_id, own)) + handler.assert_awaited_once() + + handler.reset_mock() + await adapter._handle_message(_message(user_id, foreign)) + handler.assert_not_awaited() + + await adapter._handle_message(_message(user_id, ignored)) + handler.assert_not_awaited() + + +def test_component_policy_is_profile_local(): + """Button authorization must not read another profile's global policy.""" + ss.set_multiplex_active(True) + user_a = "100000000000000001" + user_b = "100000000000000002" + adapter_a = _adapter_in_scope({"GATEWAY_ALLOWED_USERS": user_a}) + adapter_b = _adapter_in_scope({"GATEWAY_ALLOWED_USERS": user_b}) + adapter_open = _adapter_in_scope({"DISCORD_ALLOW_ALL_USERS": "true"}) + adapter_closed = _adapter_in_scope({}) + + def _view(adapter: DiscordAdapter) -> ExecApprovalView: + return ExecApprovalView( + session_key="synthetic-session", + allowed_user_ids=adapter._allowed_user_ids, + allowed_role_ids=adapter._allowed_role_ids, + access_policy=adapter._access_policy, + ) + + interaction_a = SimpleNamespace(user=SimpleNamespace(id=int(user_a), roles=[])) + interaction_b = SimpleNamespace(user=SimpleNamespace(id=int(user_b), roles=[])) + unknown = SimpleNamespace(user=SimpleNamespace(id=100000000000000099, roles=[])) + + assert _view(adapter_a)._check_auth(interaction_a) is True + assert _view(adapter_a)._check_auth(interaction_b) is False + assert _view(adapter_b)._check_auth(interaction_b) is True + assert _view(adapter_b)._check_auth(interaction_a) is False + assert _view(adapter_open)._check_auth(unknown) is True + assert _view(adapter_closed)._check_auth(unknown) is False + + +def test_stamped_secondary_profile_does_not_use_default_pairing_store(): + """A missing owner store must deny rather than borrow the default grant.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner.adapters = {} + runner._profile_adapters = {} + runner._active_profile_name = lambda: "default" + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved.return_value = True + runner.pairing_stores = {} + source = SessionSource( + platform=Platform.DISCORD, + user_id="100000000000000001", + chat_id="300000000000000001", + chat_type="dm", + profile="profile-one", + ) + + assert runner._pairing_store_for(source) is None + assert runner._is_user_authorized(source) is False + runner.pairing_store.is_approved.assert_not_called() + + +@pytest.mark.asyncio +async def test_username_resolution_does_not_rewrite_process_globals(monkeypatch): + """Resolving one profile's usernames must not mutate another profile's source.""" + ss.set_multiplex_active(True) + adapter_a = _adapter_in_scope({"DISCORD_ALLOWED_USERS": "synthetic-a"}) + adapter_b = _adapter_in_scope({"DISCORD_ALLOWED_USERS": "synthetic-b"}) + member = SimpleNamespace( + id=100000000000000001, + name="synthetic-a", + display_name="Synthetic A", + global_name=None, + discriminator="0", + ) + adapter_a._client = SimpleNamespace( + guilds=[ + SimpleNamespace( + name="Synthetic Guild", + members=[member], + member_count=1, + ) + ] + ) + monkeypatch.setenv("DISCORD_ALLOWED_USERS", "process-global-sentinel") + + await adapter_a._resolve_allowed_usernames() + + assert adapter_a._allowed_user_ids == {str(member.id)} + assert adapter_a._access_policy.allowed_user_ids == {str(member.id)} + assert adapter_b._allowed_user_ids == {"synthetic-b"} + assert os.environ["DISCORD_ALLOWED_USERS"] == "process-global-sentinel" + + +def test_restored_routed_source_keeps_transport_profile_authorization(): + """Persistence must not switch a routed session to its runtime bot policy.""" + from gateway.run import GatewayRunner + + ss.set_multiplex_active(True) + transport_adapter = _adapter_in_scope({}) + runtime_adapter = _adapter_in_scope({"DISCORD_ALLOWED_USERS": "100000000000000001"}) + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner.adapters = {Platform.DISCORD: runtime_adapter} + runner._profile_adapters = { + "transport-profile": {Platform.DISCORD: transport_adapter} + } + runner._active_profile_name = lambda: "runtime-profile" + runner.pairing_stores = {} + + source = SessionSource( + platform=Platform.DISCORD, + user_id="100000000000000001", + chat_id="300000000000000001", + profile="runtime-profile", + ) + source.transport_profile = "transport-profile" + source._transport_adapter_ref = weakref.ref(transport_adapter) + + assert runner._is_user_authorized(source) is False + restored = SessionSource.from_dict(source.to_dict()) + assert restored.profile == "runtime-profile" + assert restored.transport_profile == "transport-profile" + assert runner._adapter_for_source(restored) is transport_adapter + assert runner._is_user_authorized(restored) is False + + +def test_secondary_default_profile_resolves_its_own_adapter(): + """An explicit secondary 'default' profile must not alias the active slot.""" + from gateway.run import GatewayRunner + + active_adapter = _adapter_in_scope({"DISCORD_ALLOWED_USERS": "100000000000000001"}) + default_adapter = _adapter_in_scope({"DISCORD_ALLOWED_USERS": "100000000000000002"}) + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner.adapters = {Platform.DISCORD: active_adapter} + runner._profile_adapters = {"default": {Platform.DISCORD: default_adapter}} + runner._active_profile_name = lambda: "active-profile" + runner.pairing_stores = {} + + assert runner._authorization_adapter(Platform.DISCORD, "default") is default_adapter + assert ( + runner._is_user_authorized( + SessionSource( + platform=Platform.DISCORD, + user_id="100000000000000001", + chat_id="300000000000000001", + profile="default", + ) + ) + is False + ) + + +def _runner_with_discord_adapter(adapter: DiscordAdapter): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner.adapters = {Platform.DISCORD: adapter} + runner._profile_adapters = {} + runner._active_profile_name = lambda: "default" + runner.pairing_stores = {} + return runner + + +def test_outer_authorization_preserves_channel_context_and_hard_denials(): + """Parent/name grants pass, while allowed/ignored misses remain authoritative.""" + ss.set_multiplex_active(True) + user_id = "100000000000000001" + thread_id = "300000000000000002" + parent_id = "300000000000000001" + + parent_adapter = _adapter_in_scope({"DISCORD_ALLOWED_CHANNELS": parent_id}) + parent_source = SessionSource( + platform=Platform.DISCORD, + user_id=user_id, + chat_id=thread_id, + parent_chat_id=parent_id, + authorization_channel_keys=[thread_id, parent_id], + chat_type="thread", + profile="default", + ) + assert ( + _runner_with_discord_adapter(parent_adapter)._is_user_authorized(parent_source) + is True + ) + + name_adapter = _adapter_in_scope({"DISCORD_ALLOWED_CHANNELS": "synthetic-room"}) + name_source = SessionSource( + platform=Platform.DISCORD, + user_id=user_id, + chat_id=thread_id, + authorization_channel_keys=[ + thread_id, + "synthetic-room", + "#synthetic-room", + ], + chat_type="channel", + profile="default", + ) + assert ( + _runner_with_discord_adapter(name_adapter)._is_user_authorized(name_source) + is True + ) + + restricted_adapter = _adapter_in_scope({ + "DISCORD_ALLOWED_USERS": user_id, + "DISCORD_ALLOWED_CHANNELS": parent_id, + }) + foreign_source = SessionSource( + platform=Platform.DISCORD, + user_id=user_id, + chat_id="300000000000000099", + authorization_channel_keys=["300000000000000099"], + chat_type="channel", + profile="default", + ) + assert ( + _runner_with_discord_adapter(restricted_adapter)._is_user_authorized( + foreign_source + ) + is False + ) + + ignored_adapter = _adapter_in_scope({"DISCORD_IGNORED_CHANNELS": parent_id}) + ignored_adapter.set_pairing_check(lambda candidate: candidate == user_id) + ignored_source = SessionSource( + platform=Platform.DISCORD, + user_id=user_id, + chat_id=thread_id, + parent_chat_id=parent_id, + authorization_channel_keys=[thread_id, parent_id], + chat_type="thread", + profile="default", + ) + ignored_runner = _runner_with_discord_adapter(ignored_adapter) + paired_store = MagicMock() + paired_store.is_approved.return_value = True + ignored_runner.pairing_stores = {"default": paired_store} + assert ignored_runner._is_user_authorized(ignored_source) is False + + +def test_role_only_slash_event_preserves_verified_role_grant(): + """The outer gate must receive the role decision made at slash ingress.""" + ss.set_multiplex_active(True) + role_id = 200000000000000001 + adapter = _adapter_in_scope({"DISCORD_ALLOWED_ROLES": str(role_id)}) + guild = SimpleNamespace( + id=400000000000000001, + name="Synthetic Guild", + get_member=lambda _uid: None, + ) + channel = SimpleNamespace( + id=300000000000000001, + name="synthetic-room", + guild=guild, + topic=None, + ) + user = SimpleNamespace( + id=100000000000000001, + display_name="Synthetic User", + roles=[SimpleNamespace(id=role_id)], + guild=guild, + ) + interaction = SimpleNamespace( + user=user, + guild=guild, + guild_id=guild.id, + channel=channel, + channel_id=channel.id, + ) + + assert adapter._evaluate_slash_authorization(interaction) == (True, None) + event = adapter._build_slash_event(interaction, "/status") + assert event.source.role_authorized is True + assert ( + _runner_with_discord_adapter(adapter)._is_user_authorized(event.source) is True + )