Files
hermes-agent/tests/gateway/test_adapter_authz_secret_scope.py
T
teknium1 8185834bc9 refactor(gateway): one OwnAccessPolicyMixin replaces five copied DM/group intake predicates
Weixin, WeCom, QQBot, WhatsApp (common + cloud) and Yuanbao's AccessPolicy each carried
their own `_open_dm_opted_in` / `_is_dm_allowed` / `_is_dm_intake_allowed` /
`_is_group_allowed`, differing only in the platform prefix of the allow-all env var and in
small drifts. The same "allow-all must be scoped / fail closed" fix has landed on this trio
at least four times; a shared rule means it lands once.

gateway/platforms/access_policy_mixin.py::OwnAccessPolicyMixin owns the predicates,
reads every env name through the scoped `get_scoped_secret` and exposes two hooks:
`_entry_matches` (platform allowlist matching) and `_live_dm_allow_from` (env-seeded
lists re-read live). `ALLOW_ALL_ENV_PREFIX` is the only per-adapter datum.

Retained overrides (behavior genuinely differs):
- whatsapp_cloud `_allow_all_env_names` adds WHATSAPP_CLOUD_ALLOW_ALL_USERS;
  `_is_dm_allowed` keeps its bare-wa_id normalisation.
- whatsapp_common `_entry_matches` -> phone/LID alias matching; `_live_dm_allow_from`.
- wecom `_is_group_allowed(chat_id, sender_id)` adds the per-group sender allowlist on
  top of the shared chat-level rule; `_entry_matches` strips `wecom:user:` prefixes.
- qqbot `_entry_matches` (case-insensitive, `*`).
- yuanbao `AccessPolicy.is_group_allowed`: `open` groups still require the allow-all
  opt-in (no runner-side mention gate), so it wraps the shared rule.

Behavior change: Weixin `_is_dm_intake_allowed` now denies a blank/whitespace principal
(the other four already did; safe variant chosen). Weixin's inline group gate now goes
through `_is_group_allowed` with identical verdicts.
2026-09-13 05:32:38 -07:00

133 lines
6.1 KiB
Python

