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
- 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
Open PRs0
Fixes on record
-
#11606 fix(discord): DISCORD_ALLOW_BOTS=mentions/all works without DISCORD_ALLOWED_USERS
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.pyShow 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.00means 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 LabAt 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.