diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 70632d78cb3..bcefb4eecb4 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -457,17 +457,19 @@ 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. Email defaults to ``"ignore"`` unless explicitly opted into + 2. 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``, + 3. Explicit global ``unauthorized_dm_behavior`` in config — wins for + chat-shaped platforms when no per-platform override is set. + 4. When an adapter-level DM policy opts into pairing or silent drop, honor it. + 5. 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) - 5. No allowlist and no explicit config → ``"pair"`` (open-gateway default). + 6. No allowlist and no explicit config → ``"pair"`` (open-gateway default). """ config = getattr(self, "config", None) @@ -478,6 +480,14 @@ class GatewayAuthorizationMixin: # Operator explicitly configured behavior for this platform — respect it. return config.get_unauthorized_dm_behavior(platform) + # 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. Keep this before the global fallback to + # match GatewayConfig.get_unauthorized_dm_behavior(). + if platform == Platform.EMAIL: + return "ignore" + # Check for an explicit global config override. if config and hasattr(config, "unauthorized_dm_behavior"): if config.unauthorized_dm_behavior != "pair": # non-default → explicit override @@ -497,13 +507,6 @@ 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 6b474a34038..e1556b37d52 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -749,7 +749,12 @@ class GatewayConfig: ) def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str: - """Return the effective unauthorized-DM behavior for a platform.""" + """Return the effective unauthorized-DM behavior for a platform. + + Email is inbox-shaped, not chat-shaped, so it defaults to ``"ignore"`` + unless ``platforms.email.unauthorized_dm_behavior`` explicitly opts + into pairing. A global default does not opt email into pairing. + """ if platform: platform_cfg = self.platforms.get(platform) if platform_cfg and "unauthorized_dm_behavior" in platform_cfg.extra: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 49f516da15d..ee03744a45e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5636,6 +5636,34 @@ def load_config_readonly() -> Dict[str, Any]: return _load_config_impl(want_deepcopy=False) +def write_platform_config_field( + platform_key: str, + field_key: str, + value: Any, + *, + raw: bool = False, +) -> None: + """Persist one scalar field under ``platforms.``. + + ``raw=True`` preserves CLI setup flows that intentionally edit only the + user's raw config file. Dashboard routes use the default loaded-config path + so they retain their existing profile-scoped ``load_config`` behavior. + """ + config = read_raw_config() if raw else load_config() + platforms = config.setdefault("platforms", {}) + if not isinstance(platforms, dict): + platforms = {} + config["platforms"] = platforms + + platform_config = platforms.setdefault(platform_key, {}) + if not isinstance(platform_config, dict): + platform_config = {} + platforms[platform_key] = platform_config + + platform_config[field_key] = value + save_config(config) + + TERMINAL_CONFIG_ENV_MAP = { "backend": "TERMINAL_ENV", "modal_mode": "TERMINAL_MODAL_MODE", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b68f48476cc..03435eac028 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -30,8 +30,8 @@ from hermes_cli.config import ( is_managed, managed_error, read_raw_config, - save_config, save_env_value, + write_platform_config_field, ) # display_hermes_home is imported lazily at call sites to avoid ImportError @@ -4648,17 +4648,7 @@ def _runtime_health_lines() -> list[str]: 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) + write_platform_config_field(platform_key, "unauthorized_dm_behavior", behavior, raw=True) def _setup_standard_platform(platform: dict): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4227e621113..f869a2a43ae 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -62,6 +62,7 @@ from hermes_cli.config import ( format_docker_update_message, recommended_update_command_for_method, redact_key, + write_platform_config_field, ) from hermes_cli.memory_providers import ( MemoryProvider, @@ -5006,17 +5007,7 @@ def _messaging_platform_payload( def _write_platform_enabled(platform_id: str, enabled: bool) -> None: - config = load_config() - platforms = config.setdefault("platforms", {}) - if not isinstance(platforms, dict): - platforms = {} - config["platforms"] = platforms - platform_config = platforms.setdefault(platform_id, {}) - if not isinstance(platform_config, dict): - platform_config = {} - platforms[platform_id] = platform_config - platform_config["enabled"] = enabled - save_config(config) + write_platform_config_field(platform_id, "enabled", enabled) _TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com" diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 5235a1bd205..b6c82636892 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -21,6 +21,7 @@ from hermes_cli.config import ( save_env_value, save_env_value_secure, sanitize_env_file, + write_platform_config_field, _sanitize_env_lines, ) @@ -255,6 +256,24 @@ class TestSaveAndLoadRoundtrip: reloaded = load_config() assert reloaded["terminal"]["timeout"] == 999 + def test_write_platform_config_field_coerces_nested_platform_maps(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + (tmp_path / "config.yaml").write_text( + "model: test/custom-model\nplatforms: not-a-map\n", + encoding="utf-8", + ) + + write_platform_config_field( + "email", + "unauthorized_dm_behavior", + "pair", + raw=True, + ) + + saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + assert saved["model"] == "test/custom-model" + assert saved["platforms"]["email"]["unauthorized_dm_behavior"] == "pair" + class TestSaveEnvValueSecure: def test_save_env_value_writes_without_stdout(self, tmp_path, capsys):