Make email pairing opt-in

This commit is contained in:
Shannon Sands 2026-06-18 14:26:45 +10:00 committed by Teknium
parent 74f0dd62e8
commit 2455e1801b
9 changed files with 145 additions and 15 deletions

View file

@ -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.

View file

@ -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:

View file

@ -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 <platform> <code>"
)
elif is_email:
print_success(" Unknown email senders will be ignored.")
else:
print_info(
" Skipped — configure later with 'hermes gateway setup'"

View file

@ -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

View file

@ -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).

View file

@ -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

View file

@ -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.
:::
---

View file

@ -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"

View file

@ -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):