diff --git a/gateway/config.py b/gateway/config.py index 5efd36729d..c9b22d4e77 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -617,6 +617,20 @@ def load_gateway_config() -> GatewayConfig: if isinstance(ntc, list): ntc = ",".join(str(v) for v in ntc) os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) + # 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 + # 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): + 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): + os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower() # Telegram settings → env vars (env vars take precedence) telegram_cfg = yaml_cfg.get("telegram", {}) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index ba128ad663..45e37432de 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -80,6 +80,41 @@ def check_discord_requirements() -> bool: return DISCORD_AVAILABLE +def _build_allowed_mentions(): + """Build Discord ``AllowedMentions`` with safe defaults, overridable via env. + + Discord bots default to parsing ``@everyone``, ``@here``, role pings, and + user pings when ``allowed_mentions`` is unset on the client — any LLM + output or echoed user content that contains ``@everyone`` would therefore + ping the whole server. We explicitly deny ``@everyone`` and role pings + by default and keep user / replied-user pings enabled so normal + conversation still works. + + Override via environment variables (or ``discord.allow_mentions.*`` in + config.yaml): + + DISCORD_ALLOW_MENTION_EVERYONE default false — @everyone + @here + 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 + """ + 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") + + 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), + ) + + class VoiceReceiver: """Captures and decodes voice audio from a Discord voice channel. @@ -556,10 +591,15 @@ class DiscordAdapter(BasePlatformAdapter): if proxy_url: logger.info("[%s] Using proxy for Discord: %s", self.name, proxy_url) - # Create bot — proxy= for HTTP, connector= for SOCKS + # Create bot — proxy= for HTTP, connector= for SOCKS. + # allowed_mentions is set with safe defaults (no @everyone/roles) + # so LLM output or echoed user content can't ping the whole + # server; override per DISCORD_ALLOW_MENTION_* env vars or the + # discord.allow_mentions.* block in config.yaml. self._client = commands.Bot( command_prefix="!", # Not really used, we handle raw messages intents=intents, + allowed_mentions=_build_allowed_mentions(), **proxy_kwargs_for_bot(proxy_url), ) adapter_self = self # capture for closure diff --git a/tests/gateway/test_discord_allowed_mentions.py b/tests/gateway/test_discord_allowed_mentions.py new file mode 100644 index 0000000000..c717c3cd19 --- /dev/null +++ b/tests/gateway/test_discord_allowed_mentions.py @@ -0,0 +1,155 @@ +"""Tests for the Discord ``allowed_mentions`` safe-default helper. + +Ensures the bot defaults to blocking ``@everyone`` / ``@here`` / role pings +so an LLM response (or echoed user content) can't spam a whole server — +and that the four ``DISCORD_ALLOW_MENTION_*`` env vars correctly opt back +in when an operator explicitly wants a different policy. +""" + +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +class _FakeAllowedMentions: + """Stand-in for ``discord.AllowedMentions`` that exposes the same four + boolean flags as real attributes so the test can assert on them. + """ + + def __init__(self, *, everyone=True, roles=True, users=True, replied_user=True): + self.everyone = everyone + self.roles = roles + self.users = users + self.replied_user = replied_user + + def __repr__(self) -> str: # pragma: no cover - debug helper + return ( + f"AllowedMentions(everyone={self.everyone}, roles={self.roles}, " + f"users={self.users}, replied_user={self.replied_user})" + ) + + +def _ensure_discord_mock(): + """Install (or augment) a mock ``discord`` module. + + Other test modules in this directory stub ``discord`` via + ``sys.modules.setdefault`` — whichever test file imports first wins and + our full module is then silently dropped. We therefore ALWAYS force + ``AllowedMentions`` onto whatever is currently in ``sys.modules["discord"]``; + that's the only attribute this test file actually needs real behavior from. + """ + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + sys.modules["discord"].AllowedMentions = _FakeAllowedMentions + return + + if sys.modules.get("discord") is None: + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) + discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5) + discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace(is_loaded=lambda: True) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules["discord"] = discord_mod + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + # Whether we just installed the mock OR the mock was already installed + # by another test's _ensure_discord_mock, force the AllowedMentions + # stand-in onto it — _build_allowed_mentions() reads this attribute. + sys.modules["discord"].AllowedMentions = _FakeAllowedMentions + + +_ensure_discord_mock() + +from gateway.platforms.discord import _build_allowed_mentions # noqa: E402 + + +# The four DISCORD_ALLOW_MENTION_* env vars that _build_allowed_mentions reads. +# Cleared before each test so env leakage from other tests never masks a regression. +_ENV_VARS = ( + "DISCORD_ALLOW_MENTION_EVERYONE", + "DISCORD_ALLOW_MENTION_ROLES", + "DISCORD_ALLOW_MENTION_USERS", + "DISCORD_ALLOW_MENTION_REPLIED_USER", +) + + +@pytest.fixture(autouse=True) +def _clear_allowed_mention_env(monkeypatch): + for name in _ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def test_safe_defaults_block_everyone_and_roles(): + am = _build_allowed_mentions() + assert am.everyone is False, "default must NOT allow @everyone/@here pings" + assert am.roles is False, "default must NOT allow role pings" + assert am.users is True, "default must allow user pings so replies work" + assert am.replied_user is True, "default must allow reply-reference pings" + + +def test_env_var_opts_back_into_everyone(monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "true") + am = _build_allowed_mentions() + assert am.everyone is True + # other defaults unaffected + assert am.roles is False + assert am.users is True + assert am.replied_user is True + + +def test_env_var_can_disable_users(monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_MENTION_USERS", "false") + am = _build_allowed_mentions() + assert am.users is False + # safe defaults elsewhere remain + assert am.everyone is False + assert am.roles is False + assert am.replied_user is True + + +@pytest.mark.parametrize("raw, expected", [ + ("true", True), ("True", True), ("TRUE", True), + ("1", True), ("yes", True), ("YES", True), ("on", True), + ("false", False), ("False", False), ("0", False), + ("no", False), ("off", False), + ("", False), # empty falls back to default (False for everyone) + ("garbage", False), # unknown falls back to default + (" true ", True), # whitespace tolerated +]) +def test_everyone_boolean_parsing(monkeypatch, raw, expected): + monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", raw) + am = _build_allowed_mentions() + assert am.everyone is expected + + +def test_all_four_knobs_together(monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "true") + monkeypatch.setenv("DISCORD_ALLOW_MENTION_ROLES", "true") + monkeypatch.setenv("DISCORD_ALLOW_MENTION_USERS", "false") + monkeypatch.setenv("DISCORD_ALLOW_MENTION_REPLIED_USER", "false") + am = _build_allowed_mentions() + assert am.everyone is True + assert am.roles is True + assert am.users is False + assert am.replied_user is False diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index 04490f2462..0ac1c9ba39 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -8,37 +8,60 @@ import pytest from gateway.config import PlatformConfig +class _FakeAllowedMentions: + """Stand-in for ``discord.AllowedMentions`` — exposes the same four + boolean flags as real attributes so tests can assert on safe defaults. + """ + + def __init__(self, *, everyone=True, roles=True, users=True, replied_user=True): + self.everyone = everyone + self.roles = roles + self.users = users + self.replied_user = replied_user + + def _ensure_discord_mock(): + """Install (or augment) a mock ``discord`` module. + + Always force ``AllowedMentions`` onto whatever is in ``sys.modules`` — + other test files also stub the module via ``setdefault``, and we need + ``_build_allowed_mentions()``'s return value to have real attribute + access regardless of which file loaded first. + """ if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + sys.modules["discord"].AllowedMentions = _FakeAllowedMentions return - discord_mod = MagicMock() - discord_mod.Intents.default.return_value = MagicMock() - discord_mod.Client = MagicMock - discord_mod.File = MagicMock - discord_mod.DMChannel = type("DMChannel", (), {}) - discord_mod.Thread = type("Thread", (), {}) - discord_mod.ForumChannel = type("ForumChannel", (), {}) - discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) - discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5) - discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) - discord_mod.Interaction = object - discord_mod.Embed = MagicMock - discord_mod.app_commands = SimpleNamespace( - describe=lambda **kwargs: (lambda fn: fn), - choices=lambda **kwargs: (lambda fn: fn), - Choice=lambda **kwargs: SimpleNamespace(**kwargs), - ) - discord_mod.opus = SimpleNamespace(is_loaded=lambda: True) + if sys.modules.get("discord") is None: + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) + discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5) + discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace(is_loaded=lambda: True) - ext_mod = MagicMock() - commands_mod = MagicMock() - commands_mod.Bot = MagicMock - ext_mod.commands = commands_mod + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod - sys.modules.setdefault("discord", discord_mod) - sys.modules.setdefault("discord.ext", ext_mod) - sys.modules.setdefault("discord.ext.commands", commands_mod) + sys.modules["discord"] = discord_mod + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + sys.modules["discord"].AllowedMentions = _FakeAllowedMentions _ensure_discord_mock() @@ -56,8 +79,9 @@ class FakeTree: class FakeBot: - def __init__(self, *, intents, proxy=None): + def __init__(self, *, intents, proxy=None, allowed_mentions=None, **_): self.intents = intents + self.allowed_mentions = allowed_mentions self.user = SimpleNamespace(id=999, name="Hermes") self._events = {} self.tree = FakeTree() @@ -115,8 +139,8 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all created = {} - def fake_bot_factory(*, command_prefix, intents, proxy=None): - created["bot"] = FakeBot(intents=intents) + def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_): + created["bot"] = FakeBot(intents=intents, allowed_mentions=allowed_mentions) return created["bot"] monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory) @@ -126,6 +150,13 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all assert ok is True assert created["bot"].intents.members is expected_members_intent + # Safe-default AllowedMentions must be applied on every connect so the + # bot cannot @everyone from LLM output. Granular overrides live in the + # dedicated test_discord_allowed_mentions.py module. + am = created["bot"].allowed_mentions + assert am is not None, "connect() must pass an AllowedMentions to commands.Bot" + assert am.everyone is False + assert am.roles is False await adapter.disconnect() @@ -144,7 +175,11 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch): monkeypatch.setattr( discord_platform.commands, "Bot", - lambda **kwargs: FakeBot(intents=kwargs["intents"], proxy=kwargs.get("proxy")), + lambda **kwargs: FakeBot( + intents=kwargs["intents"], + proxy=kwargs.get("proxy"), + allowed_mentions=kwargs.get("allowed_mentions"), + ), ) async def fake_wait_for(awaitable, timeout): @@ -172,7 +207,7 @@ async def test_connect_does_not_wait_for_slash_sync(monkeypatch): created = {} - def fake_bot_factory(*, command_prefix, intents, proxy=None): + def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_): bot = SlowSyncBot(intents=intents, proxy=proxy) created["bot"] = bot return bot diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 63844b3f93..1e8ad81358 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -196,6 +196,10 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `DISCORD_IGNORED_CHANNELS` | Comma-separated channel IDs where the bot never responds | | `DISCORD_NO_THREAD_CHANNELS` | Comma-separated channel IDs where bot responds without auto-threading | | `DISCORD_REPLY_TO_MODE` | Reply-reference behavior: `off`, `first` (default), or `all` | +| `DISCORD_ALLOW_MENTION_EVERYONE` | Allow the bot to ping `@everyone`/`@here` (default: `false`). See [Mention Control](../user-guide/messaging/discord.md#mention-control). | +| `DISCORD_ALLOW_MENTION_ROLES` | Allow the bot to ping `@role` mentions (default: `false`). | +| `DISCORD_ALLOW_MENTION_USERS` | Allow the bot to ping individual `@user` mentions (default: `true`). | +| `DISCORD_ALLOW_MENTION_REPLIED_USER` | Ping the author when replying to their message (default: `true`). | | `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) | | `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`, required for Socket Mode) | | `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs | diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 5dacefda4a..e58957c6d8 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -283,6 +283,10 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_IGNORED_CHANNELS` | No | — | Comma-separated channel IDs where the bot **never** responds, even when `@mentioned`. Takes priority over all other channel settings. | | `DISCORD_NO_THREAD_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds directly in the channel instead of creating a thread. Only relevant when `DISCORD_AUTO_THREAD` is `true`. | | `DISCORD_REPLY_TO_MODE` | No | `"first"` | Controls reply-reference behavior: `"off"` — never reply to the original message, `"first"` — reply-reference on the first message chunk only (default), `"all"` — reply-reference on every chunk. | +| `DISCORD_ALLOW_MENTION_EVERYONE` | No | `false` | When `false` (default), the bot cannot ping `@everyone` or `@here` even if its response contains those tokens. Set to `true` to opt back in. See [Mention Control](#mention-control) below. | +| `DISCORD_ALLOW_MENTION_ROLES` | No | `false` | When `false` (default), the bot cannot ping `@role` mentions. Set to `true` to allow. | +| `DISCORD_ALLOW_MENTION_USERS` | No | `true` | When `true` (default), the bot can ping individual users by ID. | +| `DISCORD_ALLOW_MENTION_REPLIED_USER` | No | `true` | When `true` (default), replying to a message pings the original author. | ### Config File (`config.yaml`) @@ -298,6 +302,11 @@ discord: ignored_channels: [] # Channel IDs where bot never responds no_thread_channels: [] # Channel IDs where bot responds without threading channel_prompts: {} # Per-channel ephemeral system prompts + allow_mentions: # What the bot is allowed to ping (safe defaults) + everyone: false # @everyone / @here pings (default: false) + roles: false # @role pings (default: false) + users: true # @user pings (default: true) + replied_user: true # reply-reference pings the author (default: true) # Session isolation (applies to all gateway platforms, not just Discord) group_sessions_per_user: true # Isolate sessions per user in shared channels @@ -552,6 +561,34 @@ If you intentionally want a shared room conversation, leave it off — just expe Always set `DISCORD_ALLOWED_USERS` to restrict who can interact with the bot. Without it, the gateway denies all users by default as a safety measure. Only add User IDs of people you trust — authorized users have full access to the agent's capabilities, including tool use and system access. ::: +### Mention Control + +By default, Hermes blocks the bot from pinging `@everyone`, `@here`, and role mentions, even if its reply contains those tokens. This prevents a poorly-worded prompt or echoed user content from spamming a whole server. Individual `@user` pings and reply-reference pings (the little "replying to…" chip) stay enabled so normal conversation still works. + +You can relax these defaults via either env vars or `config.yaml`: + +```yaml +# ~/.hermes/config.yaml +discord: + allow_mentions: + everyone: false # allow the bot to ping @everyone / @here + roles: false # allow the bot to ping @role mentions + users: true # allow the bot to ping individual @users + replied_user: true # ping the author when replying to their message +``` + +```bash +# ~/.hermes/.env — env vars win over config.yaml +DISCORD_ALLOW_MENTION_EVERYONE=false +DISCORD_ALLOW_MENTION_ROLES=false +DISCORD_ALLOW_MENTION_USERS=true +DISCORD_ALLOW_MENTION_REPLIED_USER=true +``` + +:::tip +Leave `everyone` and `roles` at `false` unless you know exactly why you need them. It is very easy for an LLM to produce the string `@everyone` inside a normal-looking response; without this protection, that would notify every member of your server. +::: + For more information on securing your Hermes Agent deployment, see the [Security Guide](../security.md).