Files
hermes-agent/tests/gateway/test_unauthorized_dm_behavior.py
kshitijk4poor d00fb8b20b test(simplex): parametrize the contactId-vs-display-name authz test
The two SimpleX allowlist tests differed only in the allowlist value and
expected verdict; one parametrized test keeps both invariants and holds
the stack at two tests after the connect()-warning test was added.
2026-09-27 20:47:41 +05:30

493 lines
18 KiB
Python

import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.event import MessageEvent
from gateway.session import SessionSource
def _clear_auth_env(monkeypatch) -> None:
for key in (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"DISCORD_ALLOWED_USERS",
"WHATSAPP_ALLOWED_USERS",
"SLACK_ALLOWED_USERS",
"SIGNAL_ALLOWED_USERS",
"SIGNAL_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
"EMAIL_ALLOWED_USERS",
"SMS_ALLOWED_USERS",
"MATTERMOST_ALLOWED_USERS",
"MATRIX_ALLOWED_USERS",
"DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
"QQ_ALLOWED_USERS", "QQ_GROUP_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", "FEISHU_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS",
"QQ_ALLOW_ALL_USERS",
"GATEWAY_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)
def _make_event(
platform: Platform,
user_id: str,
chat_id: str,
*,
profile: str | None = None,
is_bot: bool = False,
) -> MessageEvent:
return MessageEvent(
text="hello",
message_id="m1",
source=SessionSource(
platform=platform,
user_id=user_id,
chat_id=chat_id,
user_name="tester",
is_bot=is_bot,
chat_type="dm",
profile=profile,
),
)
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
runner.pairing_store._is_rate_limited.return_value = False
# Attributes required by _handle_message for the authorized-user path
runner._running_agents = {}
runner._running_agents_ts = {}
runner._update_prompts = {}
runner.hooks = SimpleNamespace(dispatch=AsyncMock(return_value=None))
runner._sessions = {}
return runner, adapter
def test_whatsapp_lid_user_matches_phone_allowlist_via_modern_session_mapping(
monkeypatch, tmp_path,
):
"""Modern ``platforms/`` installs store bridge mappings under
``platforms/whatsapp/session`` — the LID→phone resolution (and therefore
the allowlist match) must work there too, not just the legacy layout.
Regression guard for the silently-dropped-LID-sender bug (#36664)."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550000001")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
session_dir = tmp_path / "platforms" / "whatsapp" / "session"
session_dir.mkdir(parents=True)
(session_dir / "lid-mapping-15550000001.json").write_text(
'"900000000000001"', encoding="utf-8",
)
(session_dir / "lid-mapping-900000000000001_reverse.json").write_text(
'"15550000001"', encoding="utf-8",
)
runner, _adapter = _make_runner(
Platform.WHATSAPP,
GatewayConfig(platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}),
)
source = SessionSource(
platform=Platform.WHATSAPP,
user_id="900000000000001@lid",
chat_id="900000000000001@lid",
user_name="tester",
chat_type="dm",
)
assert runner._is_user_authorized(source) is True
@pytest.mark.parametrize(
"allowlist, expected",
[
# Display names are attacker-controlled: another contact can take the
# same name, so matching user_name would bypass the allowlist (#44729).
("hujikuji", False),
# The stable numeric contactId is the one form a contact cannot forge.
("4", True),
],
)
def test_simplex_allowlist_matches_contact_id_not_display_name(monkeypatch, allowlist, expected):
"""SIMPLEX_ALLOWED_USERS matches only the numeric contactId (user_id),
never the contact's display name (user_name)."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("SIMPLEX_ALLOWED_USERS", allowlist)
from gateway.platform_registry import platform_registry, PlatformEntry
platform_registry.register(PlatformEntry(
name="simplex",
label="SimpleX Chat",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
allowed_users_env="SIMPLEX_ALLOWED_USERS",
allow_all_env="SIMPLEX_ALLOW_ALL_USERS",
))
simplex = Platform("simplex")
runner, _adapter = _make_runner(
simplex,
GatewayConfig(platforms={simplex: PlatformConfig(enabled=True)}),
)
source = SessionSource(
platform=simplex,
user_id="4", # adapter sets this to the numeric contactId
chat_id="hujikuji",
user_name="hujikuji", # adapter sets this to displayName
chat_type="dm",
)
assert runner._is_user_authorized(source) is expected
def test_telegram_group_users_legacy_chat_ids_still_authorize(monkeypatch):
"""Backward-compat: PR #15027 shipped TELEGRAM_GROUP_ALLOWED_USERS as a
chat-ID allowlist. PR #17686 renamed it to sender IDs and added
TELEGRAM_GROUP_ALLOWED_CHATS. Users on the old guidance must keep working:
chat-ID-shaped values (starting with "-") in the _USERS var are honored as
chat IDs with a deprecation warning.
"""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "-1001878443972")
runner, _adapter = _make_runner(
Platform.TELEGRAM,
GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}),
)
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="999",
chat_id="-1001878443972",
user_name="tester",
chat_type="forum",
)
assert runner._is_user_authorized(source) is True
def test_telegram_group_users_mixed_sender_and_legacy_chat(monkeypatch):
"""Mixed values: positive user ID gates senders; negative chat ID gates chat."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "999,-1001878443972")
runner, _adapter = _make_runner(
Platform.TELEGRAM,
GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}),
)
# Legacy chat ID path: any sender in the listed chat is authorized
legacy_chat_source = SessionSource(
platform=Platform.TELEGRAM,
user_id="123",
chat_id="-1001878443972",
user_name="tester",
chat_type="group",
)
assert runner._is_user_authorized(legacy_chat_source) is True
# Sender path: listed sender user ID authorized in any group
sender_source = SessionSource(
platform=Platform.TELEGRAM,
user_id="999",
chat_id="-1009999999999",
user_name="tester",
chat_type="group",
)
assert runner._is_user_authorized(sender_source) is True
@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_bot_dm_is_never_offered_a_pairing_code(monkeypatch):
"""A bot cannot pair, and a reply during a loop-guard cooldown would be outbound traffic."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)})
runner, adapter = _make_runner(Platform.WHATSAPP, config)
jid = "15551234567@s.whatsapp.net"
result = await runner._handle_message(_make_event(Platform.WHATSAPP, jid, jid, is_bot=True))
assert result is None
runner.pairing_store.generate_code.assert_not_called()
adapter.send.assert_not_awaited()
@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()
# ---------------------------------------------------------------------------
# Allowlist-configured platforms default to "ignore" for unauthorized users
# (#9337: Signal gateway sends pairing spam when allowlist is configured)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_signal_with_allowlist_ignores_unauthorized_dm(monkeypatch):
"""When SIGNAL_ALLOWED_USERS is set, unauthorized DMs are silently dropped.
This is the primary regression test for #9337: before the fix, Signal
would send pairing codes to ANY sender even when a strict allowlist was
configured, spamming personal contacts with cryptic bot messages.
"""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "+15550000001") # allowlist set
config = GatewayConfig(
platforms={Platform.SIGNAL: PlatformConfig(enabled=True)},
)
runner, adapter = _make_runner(Platform.SIGNAL, config)
result = await runner._handle_message(
_make_event(Platform.SIGNAL, "+15559999999", "+15559999999") # not in allowlist
)
assert result is None
runner.pairing_store.generate_code.assert_not_called()
adapter.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_telegram_with_allowlist_ignores_unauthorized_dm(monkeypatch):
"""Same behavior for Telegram: allowlist ⟹ ignore unauthorized DMs."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111")
config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True)},
)
runner, adapter = _make_runner(Platform.TELEGRAM, config)
result = await runner._handle_message(
_make_event(Platform.TELEGRAM, "999999999", "999999999")
)
assert result is None
runner.pairing_store.generate_code.assert_not_called()
adapter.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_global_allowlist_ignores_unauthorized_dm(monkeypatch):
"""GATEWAY_ALLOWED_USERS also triggers the 'ignore' behavior."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "111111111")
config = GatewayConfig(
platforms={Platform.SIGNAL: PlatformConfig(enabled=True)},
)
runner, adapter = _make_runner(Platform.SIGNAL, config)
result = await runner._handle_message(
_make_event(Platform.SIGNAL, "+15559999999", "+15559999999")
)
assert result is None
runner.pairing_store.generate_code.assert_not_called()
adapter.send.assert_not_awaited()
def test_allowlist_authorized_user_returns_ignore_for_unauthorized(monkeypatch):
"""_get_unauthorized_dm_behavior returns 'ignore' when allowlist is set.
We test the resolver directly. The full _handle_message path for
authorized users is covered by the integration tests in this module.
"""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "+15550000001")
config = GatewayConfig(
platforms={Platform.SIGNAL: PlatformConfig(enabled=True)},
)
runner, _adapter = _make_runner(Platform.SIGNAL, config)
behavior = runner._get_unauthorized_dm_behavior(Platform.SIGNAL)
assert behavior == "ignore"
def test_get_unauthorized_dm_behavior_no_allowlist_returns_pair(monkeypatch):
"""Without any allowlist, 'pair' is still the default."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={Platform.SIGNAL: PlatformConfig(enabled=True)},
)
runner, _adapter = _make_runner(Platform.SIGNAL, config)
behavior = runner._get_unauthorized_dm_behavior(Platform.SIGNAL)
assert behavior == "pair"
def test_get_unauthorized_dm_behavior_email_no_allowlist_returns_ignore(monkeypatch):
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={Platform.EMAIL: PlatformConfig(enabled=True)},
)
runner, _adapter = _make_runner(Platform.EMAIL, config)
behavior = runner._get_unauthorized_dm_behavior(Platform.EMAIL)
assert behavior == "ignore"
def test_qqbot_with_allowlist_ignores_unauthorized_dm(monkeypatch):
"""QQBOT is included in the allowlist-aware default (QQ_ALLOWED_USERS).
Regression guard: the initial #9337 fix omitted QQBOT from the env map
inside _get_unauthorized_dm_behavior, even though _is_user_authorized
mapped it to QQ_ALLOWED_USERS. Without QQBOT here, a QQ operator with a
strict user allowlist would still get pairing codes sent to strangers.
"""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("QQ_ALLOWED_USERS", "allowed-openid-1")
config = GatewayConfig(
platforms={Platform.QQBOT: PlatformConfig(enabled=True)},
)
runner, _adapter = _make_runner(Platform.QQBOT, config)
behavior = runner._get_unauthorized_dm_behavior(Platform.QQBOT)
assert behavior == "ignore"
# ---------------------------------------------------------------------------
# "decline" behavior: one-time polite decline instead of a pairing code (#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):
"""The real startup path (config.yaml -> load_gateway_config) keeps 'decline' (case-insensitive)
at top level and as a platform override, and carries 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
from gateway.config import load_gateway_config
_clear_auth_env(monkeypatch)
(tmp_path / "config.yaml").write_text(
"unauthorized_dm_behavior: DECLINE\n"
"unauthorized_dm_decline_message: ' custom text '\n"
"platforms:\n"
" telegram:\n"
" unauthorized_dm_behavior: pair\n"
" whatsapp:\n"
" unauthorized_dm_behavior: decline\n",
encoding="utf-8",
)
with _patch("gateway.config.get_hermes_home", return_value=tmp_path):
config = load_gateway_config()
assert config.unauthorized_dm_behavior == "decline"
assert config.unauthorized_dm_decline_message == "custom text"
assert config.get_unauthorized_dm_behavior(Platform.TELEGRAM) == "pair"
assert config.get_unauthorized_dm_behavior(Platform.WHATSAPP) == "decline"
assert config.get_unauthorized_dm_behavior(Platform.DISCORD) == "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