From 2455e1801b60b8c964446339a10a9bceb85986d3 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Thu, 18 Jun 2026 14:26:45 +1000 Subject: [PATCH] Make email pairing opt-in --- gateway/authz_mixin.py | 14 ++++- gateway/config.py | 2 + hermes_cli/gateway.py | 49 ++++++++++++--- tests/gateway/test_config.py | 19 ++++++ .../gateway/test_unauthorized_dm_behavior.py | 61 +++++++++++++++++++ website/docs/user-guide/configuration.md | 3 +- website/docs/user-guide/messaging/email.md | 7 ++- website/docs/user-guide/messaging/index.md | 2 +- website/docs/user-guide/security.md | 3 +- 9 files changed, 145 insertions(+), 15 deletions(-) diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 9ededa491..70632d78c 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -458,13 +458,16 @@ class GatewayAuthorizationMixin: Resolution order: 1. Explicit per-platform ``unauthorized_dm_behavior`` in config — always wins. 2. Explicit global ``unauthorized_dm_behavior`` in config — wins when no per-platform. - 3. When an allowlist (``PLATFORM_ALLOWED_USERS``, + 3. Email defaults to ``"ignore"`` unless explicitly opted into + pairing. Inboxes may contain arbitrary unread human messages, so + replying with pairing codes is not a safe platform default. + 4. When an allowlist (``PLATFORM_ALLOWED_USERS``, ``PLATFORM_GROUP_ALLOWED_USERS`` / ``PLATFORM_GROUP_ALLOWED_CHATS``, or ``GATEWAY_ALLOWED_USERS``) is configured, default to ``"ignore"`` — the allowlist signals that the owner has deliberately restricted access; spamming unknown contacts with pairing codes is both noisy and a potential info-leak. (#9337) - 4. No allowlist and no explicit config → ``"pair"`` (open-gateway default). + 5. No allowlist and no explicit config → ``"pair"`` (open-gateway default). """ config = getattr(self, "config", None) @@ -494,6 +497,13 @@ class GatewayAuthorizationMixin: if dm_policy in {"allowlist", "disabled"}: return "ignore" + # Email is inbox-shaped, not chat-shaped: an agent mailbox may contain + # unrelated unread human email. Require an explicit per-platform + # ``unauthorized_dm_behavior: pair`` opt-in before replying to unknown + # senders with pairing codes. + if platform == Platform.EMAIL: + return "ignore" + # No explicit override. Fall back to allowlist-aware default: # if any allowlist is configured for this platform, silently drop # unauthorized messages instead of sending pairing codes. diff --git a/gateway/config.py b/gateway/config.py index d3c85e868..6b474a340 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -757,6 +757,8 @@ class GatewayConfig: platform_cfg.extra.get("unauthorized_dm_behavior"), self.unauthorized_dm_behavior, ) + if platform == Platform.EMAIL: + return "ignore" return self.unauthorized_dm_behavior def get_notice_delivery(self, platform: Optional[Platform] = None) -> str: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 1a3f58ef2..b68f48476 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -30,6 +30,7 @@ from hermes_cli.config import ( is_managed, managed_error, read_raw_config, + save_config, save_env_value, ) @@ -4645,6 +4646,21 @@ def _runtime_health_lines() -> list[str]: return lines +def _set_platform_unauthorized_dm_behavior(platform_key: str, behavior: str) -> None: + """Persist a platform-specific unauthorized-DM policy in config.yaml.""" + cfg = read_raw_config() + platforms = cfg.setdefault("platforms", {}) + if not isinstance(platforms, dict): + platforms = {} + cfg["platforms"] = platforms + platform_cfg = platforms.setdefault(platform_key, {}) + if not isinstance(platform_cfg, dict): + platform_cfg = {} + platforms[platform_key] = platform_cfg + platform_cfg["unauthorized_dm_behavior"] = behavior + save_config(cfg) + + def _setup_standard_platform(platform: dict): """Interactive setup for Telegram, Discord, or Slack.""" emoji = platform["emoji"] @@ -4754,24 +4770,43 @@ def _setup_standard_platform(platform: dict): else: # No allowlist — ask about open access vs DM pairing print() - access_choices = [ - "Enable open access (anyone can message the bot)", - "Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')", - "Skip for now (bot will deny all users until configured)", - ] + is_email = platform.get("key") == "email" + if is_email: + access_choices = [ + "Enable open access (any email sender can message the bot)", + "Use DM pairing (unknown email senders receive a pairing code)", + "Keep unknown senders silent", + ] + default_access_idx = 2 + else: + access_choices = [ + "Enable open access (anyone can message the bot)", + "Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')", + "Skip for now (bot will deny all users until configured)", + ] + default_access_idx = 1 access_idx = prompt_choice( - " How should unauthorized users be handled?", access_choices, 1 + " How should unauthorized users be handled?", + access_choices, + default_access_idx, ) if access_idx == 0: - save_env_value("GATEWAY_ALLOW_ALL_USERS", "true") + if is_email: + save_env_value("EMAIL_ALLOW_ALL_USERS", "true") + else: + save_env_value("GATEWAY_ALLOW_ALL_USERS", "true") print_warning(" Open access enabled — anyone can use your bot!") elif access_idx == 1: + if is_email: + _set_platform_unauthorized_dm_behavior("email", "pair") print_success( " DM pairing mode — users will receive a code to request access." ) print_info( " Approve with: hermes pairing approve " ) + elif is_email: + print_success(" Unknown email senders will be ignored.") else: print_info( " Skipped — configure later with 'hermes gateway setup'" diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index f3c3b1021..2542ff431 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -267,6 +267,25 @@ class TestGatewayConfigRoundtrip: assert restored.unauthorized_dm_behavior == "ignore" assert restored.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair" + def test_email_defaults_to_ignore_for_unauthorized_dm_behavior(self): + config = GatewayConfig( + platforms={Platform.EMAIL: PlatformConfig(enabled=True)}, + ) + + assert config.get_unauthorized_dm_behavior(Platform.EMAIL) == "ignore" + + def test_email_can_opt_into_pairing_for_unauthorized_dm_behavior(self): + config = GatewayConfig( + platforms={ + Platform.EMAIL: PlatformConfig( + enabled=True, + extra={"unauthorized_dm_behavior": "pair"}, + ), + }, + ) + + assert config.get_unauthorized_dm_behavior(Platform.EMAIL) == "pair" + def test_from_dict_coerces_quoted_false_always_log_local(self): restored = GatewayConfig.from_dict({"always_log_local": "false"}) assert restored.always_log_local is False diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index d2cc53aae..f4ea14cdb 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -801,6 +801,55 @@ async def test_no_allowlist_still_pairs_by_default(monkeypatch): assert "PAIR1234" in adapter.send.await_args.args[1] +@pytest.mark.asyncio +async def test_email_no_allowlist_ignores_unknown_senders_by_default(monkeypatch): + """Email should not send pairing codes to arbitrary unread inbox senders.""" + _clear_auth_env(monkeypatch) + + config = GatewayConfig( + platforms={Platform.EMAIL: PlatformConfig(enabled=True)}, + ) + runner, adapter = _make_runner(Platform.EMAIL, config) + runner.pairing_store.generate_code.return_value = "EMAIL123" + + result = await runner._handle_message( + _make_event(Platform.EMAIL, "stranger@example.com", "stranger@example.com") + ) + + assert result is None + runner.pairing_store.generate_code.assert_not_called() + adapter.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_email_pairing_requires_explicit_platform_opt_in(monkeypatch): + _clear_auth_env(monkeypatch) + + config = GatewayConfig( + platforms={ + Platform.EMAIL: PlatformConfig( + enabled=True, + extra={"unauthorized_dm_behavior": "pair"}, + ), + }, + ) + runner, adapter = _make_runner(Platform.EMAIL, config) + runner.pairing_store.generate_code.return_value = "EMAIL123" + + result = await runner._handle_message( + _make_event(Platform.EMAIL, "stranger@example.com", "stranger@example.com") + ) + + assert result is None + runner.pairing_store.generate_code.assert_called_once_with( + "email", + "stranger@example.com", + "tester", + ) + adapter.send.assert_awaited_once() + assert "EMAIL123" in adapter.send.await_args.args[1] + + def test_explicit_pair_config_overrides_allowlist_default(monkeypatch): """Explicit unauthorized_dm_behavior='pair' overrides the allowlist default. @@ -858,6 +907,18 @@ def test_get_unauthorized_dm_behavior_no_allowlist_returns_pair(monkeypatch): assert behavior == "pair" +def test_get_unauthorized_dm_behavior_email_no_allowlist_returns_ignore(monkeypatch): + _clear_auth_env(monkeypatch) + + config = GatewayConfig( + platforms={Platform.EMAIL: PlatformConfig(enabled=True)}, + ) + runner, _adapter = _make_runner(Platform.EMAIL, config) + + behavior = runner._get_unauthorized_dm_behavior(Platform.EMAIL) + assert behavior == "ignore" + + def test_qqbot_with_allowlist_ignores_unauthorized_dm(monkeypatch): """QQBOT is included in the allowlist-aware default (QQ_ALLOWED_USERS). diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index d8796ae42..4208868cb 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1618,8 +1618,9 @@ whatsapp: unauthorized_dm_behavior: ignore ``` -- `pair` is the default. Hermes denies access, but replies with a one-time pairing code in DMs. +- `pair` is the default for chat-style DM platforms. Hermes denies access, but replies with a one-time pairing code in DMs. - `ignore` silently drops unauthorized DMs. +- Email defaults to `ignore` unless `platforms.email.unauthorized_dm_behavior: pair` is set, because inboxes can contain unrelated unread mail. - Platform sections override the global default, so you can keep pairing enabled broadly while making one platform quieter. ## Quick Commands diff --git a/website/docs/user-guide/messaging/email.md b/website/docs/user-guide/messaging/email.md index d67307be7..eabde5da4 100644 --- a/website/docs/user-guide/messaging/email.md +++ b/website/docs/user-guide/messaging/email.md @@ -142,14 +142,15 @@ When enabled, attachment and inline parts are skipped before payload decoding. T ## Access Control -Email access follows the same pattern as all other Hermes platforms: +Email access is stricter by default than chat-style platforms: 1. **`EMAIL_ALLOWED_USERS` set** → only emails from those addresses are processed -2. **No allowlist set** → unknown senders get a pairing code +2. **No allowlist set** → unknown senders are ignored silently 3. **`EMAIL_ALLOW_ALL_USERS=true`** → any sender is accepted (use with caution) +4. **`platforms.email.unauthorized_dm_behavior: pair`** → unknown senders receive a pairing code :::warning -**Always configure `EMAIL_ALLOWED_USERS`.** Without it, anyone who knows the agent's email address could send commands. The agent has terminal access by default. +**Use a dedicated inbox and configure `EMAIL_ALLOWED_USERS` for normal operation.** Email pairing is opt-in because shared inboxes often contain unrelated unread messages, and Hermes should not reply to those contacts by default. ::: --- diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index f6fda312e..289d2eaec 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -237,7 +237,7 @@ GATEWAY_ALLOW_ALL_USERS=true ### DM Pairing (Alternative to Allowlists) -Instead of manually configuring user IDs, unknown users receive a one-time pairing code when they DM the bot: +Instead of manually configuring user IDs, unknown users receive a one-time pairing code when they DM the bot. Email is the exception: unknown email senders are ignored unless email pairing is explicitly enabled. ```bash # The user sees: "Pairing code: XKGH5N7P" diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index 5de9497f6..c48c6db6b 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -272,8 +272,9 @@ whatsapp: unauthorized_dm_behavior: ignore ``` -- `pair` is the default. Unauthorized DMs get a pairing code reply. +- `pair` is the default for chat-style DM platforms. Unauthorized DMs get a pairing code reply. - `ignore` silently drops unauthorized DMs. +- Email defaults to `ignore` unless `platforms.email.unauthorized_dm_behavior: pair` is set, because inboxes can contain unrelated unread mail. - Platform sections override the global default, so you can keep pairing on Telegram while keeping WhatsApp silent. **Security features** (based on OWASP + NIST SP 800-63-4 guidance):