NousResearch/hermes-agent

Discord bot allowlist requires DISCORD_ALLOWED_USERS workaround

Cluster 4466 · gateway. Data as of

Nothing open on GitHub. Fix merged in #11606 on 17 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #11606. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #11606. That is separate from the fix event.
Still open
Nothing open on GitHub.

DISCORD_ALLOW_BOTS set to 'mentions' or 'all' does not work unless the bot is also explicitly added to DISCORD_ALLOWED_USERS. These items address that dependency.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #11606 fix(discord): DISCORD_ALLOW_BOTS=mentions/all works without DISCORD_ALLOWED_USERS

    merged to main

    files: gateway/platforms/base.py, gateway/platforms/discord.py, gateway/run.py, gateway/session.py, scripts/release.py, tests/gateway/test_discord_bot_auth_bypass.py

    Show the diff
    • gateway/platforms/base.py
      @@ -1991,6 +1991,7 @@ def build_source(
               chat_topic: Optional[str] = None,
               user_id_alt: Optional[str] = None,
               chat_id_alt: Optional[str] = None,
      +        is_bot: bool = False,
           ) -> SessionSource:
               """Helper to build a SessionSource for this platform."""
               # Normalize empty topic to None
      @@ -2007,6 +2008,7 @@ def build_source(
                   chat_topic=chat_topic.strip() if chat_topic else None,
                   user_id_alt=user_id_alt,
                   chat_id_alt=chat_id_alt,
      +            is_bot=is_bot,
               )
           
           @abstractmethod
    • gateway/platforms/discord.py
      @@ -636,14 +636,13 @@ async def on_message(message: DiscordMessage):
                       if message.type not in (discord.MessageType.default, discord.MessageType.reply):
                           return
       
      -                # Check if the message author is in the allowed user list
      -                if not self._is_allowed_user(str(message.author.id)):
      -                    return
      -
                       # Bot message filtering (DISCORD_ALLOW_BOTS):
                       #   "none"     — ignore all other bots (default)
                       #   "mentions" — accept bot messages only when they @mention us
                       #   "all"      — accept all bot messages
      +                # Must run BEFORE the user allowlist check so that bots
      +                # permitted by DISCORD_ALLOW_BOTS are not rejected for
      +                # not being in DISCORD_ALLOWED_USERS (fixes #4466).
                       if getattr(message.author, "bot", False):
                           allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
                           if allow_bots == "none":
      @@ -651,7 +650,12 @@ async def on_message(message: DiscordMessage):
                           elif allow_bots == "mentions":
                               if not self._client.user or self._client.user not in message.mentions:
                                   return
      -                    # "all" falls through to handle_message
      +                    # "all" falls through; bot is permitted — skip the
      +                    # human-user allowlist below (bots aren't in it).
      +                else:
      +                    # Non-bot: enforce the configured user allowlist.
      +                    if not self._is_allowed_user(str(message.author.id)):
      +                        return
                       
                       # Multi-agent filtering: if the message mentions specific bots
                       # but NOT this bot, the sender is talking to another agent —
      @@ -2787,6 +2791,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:
                   user_name=message.author.display_name,
                   thread_id=thread_id,
                   chat_topic=chat_topic,
      +            is_bot=getattr(message.author, "bot", False),
               )
       
               # Build media URLs -- download image attachments to local cache so the
    • gateway/run.py
      @@ -2645,6 +2645,16 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
               if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"):
                   return True
       
      +        # Discord bot senders that passed the DISCORD_ALLOW_BOTS platform
      +        # filter are already authorized at the platform level — skip the
      +        # user allowlist. Without this, bot messages allowed by
      +        # DISCORD_ALLOW_BOTS=mentions/all would be rejected here with
      +        # "Unauthorized user" (fixes #4466).
      +        if source.platform == Platform.DISCORD and getattr(source, "is_bot", False):
      +            allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
      +            if allow_bots in ("mentions", "all"):
      +                return True
      +
               # Check pairing store (always checked, regardless of allowlists)
               platform_name = source.platform.value if source.platform else ""
               if self.pairing_store.is_approved(platform_name, user_id):
    • gateway/session.py
      @@ -82,6 +82,7 @@ class SessionSource:
           chat_topic: Optional[str] = None  # Channel topic/description (Discord, Slack)
           user_id_alt: Optional[str] = None  # Signal UUID (alternative to phone number)
           chat_id_alt: Optional[str] = None  # Signal group internal ID
      +    is_bot: bool = False  # True when the message author is a bot/webhook (Discord)
           
           @property
           def description(self) -> str:
    • scripts/release.py
      @@ -236,6 +236,7 @@
           "[redacted:email]": "ZaynJarvis",
           "[redacted:email]": "malaiwah",
           "[redacted:email]": "malaiwah",
      +    "[redacted:email]": "gnanam1990",
           "[redacted:email]": "v1k22",
           "[redacted:email]": "RucchiZ",
           "[redacted:email]": "LehaoLin",
    • tests/gateway/test_discord_bot_auth_bypass.py
      @@ -0,0 +1,154 @@
      +"""Regression guard for #4466: DISCORD_ALLOW_BOTS works without DISCORD_ALLOWED_USERS.
      +
      +The bug had two sequential gates both rejecting bot messages:
      +
      +  Gate 1 — `on_message` in gateway/platforms/discord.py ran the user-allowlist
      +  check BEFORE the bot filter, so bot senders were dropped with a warning
      +  before the DISCORD_ALLOW_BOTS policy was ever evaluated.
      +
      +  Gate 2 — `_is_user_authorized` in gateway/run.py rejected bots at the
      +  gateway level even if they somehow reached that layer.
      +
      +These tests assert both gates now pass a bot message through when
      +DISCORD_ALLOW_BOTS permits it AND no user allowlist entry exists.
      +"""
      +
      +import os
      +from types import SimpleNamespace
      +from unittest.mock import patch
      +
      +import pytest
      +
      +from gateway.session import Platform, SessionSource
      +
      +
      +# -----------------------------------------------------------------------------
      +# Gate 2: _is_user_authorized bypasses allowlist for permitted bots
      +# -----------------------------------------------------------------------------
      +
      +
      +def _make_bare_runner():
      +    """Build a GatewayRunner skeleton with just enough wiring for the auth test.
      +
      +    Uses ``object.__new__`` to skip the heavy __init__ — many gateway tests
      +    use this pattern (see AGENTS.md pitfall #17).
      +    """
      +    from gateway.run import GatewayRunner
      +    runner = object.__new__(GatewayRunner)
      +    # _is_user_authorized reads self.pairing_store.is_approved(...) before
      +    # any allowlist check succeeds; stub it to never approve so we exercise
      +    # the real allowlist path.
      +    runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)
      +    return runner
      +
      +
      +def _make_discord_bot_source(bot_id: str = "999888777"):
      +    return SessionSource(
      +        platform=Platform.DISCORD,
      +        chat_id="123",
      +        chat_type="channel",
      +        user_id=bot_id,
      +        user_name="SomeBot",
      +        is_bot=True,
      +    )
      +
      +
      +def _make_discord_human_source(user_id: str = "100200300"):
      +    return SessionSource(
      +        platform=Platform.DISCORD,
      +        chat_id="123",
      +        chat_type="channel",
      +        user_id=user_id,
      +        user_name="SomeHuman",
      +        is_bot=False,
      +    )
      +
      +
      +def test_discord_bot_authorized_when_allow_bots_mentions(monkeypatch):
      +    """DISCORD_ALLOW_BOTS=mentions must authorize a bot sender even when
      +    DISCORD_ALLOWED_USERS is set and the bot's ID is NOT in it.
      +
      +    This is the exact scenario from #4466 — a Cloudflare Worker webhook
      +    posts Notion events to Discord, the Hermes bot gets @mentioned, and
      +    the webhook's bot ID is not (and shouldn't be) on the human
      +    allowlist.
      +    """
      +    runner = _make_bare_runner()
      +
      +    monkeypatch.setenv("DISCORD_ALLOW_BOTS", "mentions")
      +    monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")  # human-only allowlist
      +
      +    source = _make_discord_bot_source(bot_id="999888777")
      +    assert runner._is_user_authorized(source) is True
      +
      +
      +def test_discord_bot_authorized_when_allow_bots_all(monkeypatch):
      +    """DISCORD_ALLOW_BOTS=all is a superset of =mentions — should also bypass."""
      +    runner = _make_bare_runner()
      +
      +    monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
      +    monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
      +
      +    source = _make_discord_bot_source()
      +    assert runner._is_user_authorized(source) is True
      +
      +
      +def test_discord_bot_NOT_authorized_when_allow_bots_none(monkeypatch):
      +    """DISCORD_ALLOW_BOTS=none (default) must still reject bots that aren't
      +    in DISCORD_ALLOWED_USERS — preserves the original security behavior.
      +    """
      +    runner = _make_bare_runner()
      +
      +    monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none")
      +    monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
      +
      +    source = _make_discord_bot_source(bot_id="999888777")
      +    assert runner._is_user_authorized(source) is False
      +
      +
      +def test_discord_bot_NOT_authorized_when_allow_bots_unset(monkeypatch):
      +    """Unset DISCORD_ALLOW_BOTS must behave like 'none'."""
      +    runner = _make_bare_runner()
      +
      +    monkeypatch.delenv("DISCORD_ALLOW_BOTS", raising=False)
      +    monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
      +
      +    source = _make_discord_bot_source(bot_id="999888777")
      +    assert runner._is_user_authorized(source) is False
      +
      +
      +def test_discord_human_still_checked_against_allowlist_when_bot_policy_set(monkeypatch):
      +    """DISCORD_ALLOW_BOTS=all must NOT open the gate for humans — they
      +    still need to be in DISCORD_ALLOWED_USERS (or a pairing approval).
      +    """
      +    runner = _make_bare_runner()
      +
      +    monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
      +    monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
      +
      +    # Human NOT on the allowlist → must be rejected.
      +    source = _make_discord_human_source(user_id="999999999")
      +    assert runner._is_user_authorized(source) is False
      +
      +    # Human ON the allowlist → accepted.
      +    source_allowed = _make_discord_human_source(user_id="100200300")
      +    assert runner._is_user_authorized(source_allowed) is True
      +
      +
      +def test_bot_bypass_does_not_leak_to_other_platforms(monkeypatch):
      +    """The is_bot bypass is Discord-specific — a Telegram bot source with
      +    is_bot=True must NOT be authorized just because DISCORD_ALLOW_BOTS=all.
      +    """
      +    runner = _make_bare_runner()
      +
      +    monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
      +    monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "100200300")
      +
      +    telegram_bot = SessionSource(
      +        platform=Platform.TELEGRAM,
      +        chat_id="123",
      +        chat_type="channel",
      +        user_id="999888777",
      +        is_bot=True,
      +    )
      +    assert runner._is_user_authorized(telegram_bot) is False

1 PR closed without merging

From the close comments: merged via another change 1

  • #4477 fix(discord): DISCORD_ALLOW_BOTS=mentions/all now works without DISCORD_ALLOWED_USERS · merged via another change → #11606, #4367
Closed issues · 1
  • Closed#4466DISCORD_ALLOW_BOTS has no effect without also adding the bot to DISCORD_ALLOWED_USERS
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#11606
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
17 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.