{"repo":"NousResearch/hermes-agent","public_id":"22334","display_label":"Discord wildcard user allowlist bug and fix","summary":"A bug was identified where the '*' wildcard in DISCORD_ALLOWED_USERS was not honored by the user-check logic, blocking non-self users. The members track the bug report and associated fix attempts.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53910,"merged_at":"2026-06-28T02:11:30+00:00"},"resolution":{"state":"resolved","by":"pr-53910"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T02:11:30+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":22334,"pr_number":53910,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-28T02:11:30+00:00"}],"members":[{"kind":"issue","number":22334,"title":"[Bug]: claw migrate emits \"*\" wildcard in DISCORD_ALLOWED_USERS but _is_allowed_user does not honor it, blocking all non-self users","url":null,"state":"closed"},{"kind":"pr","number":22335,"title":"fix(discord): honor \"*\" wildcard in DISCORD_ALLOWED_USERS","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22334","number":22334}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":22395,"title":"fix(discord): honor \"*\" wildcard in DISCORD_ALLOWED_USERS","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53910","number":53910},{"kind":"pr","value":"#22335","number":22335}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":53910,"title":"fix(discord): honor \"*\" wildcard in DISCORD_ALLOWED_USERS (#22334)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/discord/adapter.py","scripts/release.py","tests/gateway/test_discord_connect.py","tests/gateway/test_voice_command.py"],"fix_hunks":[{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -934,7 +934,12 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n             intents.dm_messages = True\n             intents.guild_messages = True\n             intents.members = (\n-                any(not entry.isdigit() for entry in self._allowed_user_ids)\n+                # ``\"*\"`` is the open-mode wildcard (honored in _is_allowed_user),\n+                # not a username to resolve, so it must not pull in the privileged\n+                # Server Members intent — exactly the migrate-from-OpenClaw path\n+                # the wildcard fix targets would otherwise silently fail to come\n+                # online when Members Intent isn't enabled in the Developer Portal.\n+                any(entry != \"*\" and not entry.isdigit() for entry in self._allowed_user_ids)\n                 or bool(self._allowed_role_ids)  # Need members intent for role lookup\n             )\n             intents.voice_states = True","@@ -2766,8 +2771,12 @@ def _is_allowed_user(\n         has_roles = bool(allowed_roles)\n         if not has_users and not has_roles:\n             return True\n-        # Check user ID allowlist (works for both DMs and guild messages)\n-        if has_users and user_id in allowed_users:\n+        # Check user ID allowlist (works for both DMs and guild messages).\n+        # ``\"*\"`` is honored as an open-mode wildcard, mirroring\n+        # ``SIGNAL_ALLOWED_USERS`` and the existing ``DISCORD_ALLOWED_CHANNELS`` /\n+        # ``DISCORD_IGNORED_CHANNELS`` / ``DISCORD_FREE_RESPONSE_CHANNELS``\n+        # semantics. This is the convention ``claw migrate`` emits (\"*\").\n+        if has_users and (\"*\" in allowed_users or user_id in allowed_users):\n             return True\n         # Role allowlist is only consulted when configured.\n         if not has_roles:","@@ -3363,6 +3372,15 @@ async def _resolve_allowed_usernames(self) -> None:\n         for entry in self._allowed_user_ids:\n             if entry.isdigit():\n                 numeric_ids.add(entry)\n+            elif entry == \"*\":\n+                # Preserve the open-mode wildcard verbatim. It is not a\n+                # username to resolve; without this branch it would land in\n+                # ``to_resolve``, fail to match any guild member, and then be\n+                # silently dropped from both ``self._allowed_user_ids`` and\n+                # ``DISCORD_ALLOWED_USERS`` by the rewrite below — quietly\n+                # undoing the wildcard fix in ``_is_allowed_user`` after the\n+                # first ``on_ready``.\n+                numeric_ids.add(entry)\n             else:\n                 to_resolve.add(entry.lower())\n "]},{"file":"scripts/release.py","hunks":["@@ -49,6 +49,7 @@\n     \"[redacted:email]\": \"Blaryxoff\",  # PR #32602 salvage (deep-merge PUT /api/config to preserve unrelated sections; #13396)\n     \"[redacted:email]\": \"DiamondEyesFox\",  # PR #53351 salvage (rebaseline in-place compression flushes to prevent duplicate compacted rows; #9096)\n     \"[redacted:email]\": \"rlaope\",  # PR #35075 salvage (align cron invisible-unicode set with install-time scanner; #35075)\n+    \"[redacted:email]\": \"bykim0119\",  # PR #22335 salvage (honor \"*\" wildcard in DISCORD_ALLOWED_USERS; #22334)\n     \"[redacted:email]\": \"rebel0789\",  # PR #47308 salvage (redact browser_type typed text across display surfaces; #47197)\n     \"267614622+agt-user@users.noreply.github.com\": \"agt-user\",  # PR #48496 salvage (telegram CLOSE-WAIT polling heartbeat, #48495)\n     \"80915+DavidMetcalfe@users.noreply.github.com\": \"DavidMetcalfe\",  # PR #52272 salvage (route reasoning-model thinking-timeouts to timeout not context_overflow + reasoning-specific guidance; #52271)"]},{"file":"tests/gateway/test_discord_connect.py","hunks":["@@ -1,5 +1,6 @@\n import asyncio\n import json\n+import os\n import sys\n from types import SimpleNamespace\n from unittest.mock import AsyncMock, MagicMock","@@ -146,6 +147,13 @@ def __init__(self, *, intents, proxy=None):\n         (\"769524422783664158\", False),\n         (\"abhey-gupta\", True),\n         (\"769524422783664158,abhey-gupta\", True),\n+        # ``\"*\"`` is the open-mode wildcard, not a username to resolve, so it\n+        # must not pull in the privileged Server Members intent. Requesting\n+        # that intent without it being enabled in the Discord Developer Portal\n+        # can prevent the bot from coming online at all — and that is exactly\n+        # the migration-from-OpenClaw path the wildcard fix targets (#22334).\n+        (\"*\", False),\n+        (\"769524422783664158,*\", False),\n     ],\n )\n async def test_connect_only_requests_members_intent_when_needed(monkeypatch, allowed_users, expected_members_intent):","@@ -182,6 +190,50 @@ def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=No\n     await adapter.disconnect()\n \n \n[redacted:email]\n[redacted:email](\n+    \"initial_allowed\",\n+    [\n+        {\"*\"},\n+        {\"769524422783664158\", \"*\"},\n+    ],\n+)\n+async def test_resolve_allowed_usernames_preserves_wildcard(monkeypatch, initial_allowed):\n+    \"\"\"Regression (#22334): ``_resolve_allowed_usernames`` must not strip ``\"*\"``.\n+\n+    The method splits entries into numeric-IDs (kept) vs non-numeric (treated\n+    as usernames to resolve), then rewrites ``self._allowed_user_ids`` and the\n+    ``DISCORD_ALLOWED_USERS`` env var from the numeric set. Since ``\"*\"`` is\n+    non-numeric, without the wildcard branch it ends up in the resolution\n+    bucket, fails to match any guild member, and is silently dropped from both\n+    the in-memory set and the env var on the first ``on_ready`` — quietly\n+    undoing the wildcard fix in ``_is_allowed_user`` for every subsequent\n+    message.\n+    \"\"\"\n+    adapter = DiscordAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n+    adapter._allowed_user_ids = set(initial_allowed)\n+    adapter._client = SimpleNamespace(guilds=[])  # no guilds → no resolution work\n+\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_USERS\", \",\".join(sorted(initial_allowed)))\n+\n+    await adapter._resolve_allowed_usernames()\n+\n+    assert \"*\" in adapter._allowed_user_ids, (\n+        \"wildcard must survive _resolve_allowed_usernames; it is the open-mode \"\n+        \"marker, not a username to resolve\"\n+    )\n+    env_entries = {\n+        e.strip()\n+        for e in os.environ.get(\"DISCORD_ALLOWED_USERS\", \"\").split(\",\")\n+        if e.strip()\n+    }\n+    assert \"*\" in env_entries, (\n+        \"DISCORD_ALLOWED_USERS env must still contain '*' after resolution; \"\n+        \"downstream readers (and any restart-time re-parse) rely on it\"\n+    )\n+\n+\n+\n @pytest.mark.asyncio\n async def test_reconnect_closes_previous_client_to_prevent_zombie_websocket(monkeypatch):\n     \"\"\"Regression for #18187: calling connect() twice without disconnect() in"]},{"file":"tests/gateway/test_voice_command.py","hunks":["@@ -1223,6 +1223,32 @@ def test_is_allowed_user_not_in_list(self):\n         adapter._allowed_user_ids = {\"99\"}\n         assert adapter._is_allowed_user(\"42\") is False\n \n+    def test_is_allowed_user_wildcard_only(self):\n+        \"\"\"``DISCORD_ALLOWED_USERS=\"*\"`` opens access to all users.\n+\n+        Mirrors ``SIGNAL_ALLOWED_USERS`` and the existing\n+        ``DISCORD_ALLOWED_CHANNELS`` / ``_IGNORED_CHANNELS`` /\n+        ``_FREE_RESPONSE_CHANNELS`` wildcard handling. This is the\n+        convention ``claw migrate`` emits (#22334).\n+        \"\"\"\n+        adapter = self._make_adapter()\n+        adapter._allowed_user_ids = {\"*\"}\n+        assert adapter._is_allowed_user(\"42\") is True\n+        assert adapter._is_allowed_user(\"999999999999999999\") is True\n+\n+    def test_is_allowed_user_wildcard_mixed_with_ids(self):\n+        \"\"\"``DISCORD_ALLOWED_USERS=\"123,*\"`` honors ``*`` for any user.\"\"\"\n+        adapter = self._make_adapter()\n+        adapter._allowed_user_ids = {\"123456789012345678\", \"*\"}\n+        assert adapter._is_allowed_user(\"42\") is True\n+        assert adapter._is_allowed_user(\"123456789012345678\") is True\n+\n+    def test_is_allowed_user_wildcard_in_dm(self):\n+        \"\"\"Wildcard short-circuits before role-auth gating, so DMs honor it too.\"\"\"\n+        adapter = self._make_adapter()\n+        adapter._allowed_user_ids = {\"*\"}\n+        assert adapter._is_allowed_user(\"42\", is_dm=True) is True\n+\n     @pytest.mark.asyncio\n     async def test_process_voice_input_success(self):\n         \"\"\"Successful voice input: PCM->WAV->STT->callback.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}