Address email pairing review feedback

This commit is contained in:
Shannon Sands 2026-06-18 17:21:43 +10:00 committed by Teknium
parent 2455e1801b
commit 5dae502b86
6 changed files with 71 additions and 35 deletions

View file

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

View file

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

View file

@ -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.<platform_key>``.
``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",

View file

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

View file

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

View file

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