# NousResearch/hermes-agent · Discord bot allowlist requires DISCORD_ALLOWED_USERS workaround

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #11606. Verify it addresses your case.
- **Recommended:** read PR #11606 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-11606`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #4466: DISCORD_ALLOW_BOTS has no effect without also adding the bot to DISCORD_ALLOWED_USERS

## Prior & competing PRs

- **#11606: fix(discord): DISCORD_ALLOW_BOTS=mentions/all works without DISCORD_ALLOWED_USERS**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `gateway/platforms/base.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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":
    ```

    ```diff
    @@ -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 —
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#4477: fix(discord): DISCORD_ALLOW_BOTS=mentions/all now works without DISCORD_ALLOWED_USERS**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#11606`, `#4367`

## Fix status

- Merged to main in PR #11606 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-17`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
