diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index be57b3f03ef21..af366033c9bc8 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -43,6 +43,35 @@ def _auth_env(name: str, default: str = "") -> str: return (os.getenv(name) or default).strip() +def _platform_gate_env(name: str, default: str = "") -> str: + """Read a platform allow/deny gate env var with per-profile isolation. + + Like ``_auth_env`` but authoritative under multiplex: when a profile + secret scope is installed AND multiplexing is active, a key absent from + the scope returns ``default`` instead of falling through to + ``os.environ``. Under multiplex the process env may hold ANOTHER + profile's first-writer-bridged value (the YAML→env bridges in the + Discord/Telegram adapters' ``_apply_yaml_config`` are first-writer-wins), + so falling through would leak profile A's allowlist into profile B + (issue #72348). Single-profile deployments — no scope installed, or + multiplex off — behave exactly like the legacy ``os.getenv`` read. + """ + if not name: + return default + try: + from agent.secret_scope import current_secret_scope, is_multiplex_active + + scope = current_secret_scope() + if scope is not None and is_multiplex_active(): + val = scope.get(name) + if val is None: + return default + return str(val).strip() + except Exception: + pass + return (os.getenv(name) or default).strip() + + def _coerce_allow_set(raw) -> set[str]: """Parse allowlist values from config or env var into a set of strings. diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 5fab2307c30a8..b2befc242baaa 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -345,6 +345,68 @@ def _clean_discord_id(entry: str) -> str: return entry.strip() +# ── per-profile gate env reads (issue #72348) ──────────────────────────── +# Under gateway.multiplex_profiles, os.environ is process-global and the +# YAML→env bridge in _apply_yaml_config is first-writer-wins, so a raw +# os.getenv() on an allow/deny gate can return ANOTHER profile's value. +# _scoped_gate_env reads the active profile's secret scope when one is +# installed (secondary adapters connect — and their discord.py event tasks +# are created — inside _profile_runtime_scope, so the contextvar propagates) +# and falls back to os.getenv only outside multiplex. + +# Authorization/gate env vars snapshotted per-adapter at connect() time. +_GATE_ENV_KEYS = ( + "DISCORD_ALLOWED_USERS", + "DISCORD_ALLOWED_ROLES", + "DISCORD_ALLOWED_CHANNELS", + "DISCORD_IGNORED_CHANNELS", + "DISCORD_NO_THREAD_CHANNELS", + "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", + "DISCORD_ALLOW_ALL_USERS", + "DISCORD_ALLOW_BOTS", + "GATEWAY_ALLOW_ALL_USERS", + "GATEWAY_ALLOWED_USERS", +) + + +def _scoped_gate_env(name: str, default: str = "") -> str: + """Scope-aware gate env read: profile secret scope first under multiplex.""" + try: + from gateway.authz_mixin import _platform_gate_env + + return _platform_gate_env(name, default) + except Exception: + return (os.getenv(name) or default).strip() + + +def _multiplex_active() -> bool: + """True when the gateway is running in multiplex_profiles mode.""" + try: + from agent.secret_scope import is_multiplex_active + + return bool(is_multiplex_active()) + except Exception: + return False + + +def _profile_scoped_config_load() -> bool: + """True when the current config load belongs to a multiplexed profile. + + Secondary profile configs load inside ``_profile_runtime_scope`` (secret + scope installed + multiplex active). In that case the YAML→env bridge in + ``_apply_yaml_config`` must NOT write process-global env vars: the values + belong to one profile only and the first-writer-wins guard would pin them + for every other profile (issue #72348). + """ + try: + from agent.secret_scope import current_secret_scope, is_multiplex_active + + return bool(is_multiplex_active() and current_secret_scope() is not None) + except Exception: + return False + + def check_discord_requirements() -> bool: """Check if Discord dependencies are available. @@ -918,6 +980,10 @@ class DiscordAdapter(BasePlatformAdapter): 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 + # Per-adapter snapshot of authorization gate env vars, captured inside + # the owning profile's runtime scope during connect(). None until then; + # accessors fall back to live scope-aware reads (issue #72348). + self._gate_env_snapshot: Optional[Dict[str, str]] = None 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 @@ -1154,22 +1220,18 @@ class DiscordAdapter(BasePlatformAdapter): if not self._acquire_platform_lock('discord-bot-token', self.config.token, 'Discord bot token'): return False + # Snapshot this profile's gate env vars (issue #72348): connect() + # runs inside the owning profile's runtime scope under multiplex, + # so the snapshot holds THIS adapter's values, immune to the + # first-writer-wins process-global env bridge. + self._snapshot_gate_env() + # 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() - } + self._allowed_user_ids = self._get_allowed_users() # 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() - } + self._allowed_role_ids = self._get_allowed_roles() # Set up intents. # Message Content is required for normal text replies. @@ -1341,7 +1403,7 @@ class DiscordAdapter(BasePlatformAdapter): role_authorized = False if getattr(message.author, "bot", False): - allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() + allow_bots = self._get_allow_bots() if allow_bots == "none": return False, False if allow_bots == "mentions" and not self._self_is_explicitly_mentioned(message): @@ -2016,13 +2078,9 @@ class DiscordAdapter(BasePlatformAdapter): 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", "") + raw = self._gate_env("DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS") if not raw.strip(): - allowed = { - item.strip() - for item in os.getenv("DISCORD_ALLOWED_CHANNELS", "").split(",") - if item.strip() - } + allowed = self._get_allowed_channels() return allowed | self._discord_free_response_channels() return {item.strip() for item in raw.split(",") if item.strip()} @@ -4415,10 +4473,9 @@ class DiscordAdapter(BasePlatformAdapter): """True when *channel_ids* intersect ``DISCORD_ALLOWED_CHANNELS``.""" if not channel_ids: return False - allowed_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "").strip() - if not allowed_raw: + allowed = self._get_allowed_channels() + 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) @@ -4484,9 +4541,9 @@ class DiscordAdapter(BasePlatformAdapter): return True if not has_users and not has_roles: - if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + if self._discord_allow_all_users(): return True - if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + if self._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 @@ -4559,11 +4616,11 @@ class DiscordAdapter(BasePlatformAdapter): allowed_roles = getattr(self, "_allowed_role_ids", set()) or set() if allowed_users or allowed_roles: return - if os.getenv("DISCORD_ALLOWED_CHANNELS", "").strip(): + if self._get_allowed_channels(): return - if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + if self._discord_allow_all_users(): return - if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + if self._gateway_allow_all_users(): return self._warned_fail_closed_default = True logger.warning( @@ -4642,9 +4699,8 @@ class DiscordAdapter(BasePlatformAdapter): 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 = self._get_allowed_channels() + if allowed: if "*" not in allowed: if not channel_ids: # Channel policy is configured but the interaction @@ -4659,9 +4715,8 @@ class DiscordAdapter(BasePlatformAdapter): # 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 = self._get_ignored_channels() + if ignored and channel_ids: if "*" in ignored or (channel_keys & ignored): return (False, "channel in DISCORD_IGNORED_CHANNELS") @@ -5188,9 +5243,19 @@ class DiscordAdapter(BasePlatformAdapter): 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 internal set. Keep the resolved IDs adapter-local first: + # under multiplex_profiles, writing os.environ here would clobber + # every OTHER profile's DISCORD_ALLOWED_USERS after this adapter's + # on_ready — an unguarded runtime mutation of process-global state + # (issue #72348). Refresh this adapter's own snapshot instead. self._allowed_user_ids = numeric_ids - os.environ["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids)) + snap = getattr(self, "_gate_env_snapshot", None) + if snap is not None: + snap["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids)) + if not _multiplex_active(): + # Single-profile: preserve the legacy env rewrite so the gateway's + # env-based auth checks match the resolved numeric IDs. + 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)") @@ -6034,6 +6099,99 @@ class DiscordAdapter(BasePlatformAdapter): and getattr(att, "waveform", None) is not None ) + # ── per-adapter authorization gates (issue #72348) ─────────────────── + # Under gateway.multiplex_profiles every Discord adapter must enforce + # ITS OWN profile's allow/deny lists. os.environ is process-global and + # the YAML→env bridge is first-writer-wins, so raw os.getenv reads here + # would leak profile A's gates into profile B. Each accessor reads, in + # order: the per-adapter env snapshot taken inside the owning profile's + # runtime scope at connect() (authoritative under multiplex), then this + # adapter's PlatformConfig.extra (per-profile YAML), with the live + # scope-aware env read as the pre-connect fallback. Single-profile + # deployments resolve to plain os.getenv, unchanged. + + def _snapshot_gate_env(self) -> None: + """Capture authorization env vars for THIS adapter's profile. + + Must be called inside the owning profile's runtime scope (connect() + runs there under multiplex) so the snapshot holds the profile's own + values, not whichever profile bridged os.environ first. + """ + self._gate_env_snapshot = { + key: _scoped_gate_env(key) for key in _GATE_ENV_KEYS + } + + def _gate_env(self, name: str, default: str = "") -> str: + """Read a gate env var from this adapter's snapshot (scope fallback).""" + snap = getattr(self, "_gate_env_snapshot", None) + if snap is not None and name in snap: + return snap[name] or default + return _scoped_gate_env(name, default) + + def _gate_raw(self, extra_key: str, env_key: str): + """Resolve one gate value: env/snapshot first (legacy precedence), then extra.""" + val = self._gate_env(env_key) + if val: + return val + extra = getattr(getattr(self, "config", None), "extra", None) + if isinstance(extra, dict): + return extra.get(extra_key) + return None + + @staticmethod + def _gate_csv_set(raw) -> set: + if raw is None: + return set() + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + return {part.strip() for part in str(raw).split(",") if part.strip()} + + def _get_allowed_channels(self) -> set: + """This adapter's DISCORD_ALLOWED_CHANNELS gate (per-profile).""" + return self._gate_csv_set(self._gate_raw("allowed_channels", "DISCORD_ALLOWED_CHANNELS")) + + def _get_ignored_channels(self) -> set: + """This adapter's DISCORD_IGNORED_CHANNELS gate (per-profile).""" + return self._gate_csv_set(self._gate_raw("ignored_channels", "DISCORD_IGNORED_CHANNELS")) + + def _get_no_thread_channels(self) -> set: + """This adapter's DISCORD_NO_THREAD_CHANNELS list (per-profile).""" + return self._gate_csv_set(self._gate_raw("no_thread_channels", "DISCORD_NO_THREAD_CHANNELS")) + + def _get_allowed_users(self) -> set: + """This adapter's DISCORD_ALLOWED_USERS entries (per-profile, cleaned).""" + raw = self._gate_raw("allow_from", "DISCORD_ALLOWED_USERS") + if raw is None: + extra = getattr(getattr(self, "config", None), "extra", None) + if isinstance(extra, dict): + raw = extra.get("allowed_users") + return { + _clean_discord_id(str(entry)) + for entry in self._gate_csv_set(raw) + if _clean_discord_id(str(entry)) + } + + def _get_allowed_roles(self) -> set: + """This adapter's DISCORD_ALLOWED_ROLES role IDs (per-profile).""" + raw = self._gate_raw("allowed_roles", "DISCORD_ALLOWED_ROLES") + return { + int(str(entry).strip()) for entry in self._gate_csv_set(raw) + if str(entry).strip().isdigit() + } + + def _discord_allow_all_users(self) -> bool: + """Per-profile DISCORD_ALLOW_ALL_USERS flag.""" + raw = self._gate_raw("allow_all_users", "DISCORD_ALLOW_ALL_USERS") + return str(raw or "").strip().lower() in {"true", "1", "yes"} + + def _gateway_allow_all_users(self) -> bool: + """Per-profile GATEWAY_ALLOW_ALL_USERS flag.""" + return self._gate_env("GATEWAY_ALLOW_ALL_USERS").strip().lower() in {"true", "1", "yes"} + + def _get_allow_bots(self) -> str: + """Per-profile DISCORD_ALLOW_BOTS mode (none|mentions|all).""" + return self._gate_env("DISCORD_ALLOW_BOTS", "none").lower().strip() or "none" + def _discord_free_response_channels(self) -> set: """Return Discord channel IDs/names where no bot mention is required. @@ -6043,7 +6201,7 @@ class DiscordAdapter(BasePlatformAdapter): """ raw = self.config.extra.get("free_response_channels") if raw is None: - raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "") + raw = self._gate_env("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. @@ -6243,7 +6401,7 @@ class DiscordAdapter(BasePlatformAdapter): return "" # Determine which bot messages to include in context - allow_bots_raw = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() + allow_bots_raw = self._get_allow_bots() include_other_bots = allow_bots_raw != "none" # Use the in-memory cache to narrow the fetch window on hot paths. @@ -7451,16 +7609,14 @@ class DiscordAdapter(BasePlatformAdapter): channel_keys = self._discord_channel_keys(message, parent_channel_id) # 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 = self._get_allowed_channels() + 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 = self._get_ignored_channels() if "*" in ignored_channels or (channel_keys & ignored_channels): logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_keys) return False @@ -7499,8 +7655,7 @@ class DiscordAdapter(BasePlatformAdapter): # no_thread_channels: channels where bot responds directly without thread. auto_threaded_channel = None if not is_thread and not isinstance(message.channel, discord.DMChannel): - no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "") - no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()} + no_thread_channels = self._get_no_thread_channels() skip_thread = bool(channel_keys & no_thread_channels) or is_free_channel auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"} is_reply_message = getattr(message, "type", None) == discord.MessageType.reply @@ -8010,15 +8165,20 @@ 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"}: + # Scope-aware reads (issue #72348): component interactions are dispatched + # from discord.py tasks descended from the task created inside the owning + # profile's runtime scope, so the profile's secret-scope contextvar is + # inherited here. Under multiplex a raw os.getenv could return ANOTHER + # profile's allow-all flag and authorize a click on this profile's bot. + if _scoped_gate_env("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 _scoped_gate_env("GATEWAY_ALLOW_ALL_USERS").strip().lower() in {"true", "1", "yes"}: 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(",") + for uid in _scoped_gate_env("GATEWAY_ALLOWED_USERS").split(",") if uid.strip() } user_set.update(global_allowed) @@ -9671,14 +9831,42 @@ 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 = {} + # Authorization gate keys are ALWAYS seeded into PlatformConfig.extra so + # every adapter carries its own profile's allow/deny lists (issue #72348). + # The os.environ writes below remain first-writer-wins for legacy env-only + # consumers, but are skipped for profile-scoped loads under multiplex — + # a secondary profile's gates must never land in process-global env where + # they'd become another profile's policy. + _skip_env_bridge = _profile_scoped_config_load() 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 allowed_users_cfg is not None: 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) + seeded_extra["allow_from"] = str(allowed_users_cfg) + if not _skip_env_bridge and not os.getenv("DISCORD_ALLOWED_USERS"): + os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg) + allowed_roles_cfg = ( + discord_cfg["allowed_roles"] if "allowed_roles" in discord_cfg + else platform_extra_cfg.get("allowed_roles") + ) + if allowed_roles_cfg is not None: + if isinstance(allowed_roles_cfg, list): + allowed_roles_cfg = ",".join(str(v) for v in allowed_roles_cfg) + seeded_extra["allowed_roles"] = str(allowed_roles_cfg) + if not _skip_env_bridge and not os.getenv("DISCORD_ALLOWED_ROLES"): + os.environ["DISCORD_ALLOWED_ROLES"] = str(allowed_roles_cfg) + allow_all_cfg = ( + discord_cfg["allow_all_users"] if "allow_all_users" in discord_cfg + else platform_extra_cfg.get("allow_all_users") + ) + if allow_all_cfg is not None: + seeded_extra["allow_all_users"] = str(allow_all_cfg).lower() + if not _skip_env_bridge and not os.getenv("DISCORD_ALLOW_ALL_USERS"): + os.environ["DISCORD_ALLOW_ALL_USERS"] = str(allow_all_cfg).lower() approval_mentions_cfg = ( discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg else platform_extra_cfg.get("approval_mentions") @@ -9686,36 +9874,43 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: if approval_mentions_cfg is not None 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 frc is not None: if isinstance(frc, list): frc = ",".join(str(v) for v in frc) - os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) + seeded_extra["free_response_channels"] = str(frc) + if not _skip_env_bridge and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): + os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) 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) # 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 ic is not None: if isinstance(ic, list): ic = ",".join(str(v) for v in ic) - os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic) + seeded_extra["ignored_channels"] = str(ic) + if not _skip_env_bridge and not os.getenv("DISCORD_IGNORED_CHANNELS"): + os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic) # 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 ac is not None: if isinstance(ac, list): ac = ",".join(str(v) for v in ac) - os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac) + seeded_extra["allowed_channels"] = str(ac) + if not _skip_env_bridge and not os.getenv("DISCORD_ALLOWED_CHANNELS"): + os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac) # 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"): + if ntc is not None: if isinstance(ntc, list): ntc = ",".join(str(v) for v in ntc) - os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) + seeded_extra["no_thread_channels"] = str(ntc) + if not _skip_env_bridge and not os.getenv("DISCORD_NO_THREAD_CHANNELS"): + os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) # 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. diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 4a430702e85e3..ef31587d223d7 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -38,6 +38,23 @@ def _redact_telegram_error_text(error: object) -> str: return "" +def _scoped_gate_env(name: str, default: str = "") -> str: + """Read a TELEGRAM_*/GATEWAY_* authorization gate env var per-profile. + + Under gateway.multiplex_profiles the process env is first-writer-wins + (the YAML→env bridge in ``_apply_yaml_config``), so a raw ``os.getenv`` + can return ANOTHER profile's allowlist (issue #72348, Telegram mirror). + Reads the active profile's secret scope when installed; falls back to + ``os.getenv`` outside multiplex — identical single-profile behavior. + """ + try: + from gateway.authz_mixin import _platform_gate_env + + return _platform_gate_env(name, default) + except Exception: + return (os.getenv(name) or default).strip() + + def _consume_abandoned_task(task: asyncio.Task) -> None: """Observe a detached task's terminal exception to avoid noisy loop logs.""" try: @@ -947,13 +964,13 @@ class TelegramAdapter(BasePlatformAdapter): exc_info=True, ) - allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip() + allowed_csv = _scoped_gate_env("TELEGRAM_ALLOWED_USERS").strip() if not allowed_csv: # Fail-closed: no allowlist means deny by default. # The runner auth path in _is_user_authorized() handles # GATEWAY_ALLOW_ALL_USERS; this fallback must not silently # allow everyone (fixes #24457). - return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} + return _scoped_gate_env("GATEWAY_ALLOW_ALL_USERS").lower() in {"true", "1", "yes"} allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} return "*" in allowed_ids or normalized_user_id in allowed_ids @@ -1027,7 +1044,7 @@ class TelegramAdapter(BasePlatformAdapter): "GATEWAY_ALLOWED_USERS", "GATEWAY_ALLOW_ALL_USERS", ) - return any(os.getenv(key, "").strip() for key in keys) + return any(_scoped_gate_env(key).strip() for key in keys) def _should_pass_unauthorized_dm_for_pairing(self, source) -> bool: """Return True when an unauthorized DM must still reach gateway pairing. @@ -1137,7 +1154,7 @@ class TelegramAdapter(BasePlatformAdapter): ) if authorized is None: - allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip() + allowed_csv = _scoped_gate_env("TELEGRAM_ALLOWED_USERS").strip() if not allowed_csv: return True allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} @@ -7771,7 +7788,7 @@ class TelegramAdapter(BasePlatformAdapter): def _telegram_free_response_chats(self) -> set[str]: raw = self.config.extra.get("free_response_chats") if raw is None: - raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "") + raw = _scoped_gate_env("TELEGRAM_FREE_RESPONSE_CHATS") if isinstance(raw, list): return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} @@ -7785,7 +7802,7 @@ class TelegramAdapter(BasePlatformAdapter): """ raw = self.config.extra.get("free_response_topics") if raw is None: - raw = os.getenv("TELEGRAM_FREE_RESPONSE_TOPICS", "") + raw = _scoped_gate_env("TELEGRAM_FREE_RESPONSE_TOPICS") if isinstance(raw, list): return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} @@ -7812,7 +7829,7 @@ class TelegramAdapter(BasePlatformAdapter): """ raw = self.config.extra.get("allowed_chats") if raw is None: - raw = os.getenv("TELEGRAM_ALLOWED_CHATS", "") + raw = _scoped_gate_env("TELEGRAM_ALLOWED_CHATS") if isinstance(raw, list): return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} @@ -7821,7 +7838,7 @@ class TelegramAdapter(BasePlatformAdapter): """Return Telegram chats authorized at group scope.""" raw = self.config.extra.get("group_allowed_chats") if raw is None: - raw = os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS", "") + raw = _scoped_gate_env("TELEGRAM_GROUP_ALLOWED_CHATS") if isinstance(raw, list): return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} @@ -7851,7 +7868,7 @@ class TelegramAdapter(BasePlatformAdapter): """ raw = self.config.extra.get("allowed_topics") if raw is None: - raw = os.getenv("TELEGRAM_ALLOWED_TOPICS", "") + raw = _scoped_gate_env("TELEGRAM_ALLOWED_TOPICS") if isinstance(raw, list): return {str(part).strip() for part in raw if str(part).strip()} return {part.strip() for part in str(raw).split(",") if part.strip()} @@ -7859,7 +7876,7 @@ class TelegramAdapter(BasePlatformAdapter): def _telegram_ignored_threads(self) -> set[int]: raw = self.config.extra.get("ignored_threads") if raw is None: - raw = os.getenv("TELEGRAM_IGNORED_THREADS", "") + raw = _scoped_gate_env("TELEGRAM_IGNORED_THREADS") if isinstance(raw, list): values = raw @@ -9955,6 +9972,18 @@ def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None: import json as _json extras: dict = {} + # Under multiplex, a secondary profile's config loads inside its runtime + # scope; its authorization gate values must NOT be written to the + # process-global env, where first-writer-wins would pin them for every + # other profile (issue #72348 Telegram mirror). They are seeded into + # PlatformConfig.extra / read via the profile secret scope instead. + try: + from agent.secret_scope import current_secret_scope, is_multiplex_active + + _skip_env_bridge = bool(is_multiplex_active() and current_secret_scope() is not None) + except Exception: + _skip_env_bridge = False + if "disable_topic_auto_rename" in telegram_cfg: extras.setdefault("disable_topic_auto_rename", telegram_cfg["disable_topic_auto_rename"]) @@ -9972,30 +10001,41 @@ def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None: if "observe_unmentioned_group_messages" in telegram_cfg and not os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"): os.environ["TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] = str(telegram_cfg["observe_unmentioned_group_messages"]).lower() frc = telegram_cfg.get("free_response_chats") - if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"): + if frc is not None: + extras.setdefault("free_response_chats", frc) if isinstance(frc, list): frc = ",".join(str(v) for v in frc) - os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc) + if not _skip_env_bridge and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"): + os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc) frt = telegram_cfg.get("free_response_topics") - if frt is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_TOPICS"): + if frt is not None: if isinstance(frt, list): frt = ",".join(str(v) for v in frt) - os.environ["TELEGRAM_FREE_RESPONSE_TOPICS"] = str(frt) + if not _skip_env_bridge and not os.getenv("TELEGRAM_FREE_RESPONSE_TOPICS"): + os.environ["TELEGRAM_FREE_RESPONSE_TOPICS"] = str(frt) ac = telegram_cfg.get("allowed_chats") - if ac is not None and not os.getenv("TELEGRAM_ALLOWED_CHATS"): + if ac is not None: if isinstance(ac, list): ac = ",".join(str(v) for v in ac) - os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac) + # NOTE: no extras seed here — gateway/config.py's shared-key loop + # already bridges ``allowed_chats`` into PlatformConfig.extra with its + # original type, and the apply_yaml_config merge would clobber it. + if not _skip_env_bridge and not os.getenv("TELEGRAM_ALLOWED_CHATS"): + os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac) allowed_topics = telegram_cfg.get("allowed_topics") - if allowed_topics is not None and not os.getenv("TELEGRAM_ALLOWED_TOPICS"): + if allowed_topics is not None: if isinstance(allowed_topics, list): allowed_topics = ",".join(str(v) for v in allowed_topics) - os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics) + # extras seed intentionally omitted (shared-key loop bridges allowed_topics). + if not _skip_env_bridge and not os.getenv("TELEGRAM_ALLOWED_TOPICS"): + os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics) ignored_threads = telegram_cfg.get("ignored_threads") - if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"): + if ignored_threads is not None: + extras.setdefault("ignored_threads", ignored_threads) if isinstance(ignored_threads, list): ignored_threads = ",".join(str(v) for v in ignored_threads) - os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads) + if not _skip_env_bridge and not os.getenv("TELEGRAM_IGNORED_THREADS"): + os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads) if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"): os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower() if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"): @@ -10009,20 +10049,24 @@ def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None: _rtm_str = "off" if _telegram_rtm is False else str(_telegram_rtm).lower() os.environ["TELEGRAM_REPLY_TO_MODE"] = _rtm_str allowed_users = telegram_cfg.get("allow_from") - if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"): + if allowed_users is not None: if isinstance(allowed_users, list): allowed_users = ",".join(str(v) for v in allowed_users) - os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users) + if not _skip_env_bridge and not os.getenv("TELEGRAM_ALLOWED_USERS"): + os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users) group_allowed_users = telegram_cfg.get("group_allow_from") or _telegram_extra.get("group_allow_from") - if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"): + if group_allowed_users is not None: if isinstance(group_allowed_users, list): group_allowed_users = ",".join(str(v) for v in group_allowed_users) - os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users) + if not _skip_env_bridge and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"): + os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users) group_allowed_chats = telegram_cfg.get("group_allowed_chats") or _telegram_extra.get("group_allowed_chats") - if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"): + if group_allowed_chats is not None: if isinstance(group_allowed_chats, list): group_allowed_chats = ",".join(str(v) for v in group_allowed_chats) - os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats) + # extras seed intentionally omitted (shared-key loop bridges group_allowed_chats). + if not _skip_env_bridge and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"): + os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats) for _key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages", "free_response_topics"): if _key in telegram_cfg: extras.setdefault(_key, telegram_cfg[_key]) diff --git a/tests/plugins/platforms/test_discord_gate_isolation.py b/tests/plugins/platforms/test_discord_gate_isolation.py new file mode 100644 index 0000000000000..dbd3aa65cc89a --- /dev/null +++ b/tests/plugins/platforms/test_discord_gate_isolation.py @@ -0,0 +1,434 @@ +"""Per-profile isolation of Discord/Telegram allow/deny gates (issue #72348). + +Under ``gateway.multiplex_profiles: true`` every adapter must enforce ITS OWN +profile's allow/deny lists. The historical bugs: + +1. First-writer-wins YAML→env bridge: the first profile's + ``_apply_yaml_config`` wrote ``DISCORD_ALLOWED_CHANNELS`` (etc.) into the + process-global ``os.environ``; later profiles' values were dropped. +2. Inbound gates read ``os.getenv`` directly, so every adapter enforced the + FIRST profile's channel/user/role allowlists. +3. Allow-all flags (``DISCORD_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS``) + read from process env: profile A opting in to open access opened profile B. +4. ``_resolve_allowed_usernames`` unconditionally rewrote + ``os.environ["DISCORD_ALLOWED_USERS"]`` at runtime. + +These tests build two adapter instances with different gate snapshots/extras +and assert each enforces only its own lists, order-independently. +""" + +import os + +import pytest + +from gateway.config import Platform, PlatformConfig +from plugins.platforms.discord.adapter import DiscordAdapter, _GATE_ENV_KEYS + + +GATE_VARS = [ + "DISCORD_ALLOWED_CHANNELS", + "DISCORD_IGNORED_CHANNELS", + "DISCORD_ALLOWED_USERS", + "DISCORD_ALLOWED_ROLES", + "DISCORD_ALLOW_ALL_USERS", + "GATEWAY_ALLOW_ALL_USERS", + "GATEWAY_ALLOWED_USERS", + "DISCORD_NO_THREAD_CHANNELS", + "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_ALLOW_BOTS", +] + + +@pytest.fixture(autouse=True) +def _clean_gate_env(monkeypatch): + for var in GATE_VARS: + monkeypatch.delenv(var, raising=False) + yield + # monkeypatch.delenv on an ABSENT var records nothing, so env writes made + # during the test (e.g. _apply_yaml_config's legacy bridge) would leak + # into later test modules. Scrub explicitly. + for var in GATE_VARS: + os.environ.pop(var, None) + + +def _adapter(extra: dict | None = None) -> DiscordAdapter: + adapter = object.__new__(DiscordAdapter) + adapter.platform = Platform.DISCORD + adapter.config = PlatformConfig(enabled=True, token="x", extra=dict(extra or {})) + adapter._gate_env_snapshot = None + adapter._allowed_user_ids = set() + adapter._allowed_role_ids = set() + return adapter + + +def _snapshot(adapter: DiscordAdapter, values: dict) -> None: + """Simulate the connect()-time per-profile snapshot.""" + adapter._gate_env_snapshot = {key: values.get(key, "") for key in _GATE_ENV_KEYS} + + +class TestTwoAdapterChannelIsolation: + """Two adapters with different allowed_channels enforce their OWN lists.""" + + def test_snapshots_isolate_allowed_channels(self): + a = _adapter() + b = _adapter() + _snapshot(a, {"DISCORD_ALLOWED_CHANNELS": "111"}) + _snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"}) + + assert a._get_allowed_channels() == {"111"} + assert b._get_allowed_channels() == {"222"} + + def test_order_independent(self): + # Reverse construction order — the winner must not change. + b = _adapter() + _snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"}) + a = _adapter() + _snapshot(a, {"DISCORD_ALLOWED_CHANNELS": "111"}) + + assert a._discord_channel_ids_allowed({"111"}) is True + assert a._discord_channel_ids_allowed({"222"}) is False + assert b._discord_channel_ids_allowed({"222"}) is True + assert b._discord_channel_ids_allowed({"111"}) is False + + def test_extras_isolate_allowed_channels_without_snapshot(self): + """Config-extra seeding isolates gates even before connect().""" + a = _adapter({"allowed_channels": "111"}) + b = _adapter({"allowed_channels": "222"}) + + assert a._get_allowed_channels() == {"111"} + assert b._get_allowed_channels() == {"222"} + + def test_process_env_does_not_leak_into_snapshotted_adapter(self, monkeypatch): + """A first-writer process-global env value must not override a + snapshotted adapter's own (empty) gate.""" + monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "999") + b = _adapter({"allowed_channels": "222"}) + _snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"}) + assert b._get_allowed_channels() == {"222"} + + def test_ignored_channels_isolated(self): + a = _adapter() + b = _adapter() + _snapshot(a, {"DISCORD_IGNORED_CHANNELS": "311"}) + _snapshot(b, {"DISCORD_IGNORED_CHANNELS": "322"}) + assert a._get_ignored_channels() == {"311"} + assert b._get_ignored_channels() == {"322"} + + +class TestTwoAdapterUserRoleIsolation: + def test_allowed_users_isolated(self): + a = _adapter() + b = _adapter() + _snapshot(a, {"DISCORD_ALLOWED_USERS": "1001,<@1002>"}) + _snapshot(b, {"DISCORD_ALLOWED_USERS": "2001"}) + assert a._get_allowed_users() == {"1001", "1002"} + assert b._get_allowed_users() == {"2001"} + + def test_allowed_roles_isolated(self): + a = _adapter() + b = _adapter() + _snapshot(a, {"DISCORD_ALLOWED_ROLES": "31,32"}) + _snapshot(b, {"DISCORD_ALLOWED_ROLES": "41"}) + assert a._get_allowed_roles() == {31, 32} + assert b._get_allowed_roles() == {41} + + def test_is_allowed_user_enforces_own_list(self, monkeypatch): + # Pairing store must not interfere. + monkeypatch.setattr( + DiscordAdapter, "_is_pairing_approved_user", lambda self, uid: False + ) + a = _adapter() + b = _adapter() + _snapshot(a, {"DISCORD_ALLOWED_USERS": "1001"}) + _snapshot(b, {"DISCORD_ALLOWED_USERS": "2001"}) + a._allowed_user_ids = a._get_allowed_users() + b._allowed_user_ids = b._get_allowed_users() + + assert a._is_allowed_user("1001") is True + assert a._is_allowed_user("2001") is False + assert b._is_allowed_user("2001") is True + assert b._is_allowed_user("1001") is False + + +class TestAllowAllFlagIsolation: + """Profile A's allow-all flag must never authorize profile B (negative case).""" + + def test_discord_allow_all_isolated(self, monkeypatch): + monkeypatch.setattr( + DiscordAdapter, "_is_pairing_approved_user", lambda self, uid: False + ) + open_profile = _adapter() + closed_profile = _adapter() + _snapshot(open_profile, {"DISCORD_ALLOW_ALL_USERS": "true"}) + _snapshot(closed_profile, {}) + + assert open_profile._is_allowed_user("555") is True + assert closed_profile._is_allowed_user("555") is False + + def test_env_allow_all_does_not_open_snapshotted_adapter(self, monkeypatch): + """First-writer env DISCORD_ALLOW_ALL_USERS=true (profile A) must not + open a snapshotted profile B.""" + monkeypatch.setattr( + DiscordAdapter, "_is_pairing_approved_user", lambda self, uid: False + ) + monkeypatch.setenv("DISCORD_ALLOW_ALL_USERS", "true") + b = _adapter() + _snapshot(b, {}) # profile B: no allow-all, no allowlists + assert b._discord_allow_all_users() is False + assert b._is_allowed_user("555") is False + + def test_gateway_allow_all_isolated(self, monkeypatch): + monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true") + b = _adapter() + _snapshot(b, {}) + assert b._gateway_allow_all_users() is False + + +class TestSlashGateIsolation: + """Slash-command channel gates use per-adapter values too.""" + + def test_evaluate_slash_channel_gate_per_adapter(self, monkeypatch): + import types + + import discord as discord_lib + + a = _adapter() + b = _adapter() + _snapshot(a, {"DISCORD_ALLOWED_CHANNELS": "111"}) + _snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"}) + + def _keys(self, chan, parent): + return {str(getattr(chan, "id", ""))} + + monkeypatch.setattr( + DiscordAdapter, "_discord_channel_keys_from_channel", _keys + ) + monkeypatch.setattr( + DiscordAdapter, "_get_parent_channel_id", lambda self, c: None + ) + + chan = types.SimpleNamespace(id=111) + interaction = types.SimpleNamespace( + channel=chan, channel_id=111, user=types.SimpleNamespace(id=999, roles=[]), + ) + # channel 111: allowed for A's gate... + allowed_a, reason_a = a._evaluate_slash_authorization(interaction) + # ...but B must reject it on ITS channel gate. + allowed_b, reason_b = b._evaluate_slash_authorization(interaction) + assert reason_a != "channel not in DISCORD_ALLOWED_CHANNELS" + assert allowed_b is False + assert reason_b == "channel not in DISCORD_ALLOWED_CHANNELS" + + +class TestUsernameResolutionEnvWrite: + """_resolve_allowed_usernames must not clobber process env under multiplex.""" + + @pytest.mark.asyncio + async def test_no_env_write_when_multiplex_active(self, monkeypatch): + from agent import secret_scope + + monkeypatch.setenv("DISCORD_ALLOWED_USERS", "999") + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + + adapter = _adapter() + _snapshot(adapter, {"DISCORD_ALLOWED_USERS": "teknium"}) + adapter._allowed_user_ids = {"teknium"} + + member = type( + "M", + (), + { + "id": 12345, + "name": "teknium", + "display_name": "teknium", + "global_name": "teknium", + "discriminator": "0", + }, + )() + guild = type( + "G", (), {"members": [member], "member_count": 1, "name": "g"}, + )() + adapter._client = type("C", (), {"guilds": [guild]})() + + await adapter._resolve_allowed_usernames() + + assert adapter._allowed_user_ids == {"12345"} + # Snapshot updated for this adapter only. + assert adapter._gate_env_snapshot["DISCORD_ALLOWED_USERS"] == "12345" + # Process-global env untouched — other profiles unaffected. + assert os.environ["DISCORD_ALLOWED_USERS"] == "999" + + @pytest.mark.asyncio + async def test_env_write_preserved_single_profile(self, monkeypatch): + from agent import secret_scope + + monkeypatch.setenv("DISCORD_ALLOWED_USERS", "teknium") + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + + adapter = _adapter() + adapter._allowed_user_ids = {"teknium"} + + member = type( + "M", + (), + { + "id": 12345, + "name": "teknium", + "display_name": "teknium", + "global_name": "teknium", + "discriminator": "0", + }, + )() + guild = type( + "G", (), {"members": [member], "member_count": 1, "name": "g"}, + )() + adapter._client = type("C", (), {"guilds": [guild]})() + + await adapter._resolve_allowed_usernames() + + # Legacy single-profile behavior: env rewritten to resolved IDs. + assert os.environ["DISCORD_ALLOWED_USERS"] == "12345" + + +class TestYamlBridgeSeeding: + """_apply_yaml_config seeds gates into extra and skips env writes when + loading a profile-scoped config under multiplex.""" + + def test_seeds_extra_and_bridges_env_single_profile(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.discord.adapter import _apply_yaml_config + + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + seeded = _apply_yaml_config( + {}, + { + "allowed_channels": ["111", "112"], + "ignored_channels": "333", + "allow_from": ["1001"], + "allowed_roles": [31], + "allow_all_users": False, + }, + ) + assert seeded["allowed_channels"] == "111,112" + assert seeded["ignored_channels"] == "333" + assert seeded["allow_from"] == "1001" + assert seeded["allowed_roles"] == "31" + assert seeded["allow_all_users"] == "false" + # Legacy env bridge preserved for single-profile deployments. + assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "111,112" + + def test_profile_scoped_load_skips_env_bridge(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.discord.adapter import _apply_yaml_config + + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({"SOME": "scope"}) + try: + seeded = _apply_yaml_config( + {}, {"allowed_channels": "222", "allow_from": "2001"}, + ) + finally: + secret_scope.reset_secret_scope(token) + + # Gates still seeded per-adapter... + assert seeded["allowed_channels"] == "222" + assert seeded["allow_from"] == "2001" + # ...but process-global env stays clean: no cross-profile leak. + assert os.getenv("DISCORD_ALLOWED_CHANNELS") is None + assert os.getenv("DISCORD_ALLOWED_USERS") is None + + def test_first_writer_env_does_not_mask_second_profile_extras(self, monkeypatch): + """End-to-end shape of the original repro: profile A bridges env first; + profile B (scoped load) still gets ITS channels via extras.""" + from agent import secret_scope + from plugins.platforms.discord.adapter import _apply_yaml_config + + # Profile A: single first load (multiplex flag not yet set). + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + seeded_a = _apply_yaml_config({}, {"allowed_channels": "111"}) + assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "111" + + # Profile B: scoped load under multiplex. + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({}) + try: + seeded_b = _apply_yaml_config({}, {"allowed_channels": "222"}) + finally: + secret_scope.reset_secret_scope(token) + + a = _adapter(seeded_a) + b = _adapter(seeded_b) + # B's snapshot taken inside its (empty-env) profile scope. + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({}) + try: + b._snapshot_gate_env() + finally: + secret_scope.reset_secret_scope(token) + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + a._snapshot_gate_env() + + assert a._get_allowed_channels() == {"111"} + assert b._get_allowed_channels() == {"222"} + + +class TestTelegramGateIsolation: + """Telegram mirror (reported by @yournetworkplug-ctrl in #72348).""" + + def test_scoped_gate_env_prefers_profile_scope(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.telegram.adapter import _scoped_gate_env + + monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111") + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope( + {"TELEGRAM_ALLOWED_USERS": "222222222"} + ) + try: + assert _scoped_gate_env("TELEGRAM_ALLOWED_USERS") == "222222222" + finally: + secret_scope.reset_secret_scope(token) + + def test_scoped_gate_env_authoritative_scope_miss(self, monkeypatch): + """Under multiplex, a scope WITHOUT the key must not fall through to + another profile's process-env value.""" + from agent import secret_scope + from plugins.platforms.telegram.adapter import _scoped_gate_env + + monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111") + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({}) + try: + assert _scoped_gate_env("TELEGRAM_ALLOWED_USERS") == "" + finally: + secret_scope.reset_secret_scope(token) + + def test_scoped_gate_env_single_profile_fallback(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.telegram.adapter import _scoped_gate_env + + monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111") + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + assert _scoped_gate_env("TELEGRAM_ALLOWED_USERS") == "111111111" + + def test_telegram_yaml_bridge_skipped_for_scoped_profile(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.telegram.adapter import _apply_yaml_config + + monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False) + monkeypatch.delenv("TELEGRAM_ALLOWED_USERS", raising=False) + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({}) + try: + extras = _apply_yaml_config( + {}, {"allowed_chats": ["-100200"], "allow_from": "222222222"}, + ) + finally: + secret_scope.reset_secret_scope(token) + + # allowed_chats reaches PlatformConfig.extra via the shared-key loop + # in gateway/config.py (type-preserving); _apply_yaml_config must not + # write either gate into the process-global env for a scoped profile. + assert extras is None or "allowed_chats" not in extras + assert os.getenv("TELEGRAM_ALLOWED_CHATS") is None + assert os.getenv("TELEGRAM_ALLOWED_USERS") is None