diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 379e6a82c0..7a152d3ef3 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -1651,9 +1651,11 @@ class QQAdapter(BasePlatformAdapter): return re.sub(r"^@\S+\s*", "", content.strip()) def _open_dm_opted_in(self) -> bool: + # Both names via the scoped reader: under multiplex os.environ is the DEFAULT profile's + # opt-in, which must not open a secondary bot's DMs. truthy = {"true", "1", "yes"} - return (os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in truthy - or _resolve_qq_secret("QQ_ALLOW_ALL_USERS", "").lower() in truthy) + return any(_resolve_qq_secret(name, "").lower() in truthy + for name in ("GATEWAY_ALLOW_ALL_USERS", "QQ_ALLOW_ALL_USERS")) def _is_dm_allowed(self, user_id: str) -> bool: if self._dm_policy == "allowlist": diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index 9442805501..57b0942a69 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -132,7 +132,10 @@ class WhatsAppBehaviorMixin: set (pairing revoke purges it in place) — a stale env value must not broaden access.""" source = getattr(self, "_dm_allowlist_source", None) if isinstance(source, str) and source != "config": - return self._coerce_allow_list(os.environ[source]) if source in os.environ else set() + # Scoped read: under multiplex os.environ is the DEFAULT profile's allowlist. None = key + # absent (revoked) → empty, never the construction snapshot. + live = _get_wsecret(source) + return self._coerce_allow_list(live) if live is not None else set() return set(self._allow_from or ()) # ------------------------------------------------------------------ JID helpers @@ -154,9 +157,10 @@ class WhatsAppBehaviorMixin: # ------------------------------------------------------------------ gating def _open_dm_opted_in(self) -> bool: - if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in _OPTIN_TRUTHY: - return True - return (_get_wsecret("WHATSAPP_ALLOW_ALL_USERS", default="") or "").lower() in _OPTIN_TRUTHY + # Both names via the scoped reader — the DEFAULT profile's os.environ opt-in must not open + # a secondary bot's DMs. + return any((_get_wsecret(name, default="") or "").lower() in _OPTIN_TRUTHY + for name in ("GATEWAY_ALLOW_ALL_USERS", "WHATSAPP_ALLOW_ALL_USERS")) @staticmethod def _matches_whatsapp_allowlist(candidate: str, allow_from) -> bool: diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py index 4d41cf7912..1d73eae067 100644 --- a/plugins/platforms/dingtalk/adapter.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -251,9 +251,11 @@ class DingTalkAdapter(BasePlatformAdapter): logger.info("[%s] Disconnected", self.name) def _extra_get(self, key: str, env_name: str = "", env_default: str = ""): - """config.extra[key]; when *env_name* is given, absent keys fall back to the env var.""" + """config.extra[key]; when *env_name* is given, absent keys fall back to the env var. + + Scoped read: under multiplex os.environ is the DEFAULT profile's allowlist/policy.""" value = self.config.extra.get(key) if self.config.extra else None - return os.getenv(env_name, env_default) if value is None and env_name else value + return _get_scoped_secret(env_name, env_default) if value is None and env_name else value def _csv_setting(self, key: str, env_name: str) -> Set[str]: """List/CSV setting from config.extra[key], falling back to the env var.""" diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py index da05d32453..47b57564ff 100644 --- a/plugins/platforms/email/adapter.py +++ b/plugins/platforms/email/adapter.py @@ -589,13 +589,17 @@ class EmailAdapter(BasePlatformAdapter): @staticmethod def _allow_all_senders() -> bool: - """True when the operator opted into any sender (EMAIL_ or GATEWAY_ALLOW_ALL_USERS).""" - return (_get_secret("EMAIL_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY or os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY) + """True when the operator opted into any sender (EMAIL_ or GATEWAY_ALLOW_ALL_USERS). + + Both names go through the scoped reader: under multiplex ``os.environ`` is the DEFAULT + profile's opt-in, and borrowing it opened every secondary mailbox to any sender.""" + return any(_get_secret(name, "").strip().lower() in _TRUTHY + for name in ("EMAIL_ALLOW_ALL_USERS", "GATEWAY_ALLOW_ALL_USERS")) @staticmethod def _allowlist_in_effect() -> bool: """True when EMAIL_/GATEWAY_ALLOWED_USERS gates access (without one the gateway default-denies, so the spoofable From: grants nothing).""" - return bool(_get_secret("EMAIL_ALLOWED_USERS", "").strip() or os.getenv("GATEWAY_ALLOWED_USERS", "").strip()) + return any(_get_secret(name, "").strip() for name in ("EMAIL_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS")) def _sender_accepted(self, sender_addr: str, msg_data: Dict[str, Any]) -> bool: """Pre-dispatch sender gate: self, automated, allowlist, From: authentication.""" diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index c88c3a5d8d..b417f3c181 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -345,7 +345,8 @@ def _csv_set(value: str) -> Set[str]: def _truthy_env(name: str, default: bool = False) -> bool: - v = os.getenv(name) + # Scoped read: under multiplex os.environ is the DEFAULT profile's allow-all flag. + v = _get_scoped_secret(name) return default if v is None else v.strip().lower() in {"1", "true", "yes", "on"} @@ -391,7 +392,8 @@ class LineAdapter(BasePlatformAdapter): return os.getenv(env) or extra.get(key, default) def allowlist(env: str, key: str) -> Set[str]: - return _csv_set(os.getenv(env, "")) | set(extra.get(key, [])) + # Scoped read: under multiplex os.environ is the DEFAULT profile's allowlist. + return _csv_set(_get_scoped_secret(env, "")) | set(extra.get(key, [])) self.channel_access_token, self.channel_secret = _credentials(config) # Host ``None`` → dual-stack bind (see DEFAULT_HOST); empty string collapses to None. diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 707d4035e4..02e3d8e0af 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -499,7 +499,8 @@ def _extra_csv_set(config, key: str, env_name: str) -> Set[str]: """Resolve a room/user list from config.extra[key], else the env var.""" raw = config.extra.get(key) if raw is None: - raw = os.getenv(env_name, "") + # Scoped read: under multiplex os.environ is the DEFAULT profile's room/user list. + raw = _startup_env_secret(env_name) return _csv_set(raw) @@ -882,10 +883,12 @@ class MatrixAdapter(BasePlatformAdapter): self._approval_timeout_seconds = _env_number("MATRIX_APPROVAL_TIMEOUT_SECONDS", 300, int) self._model_picker_prompts_by_event: Dict[str, _MatrixPickerPrompt] = {} self._choice_picker_prompts_by_event: Dict[str, _MatrixPickerPrompt] = {} - self._allowed_user_ids: Set[str] = _csv_set(os.getenv("MATRIX_ALLOWED_USERS", "")) + # Authz lists via the scoped reader: under multiplex os.environ is the DEFAULT profile's + # allowlist, which must not decide who approves tool calls on a secondary bot. + self._allowed_user_ids: Set[str] = _csv_set(_startup_env_secret("MATRIX_ALLOWED_USERS")) self._allowed_room_ids: Set[str] = set(self._allowed_rooms) self._ignored_user_patterns: list[re.Pattern[str]] = [] - for pattern in (p.strip() for p in os.getenv("MATRIX_IGNORE_USER_PATTERNS", "").split(",") if p.strip()): + for pattern in (p.strip() for p in _startup_env_secret("MATRIX_IGNORE_USER_PATTERNS").split(",") if p.strip()): try: self._ignored_user_patterns.append(re.compile(pattern)) except re.error as exc: @@ -2410,7 +2413,8 @@ class MatrixAdapter(BasePlatformAdapter): def _is_authorized_user(self, user_id: str) -> bool: """GATEWAY_ALLOW_ALL_USERS, or membership in MATRIX_ALLOWED_USERS.""" - return _env_truthy("GATEWAY_ALLOW_ALL_USERS") or bool( + # Scoped read — the DEFAULT profile's os.environ opt-in must not authorize on a secondary bot. + return _startup_env_secret("GATEWAY_ALLOW_ALL_USERS").lower() in ("true", "1", "yes") or bool( self._allowed_user_ids and user_id in self._allowed_user_ids) async def _validate_matrix_prompt_reactor( diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index f13d8141c6..26cc5cbe78 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -35,6 +35,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[3])) from agent.secret_scope import UnscopedSecretError, get_secret from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret from gateway.platforms.base import ( gateway_trust_env, BasePlatformAdapter, SendResult, SUPPORTED_DOCUMENT_TYPES, SUPPORTED_VIDEO_TYPES, _TEXT_INJECT_EXTENSIONS, @@ -2542,7 +2543,8 @@ class SlackAdapter(SlackWisdomMixin, BasePlatformAdapter): def _slack_allow_bots(self) -> str: """Return normalized Slack bot-message policy.""" - raw = self.config.extra.get("allow_bots", "") or os.getenv("SLACK_ALLOW_BOTS", "none") + # Scoped read: under multiplex os.environ is the DEFAULT profile's bot-admission policy. + raw = self.config.extra.get("allow_bots", "") or _get_scoped_secret("SLACK_ALLOW_BOTS", "none") value = str(raw).lower().strip() if value not in {"none", "mentions", "all"}: logger.warning("[Slack] Unknown allow_bots=%r; treating as 'none'", raw) @@ -2564,7 +2566,7 @@ class SlackAdapter(SlackWisdomMixin, BasePlatformAdapter): if cached is None: raw = self.config.extra.get("api_human_users") if raw is None: - raw = os.getenv("SLACK_API_HUMAN_USERS", "") + raw = _get_scoped_secret("SLACK_API_HUMAN_USERS", "") parts = raw if isinstance(raw, (list, tuple, set)) else str(raw).split(",") cached = self._api_human_users_cache = frozenset( str(p).strip() for p in parts if str(p).strip()) diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index fad47e449c..0818b907f7 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -605,9 +605,10 @@ class TeamsAdapter(BasePlatformAdapter): """Default-deny gate for approval clicks: require TEAMS_ALLOWED_USERS or an explicit TEAMS_ALLOW_ALL_USERS=true opt-in, else anyone who can message the bot could approve. Returns the user-facing denial text, or ``None`` when allowed.""" - if os.getenv("TEAMS_ALLOW_ALL_USERS", "").strip().lower() in {"1", "true", "yes"}: + # Scoped reads: under multiplex os.environ is the DEFAULT profile's allow-all/allowlist. + if _get_scoped_secret("TEAMS_ALLOW_ALL_USERS", "").strip().lower() in {"1", "true", "yes"}: return None - allowed_csv = os.getenv("TEAMS_ALLOWED_USERS", "").strip() + allowed_csv = _get_scoped_secret("TEAMS_ALLOWED_USERS", "").strip() if not allowed_csv: logger.warning( "[teams] card action rejected: TEAMS_ALLOWED_USERS not configured " diff --git a/tests/gateway/test_adapter_authz_secret_scope.py b/tests/gateway/test_adapter_authz_secret_scope.py new file mode 100644 index 0000000000..3444246b88 --- /dev/null +++ b/tests/gateway/test_adapter_authz_secret_scope.py @@ -0,0 +1,131 @@ +"""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 + + return SimpleNamespace(_dm_allowlist_source="WHATSAPP_ALLOWED_USERS", _allow_from=set(), + _coerce_allow_list=WhatsAppBehaviorMixin._coerce_allow_list) + + +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(SimpleNamespace()), + 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 diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 4eb5adc975..979a4ed99d 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -236,7 +236,11 @@ run under another profile's sandbox policy. The media-delivery credential guard (the denylist behind `MEDIA:` attachments — `.env`, `auth.json`, `config.yaml`, `state.db`, session transcripts, OAuth token stores) covers every profile under `profiles/`, so no profile's turn can attach another profile's -secrets or chat history to a reply. Kanban, +secrets or chat history to a reply. Authorization is per profile too: +`GATEWAY_ALLOW_ALL_USERS`, `GATEWAY_ALLOWED_USERS` and every platform allowlist +or allow-all opt-in are read from the owning profile's `.env` — the default +profile opting into open access never opens a secondary profile's bot, and a +secondary that opts in only in its own `.env` is honored. Kanban, profile-scoped skills/memory/SOUL, and model routing all behave per-profile exactly as they do with separate gateways.