feat(gateway): 'decline' unauthorized-DM behavior — one-time polite decline instead of pairing code

Port from qwibitai/nanoclaw#3260: adds a third unauthorized_dm_behavior
option, 'decline'. Instead of replying with a pairing code (pair) or
staying silent (ignore), the gateway sends one short, polite decline to
the unknown sender, then stays silent toward that sender for 24 hours.

- gateway/config.py: accept 'decline' in the normalizer; new
  unauthorized_dm_decline_message for custom decline text (round-trips
  through to_dict/from_dict).
- gateway/pairing.py: persisted decline stamps (_declined.json) on
  PairingStore with has_recent_decline/record_decline; stamps are
  pruned on write and recorded BEFORE delivery so a send failure can't
  become a decline storm (nanoclaw's stamp-first pattern).
- gateway/run.py: decline branch in the unauthorized-sender path;
  groups still always silently ignore.
- docs: security.md + configuration.md updated.

Adapted from TypeScript (NanoClaw's pending_sender_approvals 'decline:'
stamp rows) to Hermes' existing PairingStore JSON persistence; the
owner-FYI half of nanoclaw's flow is intentionally not ported — Hermes
logs the unauthorized attempt, and pairing remains the owner-visible
grant path.

Rebase onto the decomposed gateway (salvage, #88028):
- The unauthorized-sender path moved from gateway/run.py to
  gateway/run_inbound.py::_hm_admit_event; the decline branch is a sibling
  helper _hm_send_unauthorized_decline next to _hm_offer_pairing_code.
- gateway/config.py now validates the enum via _normalize_choice; the
  accepted set is the module constant UNAUTHORIZED_DM_BEHAVIORS (used by
  both from_dict and get_unauthorized_dm_behavior so a per-platform
  `extra.unauthorized_dm_behavior: decline` is honoured too). The default
  decline text lives in config as DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE.
- Tests trimmed from 5 to 2 invariant tests (send-once-then-silent through
  the real inbound path; config round-trip + real PairingStore stamp
  lifecycle with a patched clock instead of rewriting the JSON file).
This commit is contained in:
Teknium
2026-08-16 17:12:48 -07:00
parent 134ef6454d
commit a6e934e0fd
7 changed files with 139 additions and 12 deletions
+1 -1
View File
@@ -625,7 +625,7 @@ class GatewayAuthorizationMixin:
return "*" in allowed_ids or _principal_matches_allowlist(source, user_id, allowed_ids)
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform], *, profile: Optional[str] = None) -> str:
"""How unauthorized DMs are handled ("pair" / "ignore") for a platform.
"""How unauthorized DMs are handled ("pair" / "ignore" / "decline") for a platform.
Order: explicit per-platform config; Email → "ignore" (inboxes hold arbitrary mail); explicit
non-default global; adapter dm_policy (pairing → "pair", allowlist/disabled → "ignore"); any
+14 -4
View File
@@ -133,6 +133,14 @@ def _coerce_dict(value: Any) -> Dict[str, Any]:
return value if isinstance(value, dict) else {}
# "pair" DMs a pairing code, "ignore" drops silently, "decline" sends one polite refusal then goes
# silent toward that sender for gateway.pairing.DECLINE_DEDUPE_SECONDS (#88028).
UNAUTHORIZED_DM_BEHAVIORS = {"pair", "ignore", "decline"}
DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE = (
"Hi! I'm a personal assistant and can only chat with my owner, so I can't help you directly. Sorry!"
)
def _normalize_choice(value: Any, choices: set, default: str) -> str:
"""Lower-cased *value* when it is one of *choices*, else *default*."""
normalized = value.strip().lower() if isinstance(value, str) else None
@@ -561,7 +569,8 @@ class GatewayConfig:
loop_watchdog_probe_interval_s: float = DEFAULT_LOOP_WATCHDOG_INTERVAL_S
loop_watchdog_probe_timeout_s: float = DEFAULT_LOOP_WATCHDOG_TIMEOUT_S
loop_watchdog_max_strikes: int = DEFAULT_LOOP_WATCHDOG_MAX_STRIKES
unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
unauthorized_dm_behavior: str = "pair" # UNAUTHORIZED_DM_BEHAVIORS
unauthorized_dm_decline_message: str = "" # "decline" reply text; empty → DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE
streaming: StreamingConfig = field(default_factory=StreamingConfig)
# Prune SessionEntry records older than this (a resumed chat gets a fresh session). 0 = off.
session_store_max_age_days: int = 90
@@ -574,7 +583,7 @@ class GatewayConfig:
"max_concurrent_sessions", "multiplex_profiles",
"room_link_url", "systemd_watchdog_seconds", "loop_watchdog",
"loop_watchdog_probe_interval_s", "loop_watchdog_probe_timeout_s",
"loop_watchdog_max_strikes", "unauthorized_dm_behavior",
"loop_watchdog_max_strikes", "unauthorized_dm_behavior", "unauthorized_dm_decline_message",
)
def __post_init__(self) -> None:
@@ -720,7 +729,8 @@ class GatewayConfig:
loop_watchdog_probe_timeout_s=bounded_float("loop_watchdog_probe_timeout_s", DEFAULT_LOOP_WATCHDOG_TIMEOUT_S, 1.0, 600.0),
loop_watchdog_max_strikes=max_strikes,
max_concurrent_sessions=max_concurrent_sessions,
unauthorized_dm_behavior=_normalize_choice(data.get("unauthorized_dm_behavior"), {"pair", "ignore"}, "pair"),
unauthorized_dm_behavior=_normalize_choice(data.get("unauthorized_dm_behavior"), UNAUTHORIZED_DM_BEHAVIORS, "pair"),
unauthorized_dm_decline_message=str(data.get("unauthorized_dm_decline_message") or "").strip(),
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
profile_routes=parse_profile_routes(data.get("profile_routes") or []),
@@ -736,7 +746,7 @@ class GatewayConfig:
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
"""Effective unauthorized-DM behavior. Email is inbox-shaped so it defaults to ``"ignore"``
unless its own ``unauthorized_dm_behavior`` opts in (a global default does not)."""
choice = self._extra_choice(platform, "unauthorized_dm_behavior", {"pair", "ignore"}, self.unauthorized_dm_behavior)
choice = self._extra_choice(platform, "unauthorized_dm_behavior", UNAUTHORIZED_DM_BEHAVIORS, self.unauthorized_dm_behavior)
if choice is not None:
return choice
return "ignore" if platform == Platform.EMAIL else self.unauthorized_dm_behavior
+28
View File
@@ -34,6 +34,7 @@ RATE_LIMIT_SECONDS = 600 # 1 request per user per 10 minutes
LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures
MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform
MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
DECLINE_DEDUPE_SECONDS = 24 * 3600 # One polite decline per (platform, sender) per window (#88028)
# Default pairing directory override. Deliberately ``None``: an eagerly computed
# path would freeze the HERMES_HOME/profile context at gateway boot, ignoring later
@@ -349,6 +350,9 @@ class PairingStore:
def _rate_limit_path(self) -> Path:
return self._dir / "_rate_limits.json"
def _decline_stamp_path(self) -> Path:
return self._dir / "_declined.json"
def _cleanup_expired(self, platform: str) -> None:
"""Remove expired pending codes; malformed/legacy entries (no numeric ``created_at``) count as expired."""
path = self._pending_path(platform)
@@ -556,6 +560,30 @@ class PairingStore:
limits[f"{platform}:{alias}"] = now
self._save_limits(limits)
# ----- "decline" unauthorized-DM behavior (#88028) -----
def has_recent_decline(self, platform: str, user_id: str) -> bool:
"""Whether this sender (under any alias) was sent a polite decline within DECLINE_DEDUPE_SECONDS."""
stamps = self._load_json(self._decline_stamp_path())
now = time.time()
return any(
isinstance(stamped := stamps.get(f"{platform}:{alias}"), (int, float)) and (now - stamped) < DECLINE_DEDUPE_SECONDS
for alias in _user_id_aliases(platform, user_id)
)
def record_decline(self, platform: str, user_id: str) -> None:
"""Stamp the sender (all aliases) and prune expired stamps. Callers stamp BEFORE sending so a
delivery failure cannot become a decline storm on the sender's next message."""
with self._lock:
now = time.time()
stamps = {
key: value for key, value in self._load_json(self._decline_stamp_path()).items()
if isinstance(value, (int, float)) and (now - value) < DECLINE_DEDUPE_SECONDS
}
for alias in _user_id_aliases(platform, user_id):
stamps[f"{platform}:{alias}"] = now
self._save_json(self._decline_stamp_path(), stamps)
def _is_locked_out(self, platform: str) -> bool:
return time.time() < self._limits().get(f"_lockout:{platform}", 0)
+28 -7
View File
@@ -109,6 +109,26 @@ class GatewayInboundMixin:
# Record rate limit so subsequent messages are silently ignored
pairing_store._record_rate_limit(platform_name, source.user_id)
async def _hm_send_unauthorized_decline(self, source: SessionSource) -> None:
"""``decline`` behavior: one short refusal per sender per DECLINE_DEDUPE_SECONDS, then silence
(port of qwibitai/nanoclaw#3260, #88028). The stamp is written BEFORE the send so a delivery
hiccup cannot become a decline storm; without a store there is no dedupe state → stay silent."""
from gateway.config import DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE
platform_name = source.platform.value if source.platform else "unknown"
pairing_store = self._pairing_store_for(source)
if pairing_store is None or pairing_store.has_recent_decline(platform_name, source.user_id):
return
pairing_store.record_decline(platform_name, source.user_id)
adapter = self._adapter_for_source(source)
if not adapter:
return
config = getattr(self, "config", None)
text = str(getattr(config, "unauthorized_dm_decline_message", "") or "").strip()
try:
await adapter.send(source.chat_id, text or DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE)
except Exception:
logger.warning("Failed to deliver unauthorized-DM decline on %s", platform_name, exc_info=True)
async def _hm_admit_event(
self, event: "MessageEvent"
) -> Optional[Tuple["MessageEvent", SessionSource, bool]]:
@@ -195,13 +215,14 @@ class GatewayInboundMixin:
logger.debug("Ignoring message with no user_id from %s", source.platform.value)
return None
logger.warning("Unauthorized user: %s (%s) on %s", source.user_id, source.user_name, source.platform.value)
# DMs get a pairing code, groups are ignored. A bot cannot pair, and answering one mid-cooldown is outbound traffic.
if (
source.chat_type == "dm"
and not getattr(source, "is_bot", False)
and self._get_unauthorized_dm_behavior(source.platform, profile=source.profile) == "pair"
):
await self._hm_offer_pairing_code(source)
# DMs get a pairing code or a one-time decline, groups are ignored. A bot cannot pair, and
# answering one mid-cooldown is outbound traffic.
if source.chat_type == "dm" and not getattr(source, "is_bot", False):
behavior = self._get_unauthorized_dm_behavior(source.platform, profile=source.profile)
if behavior == "pair":
await self._hm_offer_pairing_code(source)
elif behavior == "decline":
await self._hm_send_unauthorized_decline(source)
return None
# The busy path charged this event on arrival; a drained follow-up must not pay twice.
if not getattr(event, "_bot_loop_admitted", False) and not self._admit_bot_message_for_source(source):
@@ -1,3 +1,4 @@
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -413,3 +414,62 @@ def test_qqbot_with_allowlist_ignores_unauthorized_dm(monkeypatch):
behavior = runner._get_unauthorized_dm_behavior(Platform.QQBOT)
assert behavior == "ignore"
# ---------------------------------------------------------------------------
# "decline" behavior: one-time polite decline instead of a pairing code
# (ported from qwibitai/nanoclaw#3260, #88028)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unauthorized_dm_decline_sends_once_then_stays_silent(monkeypatch):
"""First DM: stamp recorded BEFORE the send, custom text delivered, no pairing code. A sender
with a recent stamp gets nothing."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={Platform.WHATSAPP: PlatformConfig(enabled=True, extra={"unauthorized_dm_behavior": "decline"})},
)
config.unauthorized_dm_decline_message = "Sorry, this assistant is private."
runner, adapter = _make_runner(Platform.WHATSAPP, config)
jid = "15551234567@s.whatsapp.net"
runner.pairing_store.has_recent_decline.return_value = False
assert await runner._handle_message(_make_event(Platform.WHATSAPP, jid, jid)) is None
runner.pairing_store.generate_code.assert_not_called()
runner.pairing_store.record_decline.assert_called_once_with("whatsapp", jid)
adapter.send.assert_awaited_once_with(jid, "Sorry, this assistant is private.")
runner.pairing_store.has_recent_decline.return_value = True
adapter.send.reset_mock()
runner.pairing_store.record_decline.reset_mock()
assert await runner._handle_message(_make_event(Platform.WHATSAPP, jid, jid)) is None
runner.pairing_store.record_decline.assert_not_called()
adapter.send.assert_not_awaited()
def test_decline_config_and_stamp_roundtrip(monkeypatch, tmp_path):
"""Config accepts 'decline' (case-insensitive) and round-trips the custom text; a real
PairingStore persists the stamp, scopes it per sender, and expires it after the window."""
from unittest.mock import patch as _patch
import gateway.pairing as pairing_mod
config = GatewayConfig.from_dict(
{"unauthorized_dm_behavior": "DECLINE", "unauthorized_dm_decline_message": " custom text "}
)
assert config.unauthorized_dm_behavior == "decline"
assert config.get_unauthorized_dm_behavior(Platform.TELEGRAM) == "decline"
assert config.to_dict()["unauthorized_dm_behavior"] == "decline"
assert GatewayConfig.from_dict(config.to_dict()).unauthorized_dm_decline_message == "custom text"
with _patch("gateway.pairing.PAIRING_DIR", tmp_path):
store = pairing_mod.PairingStore()
assert store.has_recent_decline("telegram", "12345") is False
store.record_decline("telegram", "12345")
assert store.has_recent_decline("telegram", "12345") is True
assert store.has_recent_decline("telegram", "67890") is False
with _patch("gateway.pairing.time.time", return_value=time.time() + pairing_mod.DECLINE_DEDUPE_SECONDS + 1):
assert store.has_recent_decline("telegram", "12345") is False
+7
View File
@@ -2422,6 +2422,13 @@ whatsapp:
- `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.
- `decline` sends one short, polite decline instead of a pairing code, then stays silent toward that sender for 24 hours. Override the default text:
```yaml
unauthorized_dm_behavior: decline
unauthorized_dm_decline_message: "Sorry, this assistant is private."
```
- 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.
+1
View File
@@ -441,6 +441,7 @@ whatsapp:
- `pair` is the default for chat-style DM platforms. Unauthorized DMs get a pairing code reply.
- `ignore` silently drops unauthorized DMs.
- `decline` sends one short, polite decline ("I can only chat with my owner") instead of a pairing code, then ignores further messages from that sender for 24 hours. Customize the text with `unauthorized_dm_decline_message`.
- 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.