diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 12b6e3e43c74..5313ecd02878 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -1413,6 +1413,16 @@ class FeishuAdapter(BasePlatformAdapter): supports_code_blocks = True # Feishu renders fenced code blocks splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH) + # Feishu gates DM/group access at intake via FEISHU_GROUP_POLICY + + # per-group group_rules..allowlist (parsed from config.extra). + # The gateway's env-based FEISHU_ALLOWED_USERS check runs AFTER this; when + # no env allowlist is configured, the gateway consults this flag so it can + # honor the config-driven group_rules allowlist the adapter already + # enforced, instead of double-denying it. Mirrors WeCom / Weixin / Yuanbao. + @property + def enforces_own_access_policy(self) -> bool: + return True + MAX_MESSAGE_LENGTH = 8000 # Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in. CHAT_LOCK_MAX_SIZE: int = 1000 @@ -4200,6 +4210,17 @@ def _admit(self, sender: Any, message: Any) -> Optional[RejectReason]: return "bot_not_mentioned" if not is_group: + if os.getenv("FEISHU_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + return None + if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}: + return None + # Empty FEISHU_ALLOWED_USERS is the pairing-mode default from setup: + # forward DMs to gateway intake so the pairing handshake can run. + # Gateway auth fail-closes agent access until approval. + if not self._allowed_group_users: + return None + if not (sender_ids and (sender_ids & self._allowed_group_users)): + return "dm_policy_rejected" return None if not self._allow_group_message( diff --git a/tests/gateway/test_config_driven_access_policy.py b/tests/gateway/test_config_driven_access_policy.py index 4bfbdf59c782..64c026a1bea5 100644 --- a/tests/gateway/test_config_driven_access_policy.py +++ b/tests/gateway/test_config_driven_access_policy.py @@ -41,6 +41,7 @@ Platform.YUANBAO, Platform.QQBOT, Platform.WHATSAPP, + Platform.FEISHU, ] @@ -52,6 +53,7 @@ def _clear_auth_env(monkeypatch) -> None: "QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "WHATSAPP_ALLOWED_USERS", + "FEISHU_ALLOWED_USERS", "TELEGRAM_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS", "GATEWAY_ALLOW_ALL_USERS", @@ -60,6 +62,7 @@ def _clear_auth_env(monkeypatch) -> None: "YUANBAO_ALLOW_ALL_USERS", "QQ_ALLOW_ALL_USERS", "WHATSAPP_ALLOW_ALL_USERS", + "FEISHU_ALLOW_ALL_USERS", ): monkeypatch.delenv(key, raising=False) @@ -113,6 +116,7 @@ def test_base_adapter_defaults_to_not_owning_access_policy(): ("gateway.platforms.yuanbao", "YuanbaoAdapter"), ("gateway.platforms.qqbot.adapter", "QQAdapter"), ("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"), + ("plugins.platforms.feishu.adapter", "FeishuAdapter"), ], ) def test_own_policy_adapters_declare_the_flag(module_path, class_name): @@ -149,6 +153,19 @@ def test_own_policy_allowlist_authorized_without_env_allowlist(monkeypatch, plat assert runner._is_user_authorized(_source(platform)) is True +@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS) +def test_own_policy_open_dm_authorized_with_gateway_allow_all(monkeypatch, platform): + """Explicit ``GATEWAY_ALLOW_ALL_USERS`` unlocks ``dm_policy: open``.""" + _clear_auth_env(monkeypatch) + monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true") + config = GatewayConfig( + platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})} + ) + runner, _adapter = _make_runner(platform, config, enforces=True) + + assert runner._is_user_authorized(_source(platform)) is True + + @pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS) def test_own_policy_open_dm_not_authorized_without_allowlist(monkeypatch, platform): """``dm_policy: open`` forwards everyone → NOT authorization (SECURITY.md §2.6). @@ -207,6 +224,82 @@ def test_own_policy_open_group_not_authorized_without_allowlist(monkeypatch, pla assert runner._is_user_authorized(_source(platform, chat_type="group")) is False +@pytest.mark.parametrize( + "module_path, class_name, dm_helper", + [ + ("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter", "_is_dm_allowed"), + ("plugins.platforms.wecom.adapter", "WeComAdapter", "_is_dm_allowed"), + ("gateway.platforms.weixin", "WeixinAdapter", "_is_dm_allowed"), + ("gateway.platforms.qqbot.adapter", "QQAdapter", "_is_dm_allowed"), + ], +) +def test_pairing_dm_policy_strict_intake_auth_denies_unknown( + monkeypatch, module_path, class_name, dm_helper, +): + """Default ``dm_policy: pairing`` must not admit senders via strict auth.""" + _clear_auth_env(monkeypatch) + import importlib + + from gateway.config import PlatformConfig + + module = importlib.import_module(module_path) + adapter_cls = getattr(module, class_name) + adapter = adapter_cls(PlatformConfig(enabled=True, extra={"dm_policy": "pairing"})) + assert getattr(adapter, dm_helper)("unknown-user") is False + + +@pytest.mark.parametrize( + "module_path, class_name, intake_helper", + [ + ("gateway.platforms.qqbot.adapter", "QQAdapter", "_is_dm_intake_allowed"), + ("plugins.platforms.wecom.adapter", "WeComAdapter", "_is_dm_intake_allowed"), + ("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter", "_is_dm_intake_allowed"), + ], +) +@pytest.mark.parametrize("blank_sender", ["", " ", None]) +def test_pairing_dm_intake_denies_blank_principal( + monkeypatch, module_path, class_name, intake_helper, blank_sender, +): + """Pairing intake must not forward senderless DM callbacks to the gateway.""" + _clear_auth_env(monkeypatch) + import importlib + + from gateway.config import PlatformConfig + + module = importlib.import_module(module_path) + adapter_cls = getattr(module, class_name) + adapter = adapter_cls(PlatformConfig(enabled=True, extra={"dm_policy": "pairing"})) + assert getattr(adapter, intake_helper)(blank_sender) is False + + +@pytest.mark.parametrize("blank_sender", ["", " ", None]) +def test_yuanbao_pairing_dm_intake_denies_blank_principal(monkeypatch, blank_sender): + """Yuanbao pairing intake must not forward senderless C2C callbacks.""" + _clear_auth_env(monkeypatch) + from gateway.platforms.yuanbao import AccessPolicy + + policy = AccessPolicy( + dm_policy="pairing", + dm_allow_from=[], + group_policy="pairing", + group_allow_from=[], + ) + assert policy.is_dm_intake_allowed(blank_sender) is False + assert policy.is_dm_intake_allowed("user-1") is True + + +@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS) +def test_pairing_group_policy_not_blanket_authorized(monkeypatch, platform): + """Default ``group_policy: pairing`` must not authorize unknown group senders.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "pairing"})} + ) + runner, _adapter = _make_runner(platform, config, enforces=True) + + assert runner._is_user_authorized(_source(platform, chat_type="group")) is False + + def test_wecom_open_group_with_per_group_sender_allowlist_is_authorized(monkeypatch): """WeCom ``groups..allow_from`` is an adapter-enforced restriction.