"""Adapter authz gates must read GATEWAY_/PLATFORM_ allow-all + allowlists through the profile
secret scope (#77548 cluster; #72348 / #86905 precedent).
Under ``gateway.multiplex_profiles`` ``os.environ`` holds the DEFAULT profile's values. A raw
``os.getenv`` in an adapter's own gate let the default's ``GATEWAY_ALLOW_ALL_USERS=true`` open every
secondary email/QQ/WhatsApp/Matrix/Teams/Slack/LINE/DingTalk bot, and the default's allowlist decide
who may approve on a secondary bot. Two invariants per adapter: the default env never answers a
scoped gate; the scope's own opt-in/allowlist does.
"""
import contextlib
from types import SimpleNamespace
import pytest
from agent import secret_scope as ss
from gateway.config import PlatformConfig
_DEFAULT_ENV = {
"GATEWAY_ALLOW_ALL_USERS": "true", "GATEWAY_ALLOWED_USERS": "default-admin",
"TEAMS_ALLOW_ALL_USERS": "true", "TEAMS_ALLOWED_USERS": "default-admin",
"MATRIX_ALLOWED_USERS": "@default-admin:example.org", "MATRIX_IGNORE_USER_PATTERNS": r"^@spam:.*",
"WHATSAPP_ALLOWED_USERS": "+15550001111", "SLACK_ALLOW_BOTS": "all", "SLACK_API_HUMAN_USERS": "U0DEFAULT",
"LINE_ALLOW_ALL_USERS": "true", "LINE_ALLOWED_USERS": "Udefault", "DINGTALK_ALLOWED_USERS": "default-admin",
}
@pytest.fixture
def default_env(monkeypatch):
"""Multiplex on; os.environ carries the DEFAULT profile's permissive authz."""
for name, value in _DEFAULT_ENV.items():
monkeypatch.setenv(name, value)
monkeypatch.setattr(ss, "_MULTIPLEX_ACTIVE", True)
@contextlib.contextmanager
def _scope(secrets):
token = ss.set_secret_scope(secrets)
try:
yield
finally:
ss.reset_secret_scope(token)
def _matrix(extra=None):
from plugins.platforms.matrix.adapter import MatrixAdapter
return MatrixAdapter(PlatformConfig(enabled=True, token="t", extra={"homeserver": "https://m.example",
"user_id": "@bot:m.example", **(extra or {})}))
def _whatsapp():
from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
host = WhatsAppBehaviorMixin()
host._dm_allowlist_source, host._allow_from = "WHATSAPP_ALLOWED_USERS", set()
return host
def _slack():
from plugins.platforms.slack.adapter import SlackAdapter
adapter = SlackAdapter.__new__(SlackAdapter)
adapter.config = PlatformConfig(enabled=True, extra={})
return adapter
def _dingtalk():
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter.__new__(DingTalkAdapter)
adapter.config = PlatformConfig(enabled=True, extra={})
return adapter
def _line():
from plugins.platforms.line.adapter import LineAdapter
return LineAdapter(PlatformConfig(enabled=True, extra={"channel_access_token": "t", "channel_secret": "s"}))
# (label, opt-in the SECONDARY scope grants, gate(scope) -> observed value, closed value, open value)
_GATES = [
("email.allow_all", {"GATEWAY_ALLOW_ALL_USERS": "true"},
lambda: __import__("plugins.platforms.email.adapter", fromlist=["EmailAdapter"]).EmailAdapter._allow_all_senders(),
False, True),
("email.allowlist", {"GATEWAY_ALLOWED_USERS": "bot2-admin"},
lambda: __import__("plugins.platforms.email.adapter", fromlist=["EmailAdapter"]).EmailAdapter._allowlist_in_effect(),
False, True),
("qqbot.open_dm", {"QQ_ALLOW_ALL_USERS": "true"},
lambda: __import__("gateway.platforms.qqbot.adapter", fromlist=["QQAdapter"]).QQAdapter._open_dm_opted_in(object.__new__(__import__("gateway.platforms.qqbot.adapter", fromlist=["QQAdapter"]).QQAdapter)),
False, True),
("whatsapp.open_dm", {"GATEWAY_ALLOW_ALL_USERS": "true"},
lambda: __import__("gateway.platforms.whatsapp_common", fromlist=["WhatsAppBehaviorMixin"]).WhatsAppBehaviorMixin._open_dm_opted_in(_whatsapp()),
False, True),
("whatsapp.live_allow_from", {"WHATSAPP_ALLOWED_USERS": "+15559998888"},
lambda: __import__("gateway.platforms.whatsapp_common", fromlist=["WhatsAppBehaviorMixin"]).WhatsAppBehaviorMixin._live_dm_allow_from(_whatsapp()),
set(), {"+15559998888"}),
("teams.card_action", {"TEAMS_ALLOWED_USERS": "clicker"},
lambda: __import__("plugins.platforms.teams.adapter", fromlist=["TeamsAdapter"]).TeamsAdapter._card_action_denied(
SimpleNamespace(aad_object_id="clicker")) is None,
False, True),
("matrix.authorized_user", {"MATRIX_ALLOWED_USERS": "@bot2-admin:example.org"},
lambda: _matrix()._is_authorized_user("@bot2-admin:example.org"),
False, True),
("matrix.ignored_patterns", {"MATRIX_IGNORE_USER_PATTERNS": r"^@bot2-spam:.*"},
lambda: [p.pattern for p in _matrix()._ignored_user_patterns],
[], [r"^@bot2-spam:.*"]),
("slack.allow_bots", {"SLACK_ALLOW_BOTS": "mentions"}, lambda: _slack()._slack_allow_bots(), "none", "mentions"),
("slack.api_human_users", {"SLACK_API_HUMAN_USERS": "U0BOT2"},
lambda: set(_slack()._slack_api_human_users()), set(), {"U0BOT2"}),
("line.allow_all", {"LINE_ALLOW_ALL_USERS": "true"}, lambda: _line().allow_all, False, True),
("line.allowed_users", {"LINE_ALLOWED_USERS": "Ubot2"}, lambda: _line().allowed_users, set(), {"Ubot2"}),
("dingtalk.allowed_users", {"DINGTALK_ALLOWED_USERS": "Bot2Admin"},
lambda: {u.lower() for u in _dingtalk()._csv_setting("allowed_users", "DINGTALK_ALLOWED_USERS")}, set(), {"bot2admin"}),
]
@pytest.mark.parametrize("label,scope_opt_in,gate,closed,opened", _GATES, ids=[g[0] for g in _GATES])
def test_default_env_never_answers_a_scoped_gate(default_env, label, scope_opt_in, gate, closed, opened):
"""Secondary scope with NO opt-in: the default profile's permissive os.environ must not open it."""
with _scope({}):
assert gate() == closed
@pytest.mark.parametrize("label,scope_opt_in,gate,closed,opened", _GATES, ids=[g[0] for g in _GATES])
def test_scope_opt_in_opens_the_gate(default_env, monkeypatch, label, scope_opt_in, gate, closed, opened):
"""The secondary's own .env decides — even when the default profile's env is silent."""
for name in _DEFAULT_ENV:
monkeypatch.delenv(name, raising=False)
with _scope(scope_opt_in):
assert gate() == opened