fix(discord): route DM role-auth opt-in through config.yaml (not env var)

Per repo policy, ~/.hermes/.env is for secrets only. Guild IDs are
behavioral configuration, not secrets. Replacing the
DISCORD_DM_ROLE_AUTH_GUILD env var from the original fix with
discord.dm_role_auth_guild in config.yaml.

- New module-level _read_dm_role_auth_guild() helper reads
  hermes_cli.config.read_raw_config()['discord']['dm_role_auth_guild'].
  Fails closed on any parse error (safe default = DM role-auth off).
- DEFAULT_CONFIG['discord'] gains dm_role_auth_guild: '' with a comment
  documenting the opt-in.
- Tests patch hermes_cli.config.read_raw_config directly (via the
  _set_dm_role_auth_guild helper) instead of setenv/delenv. 12 tests
  in test_discord_roles_dm_scope pass; no env var involvement.
- Docstring + module docstring + comments updated to reference
  discord.dm_role_auth_guild.
- E2E verified with real imports across 6 scenarios: unset, int,
  string, garbage, zero, and (crucially) env-var-only-no-config all
  return None except the valid int/string cases. Env var has zero
  effect — policy compliance confirmed.
This commit is contained in:
Teknium 2026-05-07 05:51:18 -07:00
parent 5c045b8f6c
commit 80717a157f
3 changed files with 69 additions and 24 deletions

View file

