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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user