Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 60 additions & 20 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down Expand Up @@ -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
Expand All @@ -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),
)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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".
Expand All @@ -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")
Expand Down
211 changes: 211 additions & 0 deletions tests/plugins/platforms/test_discord_gate_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand Down Expand Up @@ -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)."""

Expand Down
Loading