From 47d9efe4908c66d4a5f84337cd44f2bdd8ae357c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:58:55 -0700 Subject: [PATCH] feat(gateway): plugin platforms declare group-chat allowlist and pairing suppression Two authz behaviours were hard-coded on Platform.QQBOT/TELEGRAM/... in gateway/authz_mixin.py (_GROUP_CHAT_ENV, _ALLOWLIST_SUPPRESSES_PAIRING), so a plugin-registered platform could not get them and removing the bundled QQ adapter would silently drop QQ_GROUP_ALLOWED_USERS gating and change pairing. PlatformEntry gains group_allowed_chats_env and allowlist_suppresses_pairing; authz_mixin reads them through authz_field (live entry, else the bundled gateway_env.AUTHZ). Every bundled platform that was in the old tables declares them in its AUTHZ, so behaviour is unchanged. --- gateway/authz_mixin.py | 34 +++++++++---------- gateway/platform_registry.py | 6 ++++ plugins/platforms/bluebubbles/gateway_env.py | 1 + plugins/platforms/dingtalk/gateway_env.py | 1 + plugins/platforms/discord/gateway_env.py | 1 + plugins/platforms/email/gateway_env.py | 1 + plugins/platforms/feishu/gateway_env.py | 1 + plugins/platforms/matrix/gateway_env.py | 1 + plugins/platforms/mattermost/gateway_env.py | 1 + plugins/platforms/qqbot/gateway_env.py | 2 ++ plugins/platforms/signal/gateway_env.py | 1 + plugins/platforms/slack/gateway_env.py | 1 + plugins/platforms/sms/gateway_env.py | 1 + plugins/platforms/telegram/gateway_env.py | 2 ++ plugins/platforms/wecom/gateway_env.py | 2 ++ plugins/platforms/weixin/gateway_env.py | 1 + plugins/platforms/whatsapp/gateway_env.py | 1 + .../platforms/whatsapp_cloud/gateway_env.py | 1 + .../test_platform_entry_authz_fields.py | 28 +++++++++++++++ 19 files changed, 70 insertions(+), 17 deletions(-) diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index b9f4ed90fb..dd43d5df00 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -28,19 +28,11 @@ _TRUTHY = frozenset({"true", "1", "yes"}) _BOT_LOOP_GUARD_INIT_LOCK = threading.Lock() logger = logging.getLogger(__name__) -# ``_ALLOWED_USERS`` / ``_ALLOW_ALL_USERS`` come from each platform's -# ``PlatformEntry.allowed_users_env`` / ``allow_all_env`` (shared with the pairing allowlist mirror). -# Platforms whose env allowlist turns the unauthorized-DM default from "pair" into "ignore". Names -# only (the env var is still the plugin's): this is the historical set the allowlist-aware default -# covered, so adding a platform here is a behaviour change (Yuanbao was never in it). -_ALLOWLIST_SUPPRESSES_PAIRING = frozenset({ - Platform.TELEGRAM, Platform.DISCORD, Platform.WHATSAPP, Platform.WHATSAPP_CLOUD, Platform.SLACK, - Platform.SIGNAL, Platform.EMAIL, Platform.SMS, Platform.MATTERMOST, Platform.MATRIX, Platform.DINGTALK, - Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, - Platform.QQBOT, -}) +# ``_ALLOWED_USERS`` / ``_ALLOW_ALL_USERS``, the group-chat allowlist env and +# whether an allowlist suppresses pairing come from each platform's ``PlatformEntry`` +# (``allowed_users_env`` / ``allow_all_env`` / ``group_allowed_chats_env`` / +# ``allowlist_suppresses_pairing``), so a plugin platform declares them like a bundled one. _GROUP_USER_ENV = {Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS"} -_GROUP_CHAT_ENV = {Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS"} # Gate reads use the shared per-profile isolated reader (allowlist leak under multiplex, #72348). @@ -59,6 +51,13 @@ def authz_field(name, field, entry): return _field(name, field, entry) +def _group_chat_env(platform, entry=None) -> str: + """The platform's group-chat allowlist env name (``PlatformEntry.group_allowed_chats_env``), or "".""" + if platform is None: + return "" + return authz_field(platform.value, "group_allowed_chats_env", entry or _registry_entry(platform)) or "" + + def _registry_entry(platform): """Platform-registry entry for a (plugin) platform, or None. Discovers first (idempotent): the allowlist env names live only on the entry, so an undiscovered process must not read "no list".""" @@ -553,7 +552,7 @@ class GatewayAuthorizationMixin: # Chat-scoped group allowlists must work with ``user_id is None`` (anonymous admins, # sender_chat posts, channel broadcasts). if is_group and source.chat_id: - chat_allowlist_env = _GROUP_CHAT_ENV.get(source.platform, "") + chat_allowlist_env = _group_chat_env(source.platform) if chat_allowlist_env and _allows(_coerce_allow_set(_auth_env(chat_allowlist_env)), source.chat_id): return True # config.yaml fallback (``extra.group_allowed_chats``): Telegram observe-unmentioned mode @@ -681,7 +680,7 @@ class GatewayAuthorizationMixin: platform_allowlist = _auth_env(platform_allow_env) group_user_allowlist = _auth_env(_GROUP_USER_ENV.get(source.platform, "")) if is_group_or_forum else "" - group_chat_allowlist = _auth_env(_GROUP_CHAT_ENV.get(source.platform, "")) if is_group_or_forum else "" + group_chat_allowlist = _auth_env(_group_chat_env(source.platform, entry)) if is_group_or_forum else "" global_allowlist = _auth_env("GATEWAY_ALLOWED_USERS") if not (platform_allowlist or group_user_allowlist or group_chat_allowlist or global_allowlist): @@ -749,9 +748,10 @@ class GatewayAuthorizationMixin: return "pair" if dm_policy in {"allowlist", "disabled"}: return "ignore" - env_key = (authz_field(platform.value, "allowed_users_env", _registry_entry(platform)) or "" - if platform in _ALLOWLIST_SUPPRESSES_PAIRING else "") - allowlist_keys = [env_key, _GROUP_USER_ENV.get(platform), _GROUP_CHAT_ENV.get(platform), *allowlist_keys] + entry = _registry_entry(platform) + env_key = (authz_field(platform.value, "allowed_users_env", entry) or "" + if authz_field(platform.value, "allowlist_suppresses_pairing", entry) else "") + allowlist_keys = [env_key, _GROUP_USER_ENV.get(platform), _group_chat_env(platform, entry), *allowlist_keys] if any(key and _auth_env(key).strip() for key in allowlist_keys): return "ignore" return "pair" diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index c7a0625f10..81eb059c77 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -138,6 +138,12 @@ class PlatformEntry: # either resolves "open" without ``allow_all_env`` / GATEWAY_ALLOW_ALL_USERS, startup is refused. # A "" member = that policy is YAML-only (QQ Bot). None = no own policy, never checked. own_policy_env: Optional[tuple] = None + # Env var listing group/forum CHAT ids whose traffic is authorized by chat (any sender, user_id + # optional): Telegram ``TELEGRAM_GROUP_ALLOWED_CHATS``, QQ ``QQ_GROUP_ALLOWED_USERS``. "" = none. + group_allowed_chats_env: str = "" + # True: a configured env allowlist turns the unauthorized-DM default from "pair" into "ignore" + # (the owner restricted access; unknown senders get no pairing code). + allowlist_suppresses_pairing: bool = False # --- send --- # send_message native target syntax, see ``gateway.platforms.send_targets``. A hint, not an # authority like ``parse_target_ref_fn``: None / NOT_EXPLICIT fall back to core's generic id diff --git a/plugins/platforms/bluebubbles/gateway_env.py b/plugins/platforms/bluebubbles/gateway_env.py index 9fc563a2ce..5d3079e612 100644 --- a/plugins/platforms/bluebubbles/gateway_env.py +++ b/plugins/platforms/bluebubbles/gateway_env.py @@ -27,4 +27,5 @@ ENV_OVERRIDES = ( AUTHZ = { "allowed_users_env": "BLUEBUBBLES_ALLOWED_USERS", "allow_all_env": "BLUEBUBBLES_ALLOW_ALL_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/dingtalk/gateway_env.py b/plugins/platforms/dingtalk/gateway_env.py index aa4ee5c660..caa7dc4298 100644 --- a/plugins/platforms/dingtalk/gateway_env.py +++ b/plugins/platforms/dingtalk/gateway_env.py @@ -17,4 +17,5 @@ ENV_OVERRIDES = ( AUTHZ = { "allowed_users_env": "DINGTALK_ALLOWED_USERS", "allow_all_env": "DINGTALK_ALLOW_ALL_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/discord/gateway_env.py b/plugins/platforms/discord/gateway_env.py index 484e2ac0dd..7d79c97cd4 100644 --- a/plugins/platforms/discord/gateway_env.py +++ b/plugins/platforms/discord/gateway_env.py @@ -17,4 +17,5 @@ AUTHZ = { "allowed_users_env": "DISCORD_ALLOWED_USERS", "allow_all_env": "DISCORD_ALLOW_ALL_USERS", "allow_bots_env": "DISCORD_ALLOW_BOTS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/email/gateway_env.py b/plugins/platforms/email/gateway_env.py index 6671945cae..9c74a39f26 100644 --- a/plugins/platforms/email/gateway_env.py +++ b/plugins/platforms/email/gateway_env.py @@ -17,4 +17,5 @@ ENV_OVERRIDES = ( AUTHZ = { "allowed_users_env": "EMAIL_ALLOWED_USERS", "allow_all_env": "EMAIL_ALLOW_ALL_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/feishu/gateway_env.py b/plugins/platforms/feishu/gateway_env.py index fefcbdee9e..96f115b782 100644 --- a/plugins/platforms/feishu/gateway_env.py +++ b/plugins/platforms/feishu/gateway_env.py @@ -22,4 +22,5 @@ AUTHZ = { "allowed_users_env": "FEISHU_ALLOWED_USERS", "allow_all_env": "FEISHU_ALLOW_ALL_USERS", "allow_bots_env": "FEISHU_ALLOW_BOTS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/matrix/gateway_env.py b/plugins/platforms/matrix/gateway_env.py index 883332a200..6e5a3ca965 100644 --- a/plugins/platforms/matrix/gateway_env.py +++ b/plugins/platforms/matrix/gateway_env.py @@ -30,4 +30,5 @@ ENV_OVERRIDES = ( AUTHZ = { "allowed_users_env": "MATRIX_ALLOWED_USERS", "allow_all_env": "MATRIX_ALLOW_ALL_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/mattermost/gateway_env.py b/plugins/platforms/mattermost/gateway_env.py index 78512223bd..8956fe21a6 100644 --- a/plugins/platforms/mattermost/gateway_env.py +++ b/plugins/platforms/mattermost/gateway_env.py @@ -18,4 +18,5 @@ ENV_OVERRIDES = ( AUTHZ = { "allowed_users_env": "MATTERMOST_ALLOWED_USERS", "allow_all_env": "MATTERMOST_ALLOW_ALL_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/qqbot/gateway_env.py b/plugins/platforms/qqbot/gateway_env.py index 6f3b74753a..7647b5de5a 100644 --- a/plugins/platforms/qqbot/gateway_env.py +++ b/plugins/platforms/qqbot/gateway_env.py @@ -43,4 +43,6 @@ AUTHZ = { "allowed_users_env": "QQ_ALLOWED_USERS", "allow_all_env": "QQ_ALLOW_ALL_USERS", "own_policy_env": ("", ""), + "group_allowed_chats_env": "QQ_GROUP_ALLOWED_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/signal/gateway_env.py b/plugins/platforms/signal/gateway_env.py index bba7dd8972..b1782ac0a3 100644 --- a/plugins/platforms/signal/gateway_env.py +++ b/plugins/platforms/signal/gateway_env.py @@ -21,4 +21,5 @@ ENV_OVERRIDES = ( AUTHZ = { "allowed_users_env": "SIGNAL_ALLOWED_USERS", "allow_all_env": "SIGNAL_ALLOW_ALL_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/slack/gateway_env.py b/plugins/platforms/slack/gateway_env.py index 31b3b265a9..ea476a726e 100644 --- a/plugins/platforms/slack/gateway_env.py +++ b/plugins/platforms/slack/gateway_env.py @@ -28,4 +28,5 @@ AUTHZ = { "allowed_users_env": "SLACK_ALLOWED_USERS", "allow_all_env": "SLACK_ALLOW_ALL_USERS", "allow_bots_env": "SLACK_ALLOW_BOTS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/sms/gateway_env.py b/plugins/platforms/sms/gateway_env.py index 4e51a9788b..afbb628588 100644 --- a/plugins/platforms/sms/gateway_env.py +++ b/plugins/platforms/sms/gateway_env.py @@ -19,4 +19,5 @@ ENV_OVERRIDES = ( AUTHZ = { "allowed_users_env": "SMS_ALLOWED_USERS", "allow_all_env": "SMS_ALLOW_ALL_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/telegram/gateway_env.py b/plugins/platforms/telegram/gateway_env.py index 0ce876ad40..659ac07418 100644 --- a/plugins/platforms/telegram/gateway_env.py +++ b/plugins/platforms/telegram/gateway_env.py @@ -24,4 +24,6 @@ AUTHZ = { "allowed_users_env": "TELEGRAM_ALLOWED_USERS", "allow_all_env": "TELEGRAM_ALLOW_ALL_USERS", "allow_bots_env": "TELEGRAM_ALLOW_BOTS", + "group_allowed_chats_env": "TELEGRAM_GROUP_ALLOWED_CHATS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/wecom/gateway_env.py b/plugins/platforms/wecom/gateway_env.py index 3d9dfea900..e1fcad2aa8 100644 --- a/plugins/platforms/wecom/gateway_env.py +++ b/plugins/platforms/wecom/gateway_env.py @@ -33,8 +33,10 @@ AUTHZ = { "allowed_users_env": "WECOM_ALLOWED_USERS", "allow_all_env": "WECOM_ALLOW_ALL_USERS", "own_policy_env": ("WECOM_DM_POLICY", "WECOM_GROUP_POLICY"), + "allowlist_suppresses_pairing": True, } CALLBACK_AUTHZ = { "allowed_users_env": "WECOM_CALLBACK_ALLOWED_USERS", "allow_all_env": "WECOM_CALLBACK_ALLOW_ALL_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/weixin/gateway_env.py b/plugins/platforms/weixin/gateway_env.py index 28d69044aa..0983dc1754 100644 --- a/plugins/platforms/weixin/gateway_env.py +++ b/plugins/platforms/weixin/gateway_env.py @@ -24,4 +24,5 @@ AUTHZ = { "allowed_users_env": "WEIXIN_ALLOWED_USERS", "allow_all_env": "WEIXIN_ALLOW_ALL_USERS", "own_policy_env": ("WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY"), + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/whatsapp/gateway_env.py b/plugins/platforms/whatsapp/gateway_env.py index 666ad8d9d0..1b95bd8cb8 100644 --- a/plugins/platforms/whatsapp/gateway_env.py +++ b/plugins/platforms/whatsapp/gateway_env.py @@ -27,4 +27,5 @@ AUTHZ = { "allowed_users_env": "WHATSAPP_ALLOWED_USERS", "allow_all_env": "WHATSAPP_ALLOW_ALL_USERS", "own_policy_env": ("WHATSAPP_DM_POLICY", "WHATSAPP_GROUP_POLICY"), + "allowlist_suppresses_pairing": True, } diff --git a/plugins/platforms/whatsapp_cloud/gateway_env.py b/plugins/platforms/whatsapp_cloud/gateway_env.py index 7e58d07497..04eb165532 100644 --- a/plugins/platforms/whatsapp_cloud/gateway_env.py +++ b/plugins/platforms/whatsapp_cloud/gateway_env.py @@ -24,4 +24,5 @@ ENV_OVERRIDES = ( AUTHZ = { "allowed_users_env": "WHATSAPP_CLOUD_ALLOWED_USERS", "allow_all_env": "WHATSAPP_CLOUD_ALLOW_ALL_USERS", + "allowlist_suppresses_pairing": True, } diff --git a/tests/gateway/test_platform_entry_authz_fields.py b/tests/gateway/test_platform_entry_authz_fields.py index 1dedd103a3..8ac7827972 100644 --- a/tests/gateway/test_platform_entry_authz_fields.py +++ b/tests/gateway/test_platform_entry_authz_fields.py @@ -51,3 +51,31 @@ def test_declared_own_policy_env_refuses_open_policy_without_allow_all(probe_pla assert _own_policy_open_startup_violation(cfg) == "authzprobe: open policy without allow-all opt-in" monkeypatch.setenv("AUTHZPROBE_ALLOW_ALL_USERS", "true") assert _own_policy_open_startup_violation(cfg) is None + + +@pytest.mark.parametrize("declared", [True, False]) +def test_declared_group_chat_env_and_pairing_suppression(monkeypatch, declared): + """A plugin platform declaring ``group_allowed_chats_env`` / ``allowlist_suppresses_pairing`` + gets chat-id group authorization and the allowlist-aware "ignore" default; undeclared, neither.""" + from gateway.platform_registry import PlatformEntry, platform_registry + from gateway.run import GatewayRunner + + for var in ("GRPPROBE_ALLOWED_USERS", "GRPPROBE_GROUP_CHATS", "GATEWAY_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS"): + monkeypatch.delenv(var, raising=False) + fields = dict(group_allowed_chats_env="GRPPROBE_GROUP_CHATS", allowlist_suppresses_pairing=True) if declared else {} + platform_registry.register(PlatformEntry( + name="grpprobe", label="Group Probe", adapter_factory=lambda cfg: None, check_fn=lambda: True, + allowed_users_env="GRPPROBE_ALLOWED_USERS", **fields)) + try: + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(platforms={Platform("grpprobe"): PlatformConfig(enabled=True)}) + runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False) + group = SessionSource(platform=Platform("grpprobe"), chat_id="g1", chat_type="group", user_id=None, + user_name="") + monkeypatch.setenv("GRPPROBE_GROUP_CHATS", "g1") + assert runner._is_user_authorized(group) is declared + monkeypatch.delenv("GRPPROBE_GROUP_CHATS") + monkeypatch.setenv("GRPPROBE_ALLOWED_USERS", "owner") + assert runner._get_unauthorized_dm_behavior(Platform("grpprobe")) == ("ignore" if declared else "pair") + finally: + platform_registry.unregister("grpprobe")