diff --git a/gateway/config.py b/gateway/config.py index 55a811aa89..242111ddf8 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -32,6 +32,15 @@ def _coerce_bool(value: Any, default: bool = True) -> bool: return bool(value) +def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: + """Normalize unauthorized DM behavior to a supported value.""" + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"pair", "ignore"}: + return normalized + return default + + class Platform(Enum): """Supported messaging platforms.""" LOCAL = "local" @@ -215,6 +224,9 @@ class GatewayConfig: # Session isolation in shared chats group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available + # Unauthorized DM policy + unauthorized_dm_behavior: str = "pair" # "pair" or "ignore" + # Streaming configuration streaming: StreamingConfig = field(default_factory=StreamingConfig) @@ -289,6 +301,7 @@ class GatewayConfig: "always_log_local": self.always_log_local, "stt_enabled": self.stt_enabled, "group_sessions_per_user": self.group_sessions_per_user, + "unauthorized_dm_behavior": self.unauthorized_dm_behavior, "streaming": self.streaming.to_dict(), } @@ -331,6 +344,10 @@ class GatewayConfig: stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None group_sessions_per_user = data.get("group_sessions_per_user") + unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior( + data.get("unauthorized_dm_behavior"), + "pair", + ) return cls( platforms=platforms, @@ -343,9 +360,21 @@ class GatewayConfig: always_log_local=data.get("always_log_local", True), stt_enabled=_coerce_bool(stt_enabled, True), group_sessions_per_user=_coerce_bool(group_sessions_per_user, True), + unauthorized_dm_behavior=unauthorized_dm_behavior, streaming=StreamingConfig.from_dict(data.get("streaming", {})), ) + def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str: + """Return the effective unauthorized-DM behavior for a platform.""" + if platform: + platform_cfg = self.platforms.get(platform) + if platform_cfg and "unauthorized_dm_behavior" in platform_cfg.extra: + return _normalize_unauthorized_dm_behavior( + platform_cfg.extra.get("unauthorized_dm_behavior"), + self.unauthorized_dm_behavior, + ) + return self.unauthorized_dm_behavior + def load_gateway_config() -> GatewayConfig: """ @@ -416,6 +445,38 @@ def load_gateway_config() -> GatewayConfig: if "always_log_local" in yaml_cfg: gw_data["always_log_local"] = yaml_cfg["always_log_local"] + if "unauthorized_dm_behavior" in yaml_cfg: + gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( + yaml_cfg.get("unauthorized_dm_behavior"), + "pair", + ) + + # Bridge per-platform unauthorized_dm_behavior from config.yaml + platforms_data = gw_data.setdefault("platforms", {}) + if not isinstance(platforms_data, dict): + platforms_data = {} + gw_data["platforms"] = platforms_data + for plat in Platform: + if plat == Platform.LOCAL: + continue + platform_cfg = yaml_cfg.get(plat.value) + if not isinstance(platform_cfg, dict): + continue + if "unauthorized_dm_behavior" not in platform_cfg: + continue + plat_data = platforms_data.setdefault(plat.value, {}) + if not isinstance(plat_data, dict): + plat_data = {} + platforms_data[plat.value] = plat_data + extra = plat_data.setdefault("extra", {}) + if not isinstance(extra, dict): + extra = {} + plat_data["extra"] = extra + extra["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( + platform_cfg.get("unauthorized_dm_behavior"), + gw_data.get("unauthorized_dm_behavior", "pair"), + ) + # Discord settings → env vars (env vars take precedence) discord_cfg = yaml_cfg.get("discord", {}) if isinstance(discord_cfg, dict): diff --git a/gateway/run.py b/gateway/run.py index 62d16e680d..95663cb915 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1257,6 +1257,13 @@ class GatewayRunner: if "@" in user_id: check_ids.add(user_id.split("@")[0]) return bool(check_ids & allowed_ids) + + def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str: + """Return how unauthorized DMs should be handled for a platform.""" + config = getattr(self, "config", None) + if config and hasattr(config, "get_unauthorized_dm_behavior"): + return config.get_unauthorized_dm_behavior(platform) + return "pair" async def _handle_message(self, event: MessageEvent) -> Optional[str]: """ @@ -1277,7 +1284,7 @@ class GatewayRunner: if not self._is_user_authorized(source): logger.warning("Unauthorized user: %s (%s) on %s", source.user_id, source.user_name, source.platform.value) # In DMs: offer pairing code. In groups: silently ignore. - if source.chat_type == "dm": + if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair": platform_name = source.platform.value if source.platform else "unknown" code = self.pairing_store.generate_code( platform_name, source.user_id, source.user_name or "" diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 363118b3e2..8dbb725d82 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -115,6 +115,22 @@ class TestGatewayConfigRoundtrip: assert restored.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}} assert restored.group_sessions_per_user is False + def test_roundtrip_preserves_unauthorized_dm_behavior(self): + config = GatewayConfig( + unauthorized_dm_behavior="ignore", + platforms={ + Platform.WHATSAPP: PlatformConfig( + enabled=True, + extra={"unauthorized_dm_behavior": "pair"}, + ), + }, + ) + + restored = GatewayConfig.from_dict(config.to_dict()) + + assert restored.unauthorized_dm_behavior == "ignore" + assert restored.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair" + class TestLoadGatewayConfig: def test_bridges_quick_commands_from_config_yaml(self, tmp_path, monkeypatch): @@ -158,3 +174,21 @@ class TestLoadGatewayConfig: config = load_gateway_config() assert config.quick_commands == {} + + def test_bridges_unauthorized_dm_behavior_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "unauthorized_dm_behavior: ignore\n" + "whatsapp:\n" + " unauthorized_dm_behavior: pair\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.unauthorized_dm_behavior == "ignore" + assert config.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair" diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py new file mode 100644 index 0000000000..0dbe457a82 --- /dev/null +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -0,0 +1,137 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + + +def _clear_auth_env(monkeypatch) -> None: + for key in ( + "TELEGRAM_ALLOWED_USERS", + "DISCORD_ALLOWED_USERS", + "WHATSAPP_ALLOWED_USERS", + "SLACK_ALLOWED_USERS", + "SIGNAL_ALLOWED_USERS", + "EMAIL_ALLOWED_USERS", + "SMS_ALLOWED_USERS", + "MATTERMOST_ALLOWED_USERS", + "MATRIX_ALLOWED_USERS", + "DINGTALK_ALLOWED_USERS", + "GATEWAY_ALLOWED_USERS", + "TELEGRAM_ALLOW_ALL_USERS", + "DISCORD_ALLOW_ALL_USERS", + "WHATSAPP_ALLOW_ALL_USERS", + "SLACK_ALLOW_ALL_USERS", + "SIGNAL_ALLOW_ALL_USERS", + "EMAIL_ALLOW_ALL_USERS", + "SMS_ALLOW_ALL_USERS", + "MATTERMOST_ALLOW_ALL_USERS", + "MATRIX_ALLOW_ALL_USERS", + "DINGTALK_ALLOW_ALL_USERS", + "GATEWAY_ALLOW_ALL_USERS", + ): + monkeypatch.delenv(key, raising=False) + + +def _make_event(platform: Platform, user_id: str, chat_id: str) -> MessageEvent: + return MessageEvent( + text="hello", + message_id="m1", + source=SessionSource( + platform=platform, + user_id=user_id, + chat_id=chat_id, + user_name="tester", + chat_type="dm", + ), + ) + + +def _make_runner(platform: Platform, config: GatewayConfig): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = config + adapter = SimpleNamespace(send=AsyncMock()) + runner.adapters = {platform: adapter} + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved.return_value = False + return runner, adapter + + +@pytest.mark.asyncio +async def test_unauthorized_dm_pairs_by_default(monkeypatch): + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}, + ) + runner, adapter = _make_runner(Platform.WHATSAPP, config) + runner.pairing_store.generate_code.return_value = "ABC12DEF" + + result = await runner._handle_message( + _make_event( + Platform.WHATSAPP, + "15551234567@s.whatsapp.net", + "15551234567@s.whatsapp.net", + ) + ) + + assert result is None + runner.pairing_store.generate_code.assert_called_once_with( + "whatsapp", + "15551234567@s.whatsapp.net", + "tester", + ) + adapter.send.assert_awaited_once() + assert "ABC12DEF" in adapter.send.await_args.args[1] + + +@pytest.mark.asyncio +async def test_unauthorized_whatsapp_dm_can_be_ignored(monkeypatch): + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={ + Platform.WHATSAPP: PlatformConfig( + enabled=True, + extra={"unauthorized_dm_behavior": "ignore"}, + ), + }, + ) + runner, adapter = _make_runner(Platform.WHATSAPP, config) + + result = await runner._handle_message( + _make_event( + Platform.WHATSAPP, + "15551234567@s.whatsapp.net", + "15551234567@s.whatsapp.net", + ) + ) + + assert result is None + runner.pairing_store.generate_code.assert_not_called() + adapter.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_global_ignore_suppresses_pairing_reply(monkeypatch): + _clear_auth_env(monkeypatch) + config = GatewayConfig( + unauthorized_dm_behavior="ignore", + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}, + ) + runner, adapter = _make_runner(Platform.TELEGRAM, config) + + result = await runner._handle_message( + _make_event( + Platform.TELEGRAM, + "12345", + "12345", + ) + ) + + assert result is None + runner.pairing_store.generate_code.assert_not_called() + adapter.send.assert_not_awaited() diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index aa770c9e89..28b54ffadc 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1090,6 +1090,21 @@ group_sessions_per_user: true # true = per-user isolation in groups/channels, f For the behavior details and examples, see [Sessions](/docs/user-guide/sessions) and the [Discord guide](/docs/user-guide/messaging/discord). +## Unauthorized DM Behavior + +Control what Hermes does when an unknown user sends a direct message: + +```yaml +unauthorized_dm_behavior: pair + +whatsapp: + unauthorized_dm_behavior: ignore +``` + +- `pair` is the default. Hermes denies access, but replies with a one-time pairing code in DMs. +- `ignore` silently drops unauthorized DMs. +- Platform sections override the global default, so you can keep pairing enabled broadly while making one platform quieter. + ## Quick Commands Define custom commands that run shell commands without invoking the LLM — zero token usage, instant execution. Especially useful from messaging platforms (Telegram, Discord, etc.) for quick server checks or utility scripts. diff --git a/website/docs/user-guide/messaging/whatsapp.md b/website/docs/user-guide/messaging/whatsapp.md index f754c9c221..57212df15d 100644 --- a/website/docs/user-guide/messaging/whatsapp.md +++ b/website/docs/user-guide/messaging/whatsapp.md @@ -97,6 +97,18 @@ WHATSAPP_MODE=bot # "bot" or "self-chat" WHATSAPP_ALLOWED_USERS=15551234567 # Comma-separated phone numbers (with country code, no +) ``` +Optional behavior settings in `~/.hermes/config.yaml`: + +```yaml +unauthorized_dm_behavior: pair + +whatsapp: + unauthorized_dm_behavior: ignore +``` + +- `unauthorized_dm_behavior: pair` is the global default. Unknown DM senders get a pairing code. +- `whatsapp.unauthorized_dm_behavior: ignore` makes WhatsApp stay silent for unauthorized DMs, which is usually the better choice for a private number. + Then start the gateway: ```bash @@ -162,6 +174,7 @@ whatsapp: | **Bridge crashes or reconnect loops** | Restart the gateway, update Hermes, and re-pair if the session was invalidated by a WhatsApp protocol change. | | **Bot stops working after WhatsApp update** | Update Hermes to get the latest bridge version, then re-pair. | | **Messages not being received** | Verify `WHATSAPP_ALLOWED_USERS` includes the sender's number (with country code, no `+` or spaces). | +| **Bot replies to strangers with a pairing code** | Set `whatsapp.unauthorized_dm_behavior: ignore` in `~/.hermes/config.yaml` if you want unauthorized DMs to be silently ignored instead. | --- @@ -173,6 +186,13 @@ of authorized users. Without this setting, the gateway will **deny all incoming safety measure. ::: +By default, unauthorized DMs still receive a pairing code reply. If you want a private WhatsApp number to stay completely silent to strangers, set: + +```yaml +whatsapp: + unauthorized_dm_behavior: ignore +``` + - The `~/.hermes/whatsapp/session` directory contains full session credentials — protect it like a password - Set file permissions: `chmod 700 ~/.hermes/whatsapp/session` - Use a **dedicated phone number** for the bot to isolate risk from your personal account diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index d6d14db8de..edf0a2e9b1 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -151,6 +151,19 @@ For more flexible authorization, Hermes includes a code-based pairing system. In 3. The bot owner runs `hermes pairing approve ` on the CLI 4. The user is permanently approved for that platform +Control how unauthorized direct messages are handled in `~/.hermes/config.yaml`: + +```yaml +unauthorized_dm_behavior: pair + +whatsapp: + unauthorized_dm_behavior: ignore +``` + +- `pair` is the default. Unauthorized DMs get a pairing code reply. +- `ignore` silently drops unauthorized DMs. +- 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): | Feature | Details |