Make email pairing opt-in
This commit is contained in:
parent
74f0dd62e8
commit
2455e1801b
9 changed files with 145 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
:::
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue