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)