diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 79268849df51d..d6a6d6a426734 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -439,6 +439,8 @@ def _clean_discord_id(entry: str) -> str: "DISCORD_ALLOW_BOTS", "GATEWAY_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS", + "DISCORD_REACTIONS", + "DISCORD_AUTO_THREAD", ) @@ -520,7 +522,7 @@ def check_discord_requirements() -> bool: return True -def _build_allowed_mentions(): +def _build_allowed_mentions(extra: Optional[dict] = None): """Build Discord ``AllowedMentions`` with safe defaults, overridable via env. Discord bots default to parsing ``@everyone``, ``@here``, role pings, and @@ -537,21 +539,35 @@ def _build_allowed_mentions(): DISCORD_ALLOW_MENTION_ROLES default false — @role pings DISCORD_ALLOW_MENTION_USERS default true — @user pings DISCORD_ALLOW_MENTION_REPLIED_USER default true — reply-ping author + + Scope-aware via ``_scoped_gate_env`` (issue #72348's mechanism): a + secondary multiplex profile's own scope/``extra.allow_mentions`` is + authoritative, so it can't borrow the default profile's bridged + ``DISCORD_ALLOW_MENTION_*`` env — including a permissive + ``everyone=true`` that would otherwise let every profile's bot ping a + whole server. """ if not DISCORD_AVAILABLE: return None - def _b(name: str, default: bool) -> bool: - raw = os.getenv(name, "").strip().lower() - if not raw: - return default - return raw in {"true", "1", "yes", "on"} + allow_mentions_extra = (extra or {}).get("allow_mentions") + if not isinstance(allow_mentions_extra, dict): + allow_mentions_extra = {} + + def _b(name: str, extra_key: str, default: bool) -> bool: + raw = _scoped_gate_env(name, "").strip().lower() + if raw: + return raw in {"true", "1", "yes", "on"} + extra_raw = allow_mentions_extra.get(extra_key) + if extra_raw is not None: + return str(extra_raw).strip().lower() in {"true", "1", "yes", "on"} + return default return discord.AllowedMentions( - everyone=_b("DISCORD_ALLOW_MENTION_EVERYONE", False), - roles=_b("DISCORD_ALLOW_MENTION_ROLES", False), - users=_b("DISCORD_ALLOW_MENTION_USERS", True), - replied_user=_b("DISCORD_ALLOW_MENTION_REPLIED_USER", True), + everyone=_b("DISCORD_ALLOW_MENTION_EVERYONE", "everyone", False), + roles=_b("DISCORD_ALLOW_MENTION_ROLES", "roles", False), + users=_b("DISCORD_ALLOW_MENTION_USERS", "users", True), + replied_user=_b("DISCORD_ALLOW_MENTION_REPLIED_USER", "replied_user", True), ) @@ -1381,7 +1397,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: self._client = commands.Bot( command_prefix="!", # Not really used, we handle raw messages intents=intents, - allowed_mentions=_build_allowed_mentions(), + allowed_mentions=_build_allowed_mentions(getattr(self.config, "extra", None)), **proxy_kwargs_for_bot(proxy_url), ) adapter_self = self # capture for closure @@ -3350,8 +3366,27 @@ async def _remove_reaction(self, message: Any, emoji: str) -> bool: return False def _reactions_enabled(self) -> bool: - """Check if message reactions are enabled via config/env.""" - return os.getenv("DISCORD_REACTIONS", "true").lower() not in {"false", "0", "no"} + """Check if message reactions are enabled via config/env. + + Uses the established per-profile gate accessor (issue #72348) so a + secondary multiplex profile's own scope/``extra.reactions`` is + authoritative instead of the default profile's bridged + ``DISCORD_REACTIONS``. + """ + raw = self._gate_raw("reactions", "DISCORD_REACTIONS") + if raw is None: + return True + return str(raw).strip().lower() not in {"false", "0", "no"} + + def _auto_thread_enabled(self) -> bool: + """Check if auto-threading is enabled via config/env (per-profile). + + Same gate accessor as ``_reactions_enabled`` — see its docstring. + """ + raw = self._gate_raw("auto_thread", "DISCORD_AUTO_THREAD") + if raw is None: + return True + return str(raw).strip().lower() in {"true", "1", "yes"} async def on_processing_start(self, event: MessageEvent) -> None: """Add an in-progress reaction and record durable handling state.""" @@ -8200,7 +8235,7 @@ async def _handle_message( if not is_thread and not isinstance(message.channel, discord.DMChannel): 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"} + auto_thread = self._auto_thread_enabled() is_reply_message = getattr(message, "type", None) == discord.MessageType.reply if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message: thread = await self._auto_create_thread(message) @@ -10473,10 +10508,14 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: 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() + if "auto_thread" in discord_cfg: + seeded_extra["auto_thread"] = str(discord_cfg["auto_thread"]).lower() + if not _skip_env_bridge and not os.getenv("DISCORD_AUTO_THREAD"): + os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() + if "reactions" in discord_cfg: + seeded_extra["reactions"] = str(discord_cfg["reactions"]).lower() + if not _skip_env_bridge and not os.getenv("DISCORD_REACTIONS"): + os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower() backfill_cfg = discord_cfg.get("missed_message_backfill") if isinstance(backfill_cfg, dict): seeded_extra["missed_message_backfill"] = dict(backfill_cfg) @@ -10518,13 +10557,14 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: # into unsafe modes (e.g. roles=true) if they actually want it. allow_mentions_cfg = discord_cfg.get("allow_mentions") if isinstance(allow_mentions_cfg, dict): + seeded_extra["allow_mentions"] = dict(allow_mentions_cfg) for yaml_key, env_key in ( ("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"), ("roles", "DISCORD_ALLOW_MENTION_ROLES"), ("users", "DISCORD_ALLOW_MENTION_USERS"), ("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"), ): - if yaml_key in allow_mentions_cfg and not os.getenv(env_key): + if yaml_key in allow_mentions_cfg and not _skip_env_bridge and not os.getenv(env_key): os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower() # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode. # YAML 1.1 parses bare 'off' as boolean False — coerce to string "off". @@ -10533,7 +10573,7 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg else _discord_extra.get("reply_to_mode") ) - if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"): + if _discord_rtm is not None and not _skip_env_bridge and not os.getenv("DISCORD_REPLY_TO_MODE"): _rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower() os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str _websocket_extra_cfg = discord_cfg.get("extra") diff --git a/tests/plugins/platforms/test_discord_gate_isolation.py b/tests/plugins/platforms/test_discord_gate_isolation.py index 0844c7372a3c8..05de2ca5b3334 100644 --- a/tests/plugins/platforms/test_discord_gate_isolation.py +++ b/tests/plugins/platforms/test_discord_gate_isolation.py @@ -36,6 +36,13 @@ "DISCORD_NO_THREAD_CHANNELS", "DISCORD_FREE_RESPONSE_CHANNELS", "DISCORD_ALLOW_BOTS", + "DISCORD_REACTIONS", + "DISCORD_AUTO_THREAD", + "DISCORD_ALLOW_MENTION_EVERYONE", + "DISCORD_ALLOW_MENTION_ROLES", + "DISCORD_ALLOW_MENTION_USERS", + "DISCORD_ALLOW_MENTION_REPLIED_USER", + "DISCORD_REPLY_TO_MODE", ] @@ -374,6 +381,210 @@ def test_first_writer_env_does_not_mask_second_profile_extras(self, monkeypatch) assert b._get_allowed_channels() == {"222"} +class TestReactionsAutoThreadGateIsolation: + """_reactions_enabled/_auto_thread_enabled must use the per-profile gate + accessor too (sibling gap next to the channel/user/role gates above).""" + + def test_reactions_isolated(self): + a = _adapter() + b = _adapter() + _snapshot(a, {"DISCORD_REACTIONS": "false"}) + _snapshot(b, {"DISCORD_REACTIONS": "true"}) + assert a._reactions_enabled() is False + assert b._reactions_enabled() is True + + def test_reactions_default_true_when_unset(self): + a = _adapter() + _snapshot(a, {}) + assert a._reactions_enabled() is True + + def test_reactions_extra_used_without_snapshot(self): + a = _adapter({"reactions": "false"}) + assert a._reactions_enabled() is False + + def test_process_env_does_not_leak_into_snapshotted_reactions(self, monkeypatch): + monkeypatch.setenv("DISCORD_REACTIONS", "false") + b = _adapter({"reactions": "true"}) + _snapshot(b, {"DISCORD_REACTIONS": "true"}) + assert b._reactions_enabled() is True + + def test_auto_thread_isolated(self): + a = _adapter() + b = _adapter() + _snapshot(a, {"DISCORD_AUTO_THREAD": "false"}) + _snapshot(b, {"DISCORD_AUTO_THREAD": "true"}) + assert a._auto_thread_enabled() is False + assert b._auto_thread_enabled() is True + + def test_auto_thread_default_true_when_unset(self): + a = _adapter() + _snapshot(a, {}) + assert a._auto_thread_enabled() is True + + def test_process_env_does_not_leak_into_snapshotted_auto_thread(self, monkeypatch): + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + b = _adapter({"auto_thread": "true"}) + _snapshot(b, {"DISCORD_AUTO_THREAD": "true"}) + assert b._auto_thread_enabled() is True + + +class TestAllowedMentionsScope: + """_build_allowed_mentions must not borrow the default profile's bridged + DISCORD_ALLOW_MENTION_* env — a permissive everyone=true leaking across + profiles lets every bot ping a whole server.""" + + def test_scoped_profile_falls_closed_to_safe_default(self, monkeypatch): + """Secondary profile scope has no override: safe defaults apply, the + default profile's bridged env is not consulted.""" + discord_lib = pytest.importorskip( + "discord", reason="discord.py optional dep not installed" + ) + from agent import secret_scope + from plugins.platforms.discord.adapter import _build_allowed_mentions + + monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "true") + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({}) + try: + am = _build_allowed_mentions() + finally: + secret_scope.reset_secret_scope(token) + assert am.everyone is False + + def test_scoped_profile_uses_own_extra(self, monkeypatch): + """A scoped secondary profile's own config.yaml allow_mentions block + is honored even though env is not consulted.""" + discord_lib = pytest.importorskip( + "discord", reason="discord.py optional dep not installed" + ) + from agent import secret_scope + from plugins.platforms.discord.adapter import _build_allowed_mentions + + monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "false") + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({}) + try: + am = _build_allowed_mentions({"allow_mentions": {"everyone": True}}) + finally: + secret_scope.reset_secret_scope(token) + assert am.everyone is True + + def test_single_profile_env_precedence_unchanged(self, monkeypatch): + """Unscoped (single-profile) behavior is unaffected by the extra + parameter — legacy env-over-default precedence still applies.""" + discord_lib = pytest.importorskip( + "discord", reason="discord.py optional dep not installed" + ) + from agent import secret_scope + + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "true") + from plugins.platforms.discord.adapter import _build_allowed_mentions + + am = _build_allowed_mentions() + assert am.everyone is True + + +class TestReactionsAutoThreadMentionsReplyModeYamlBridge: + """_apply_yaml_config's env writes for auto_thread/reactions/ + allow_mentions/reply_to_mode were unconditional (no _skip_env_bridge + check) — sibling gap next to the auth-gate keys TestYamlBridgeSeeding + already covers.""" + + def test_auto_thread_reactions_skip_env_bridge_when_scoped(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.discord.adapter import _apply_yaml_config + + monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) + monkeypatch.delenv("DISCORD_REACTIONS", raising=False) + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({}) + try: + seeded = _apply_yaml_config( + {}, {"auto_thread": False, "reactions": False}, + ) + finally: + secret_scope.reset_secret_scope(token) + + assert seeded["auto_thread"] == "false" + assert seeded["reactions"] == "false" + assert os.getenv("DISCORD_AUTO_THREAD") is None + assert os.getenv("DISCORD_REACTIONS") is None + + def test_auto_thread_reactions_bridge_env_single_profile(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.discord.adapter import _apply_yaml_config + + monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) + monkeypatch.delenv("DISCORD_REACTIONS", raising=False) + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + seeded = _apply_yaml_config({}, {"auto_thread": False, "reactions": False}) + + assert seeded["auto_thread"] == "false" + assert os.environ["DISCORD_AUTO_THREAD"] == "false" + assert os.environ["DISCORD_REACTIONS"] == "false" + + def test_allow_mentions_skips_env_bridge_when_scoped(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.discord.adapter import _apply_yaml_config + + for var in ( + "DISCORD_ALLOW_MENTION_EVERYONE", + "DISCORD_ALLOW_MENTION_ROLES", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({}) + try: + seeded = _apply_yaml_config( + {}, {"allow_mentions": {"everyone": True, "roles": True}}, + ) + finally: + secret_scope.reset_secret_scope(token) + + assert seeded["allow_mentions"] == {"everyone": True, "roles": True} + assert os.getenv("DISCORD_ALLOW_MENTION_EVERYONE") is None + assert os.getenv("DISCORD_ALLOW_MENTION_ROLES") is None + + def test_allow_mentions_bridges_env_single_profile(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.discord.adapter import _apply_yaml_config + + for var in ( + "DISCORD_ALLOW_MENTION_EVERYONE", + "DISCORD_ALLOW_MENTION_ROLES", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + _apply_yaml_config({}, {"allow_mentions": {"everyone": True}}) + assert os.environ["DISCORD_ALLOW_MENTION_EVERYONE"] == "true" + + def test_reply_to_mode_skips_env_bridge_when_scoped(self, monkeypatch): + """The reverse-direction leak: a secondary profile's reply_to_mode + must not pollute env for the (unscoped) default profile to inherit.""" + from agent import secret_scope + from plugins.platforms.discord.adapter import _apply_yaml_config + + monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False) + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({}) + try: + _apply_yaml_config({}, {"reply_to_mode": "off"}) + finally: + secret_scope.reset_secret_scope(token) + + assert os.getenv("DISCORD_REPLY_TO_MODE") is None + + def test_reply_to_mode_bridges_env_single_profile(self, monkeypatch): + from agent import secret_scope + from plugins.platforms.discord.adapter import _apply_yaml_config + + monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False) + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False) + _apply_yaml_config({}, {"reply_to_mode": "off"}) + assert os.environ["DISCORD_REPLY_TO_MODE"] == "off" + + class TestTelegramGateIsolation: """Telegram mirror (reported by @yournetworkplug-ctrl in #72348)."""