@ -477,6 +477,34 @@ class VoiceReceiver:
pass
def _read_dm_role_auth_guild() -> Optional[int]:
"""Return the guild ID opted-in for DM role-based auth, or None.
Reads ``discord.dm_role_auth_guild`` from config.yaml. This is
deliberately a config.yaml-only setting (not an env var): per repo
policy, ``~/.hermes/.env`` is for secrets only, and this is a
behavioral setting. Guild IDs aren't secrets.
Accepts ints or numeric strings in the config. Anything else
(empty, malformed, None) returns None, which keeps the secure
default (DM role-auth disabled).
"""
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config() or {}
discord_cfg = cfg.get("discord", {}) or {}
raw = discord_cfg.get("dm_role_auth_guild")
except Exception:
return None
if raw is None or raw == "":
return None
try:
guild_id = int(raw)
except (TypeError, ValueError):
return None
return guild_id if guild_id > 0 else None
class DiscordAdapter(BasePlatformAdapter):
"""
Discord bot adapter.
@ -2140,11 +2168,11 @@ class DiscordAdapter(BasePlatformAdapter):
Role checks are **scoped to the guild the message originated from**.
For DMs (no guild context), role-based auth is disabled by default and
only user-ID allowlist applies. Set ``DISCORD_DM_ROLE_AUTH_GUILD``
to a specific guild ID to opt-in: role membership in that one guild
will authorize DMs. This prevents cross-guild privilege escalation
where a user with the configured role in any shared public server
could DM the bot and pass the allowlist.
only user-ID allowlist applies. Set ``discord.dm_role_auth_guild``
in config.yaml to a specific guild ID to opt-in: role membership in
that one guild will authorize DMs. This prevents cross-guild
privilege escalation where a user with the configured role in any
shared public server could DM the bot and pass the allowlist.
Args:
user_id: Author ID as a string.
@ -2168,14 +2196,14 @@ class DiscordAdapter(BasePlatformAdapter):
if not has_roles:
return False
# DM path: roles require explicit opt-in via DISCORD_DM_ROLE_AUTH_GUILD.
# Without this, a user with the configured role in ANY mutual guild
# could DM the bot and bypass the allowlist (cross-guild leakage).
# DM path: roles require explicit opt-in via
# ``discord.dm_role_auth_guild`` in config.yaml. Without this, a
# user with the configured role in ANY mutual guild could DM the
# bot and bypass the allowlist (cross-guild leakage).
if is_dm or guild is None:
dm_guild_env = os.getenv("DISCORD_DM_ROLE_AUTH_GUILD", "").strip()
if not dm_guild_env.isdigit():
dm_guild_id = _read_dm_role_auth_guild()
if dm_guild_id is None:
return False
dm_guild_id = int(dm_guild_env)
if self._client is None:
return False
dm_guild = self._client.get_guild(dm_guild_id)

View file

@ -1108,6 +1108,12 @@ DEFAULT_CONFIG = {
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
# Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES
# authorizes only guild messages in the role's own guild — DMs require
# DISCORD_ALLOWED_USERS. Set dm_role_auth_guild to a guild ID to also
# authorize DMs from members of that one trusted guild holding the
# allowed role. Unset / empty / 0 = secure default (DM role-auth off).
"dm_role_auth_guild": "",
# discord / discord_admin tools: restrict which actions the agent may call.
# Default (empty) = all actions allowed (subject to bot privileged intents).
# Accepts comma-separated string ("list_guilds,list_channels,fetch_messages")

View file

@ -11,8 +11,8 @@ allowed a cross-guild DM bypass:
in public server A, and authorizes the DM.
The fix scopes role checks to the originating guild and disables role-based
auth on DMs unless ``DISCORD_DM_ROLE_AUTH_GUILD`` explicitly opts into a
single trusted guild.
auth on DMs unless ``discord.dm_role_auth_guild`` in config.yaml explicitly
opts into a single trusted guild.
"""
from types import SimpleNamespace
@ -23,6 +23,17 @@ import pytest
from gateway.platforms.discord import DiscordAdapter
def _set_dm_role_auth_guild(monkeypatch, guild_id=None):
"""Stub ``hermes_cli.config.read_raw_config`` so ``_read_dm_role_auth_guild``
resolves to ``guild_id`` (or None for the opt-out default).
"""
cfg = {"discord": {"dm_role_auth_guild": guild_id if guild_id is not None else ""}}
# Patch the attribute ``hermes_cli.config.read_raw_config`` — that's
# what ``_read_dm_role_auth_guild`` imports at call time.
import hermes_cli.config as _cfg_mod
monkeypatch.setattr(_cfg_mod, "read_raw_config", lambda: cfg, raising=True)
def _make_adapter(allowed_users=None, allowed_roles=None, guilds=None):
"""Build a minimal DiscordAdapter without running __init__."""
adapter = object.__new__(DiscordAdapter)
@ -69,7 +80,7 @@ def test_dm_rejects_role_held_in_other_guild(monkeypatch):
Regression guard for the cross-guild DM bypass in the initial
DISCORD_ALLOWED_ROLES implementation.
"""
monkeypatch.delenv("DISCORD_DM_ROLE_AUTH_GUILD", raising=False)
_set_dm_role_auth_guild(monkeypatch)
public_guild, _ = _guild_with_member(
guild_id=111111,
@ -91,7 +102,7 @@ def test_dm_rejects_role_held_in_other_guild(monkeypatch):
def test_dm_role_auth_requires_explicit_guild_optin(monkeypatch):
"""With DISCORD_DM_ROLE_AUTH_GUILD set, only that specific guild counts.
"""With dm_role_auth_guild set, only that specific guild counts.
The user has the role in the opted-in guild allowed.
"""
@ -106,7 +117,7 @@ def test_dm_role_auth_requires_explicit_guild_optin(monkeypatch):
allowed_roles=[5555],
guilds=[other_guild, trusted_guild],
)
monkeypatch.setenv("DISCORD_DM_ROLE_AUTH_GUILD", "222222")
_set_dm_role_auth_guild(monkeypatch, 222222)
assert (
adapter._is_allowed_user("42", author=None, guild=None, is_dm=True)
@ -115,7 +126,7 @@ def test_dm_role_auth_requires_explicit_guild_optin(monkeypatch):
def test_dm_role_auth_optin_rejects_when_not_member(monkeypatch):
"""DISCORD_DM_ROLE_AUTH_GUILD set but user isn't a member → reject."""
"""dm_role_auth_guild set but user isn't a member → reject."""
trusted_guild = SimpleNamespace(
id=222222,
get_member=lambda uid: None, # user not in trusted guild
@ -129,7 +140,7 @@ def test_dm_role_auth_optin_rejects_when_not_member(monkeypatch):
allowed_roles=[5555],
guilds=[public_guild, trusted_guild],
)
monkeypatch.setenv("DISCORD_DM_ROLE_AUTH_GUILD", "222222")
_set_dm_role_auth_guild(monkeypatch, 222222)
assert (
adapter._is_allowed_user("42", author=None, guild=None, is_dm=True)
@ -146,7 +157,7 @@ def test_guild_message_role_check_scoped_to_originating_guild(monkeypatch):
"""A user with the role in a DIFFERENT guild than the message origin
must NOT be authorized, even when both guilds are mutual.
"""
monkeypatch.delenv("DISCORD_DM_ROLE_AUTH_GUILD", raising=False)
_set_dm_role_auth_guild(monkeypatch)
public_guild, _ = _guild_with_member(
guild_id=111111,
@ -172,7 +183,7 @@ def test_guild_message_role_check_scoped_to_originating_guild(monkeypatch):
def test_guild_message_role_check_allows_when_role_in_same_guild(monkeypatch):
"""Positive path: user has the role IN the message's guild → allowed."""
monkeypatch.delenv("DISCORD_DM_ROLE_AUTH_GUILD", raising=False)
_set_dm_role_auth_guild(monkeypatch)
trusted_guild, _ = _guild_with_member(
guild_id=222222,
@ -197,7 +208,7 @@ def test_guild_message_rejects_author_roles_from_different_guild(monkeypatch):
message, the cached .roles on it must NOT be trusted rely on the
current guild's Member lookup instead.
"""
monkeypatch.delenv("DISCORD_DM_ROLE_AUTH_GUILD", raising=False)
_set_dm_role_auth_guild(monkeypatch)
# Author is a Member of a DIFFERENT guild with the allowed role
foreign_guild = SimpleNamespace(id=999, get_member=lambda uid: None)
@ -264,7 +275,7 @@ def test_slash_authorization_rejects_cross_guild_role_dm(monkeypatch):
"""Slash interaction in a DM must not be authorized by a role held in
any mutual guild (parallel to the on_message cross-guild bypass)."""
import discord as _discord # type: ignore
monkeypatch.delenv("DISCORD_DM_ROLE_AUTH_GUILD", raising=False)
_set_dm_role_auth_guild(monkeypatch)
public_guild, _ = _guild_with_member(
guild_id=111111,
@ -292,7 +303,7 @@ def test_slash_authorization_rejects_cross_guild_role_dm(monkeypatch):
def test_slash_authorization_rejects_cross_guild_role_in_guild(monkeypatch):
"""Slash in guild B must not be authorized by a role held in guild A."""
monkeypatch.delenv("DISCORD_DM_ROLE_AUTH_GUILD", raising=False)
_set_dm_role_auth_guild(monkeypatch)
public_guild, _ = _guild_with_member(
guild_id=111111,
@ -320,7 +331,7 @@ def test_slash_authorization_rejects_cross_guild_role_in_guild(monkeypatch):
def test_slash_authorization_allows_in_scope_guild_role(monkeypatch):
"""Positive control: slash in guild B, user has role in guild B → allowed."""
monkeypatch.delenv("DISCORD_DM_ROLE_AUTH_GUILD", raising=False)
_set_dm_role_auth_guild(monkeypatch)
trusted_guild, _ = _guild_with_member(
guild_id=222222,