fix(gateway): secondary-profile adapters no longer inherit the default's allow-all / allowlists

Under gateway.multiplex_profiles, os.environ holds the DEFAULT profile's .env. Several
adapter-owned authorization gates still read GATEWAY_ALLOW_ALL_USERS, GATEWAY_ALLOWED_USERS
or their platform allowlist/allow-all raw from os.environ, so the default profile opting
into open access opened every secondary email/QQ/WhatsApp/Matrix/Teams/Slack/LINE/DingTalk
bot to any sender (email additionally skipped From: authentication), the default's Matrix
allowlist decided who may approve tool calls on a secondary bot, and a secondary that
opted in only in its own .env was silently deny-all.

Every such read now goes through the adapter's existing module-local scoped reader
(gateway.platforms._shared.get_scoped_secret / matrix _startup_env_secret): profile
scope first, scoped miss = default, never os.environ; the unscoped default-profile and
single-profile paths keep the environ read, where it IS the profile's own value.

Sites: email _allow_all_senders/_allowlist_in_effect; qqbot _open_dm_opted_in;
whatsapp_common _open_dm_opted_in/_live_dm_allow_from; teams _card_action_denied;
matrix _is_authorized_user, MATRIX_ALLOWED_USERS, MATRIX_IGNORE_USER_PATTERNS,
_extra_csv_set (allowed/free-response rooms); slack _slack_allow_bots/_slack_api_human_users;
line _truthy_env/allowlist (allow-all, user/group/room allowlists); dingtalk _extra_get
(allowed_users/chats, free-response chats, require_mention).

Live repro (temp HERMES_HOME, multiplex on, default env GATEWAY_ALLOW_ALL_USERS=true,
secondary scope without opt-in): EmailAdapter._allow_all_senders() True -> False,
QQAdapter._open_dm_opted_in() True -> False, Matrix _is_authorized_user('@stranger')
True -> False, Teams card action allowed -> denied.

Co-authored-by: Drexuxux <drexux0@gmail.com>
Co-authored-by: MoonsvnLyn <FirmamentalSpring@users.noreply.github.com>
Co-authored-by: svector-anu <anuoluwakolapo94@gmail.com>
Co-authored-by: babatorik <durgun.ismail@gmail.com>
Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
This commit is contained in:
Teknium
2026-09-10 11:52:05 -07:00
parent 94fb74fa97
commit cbd03e6e4c
10 changed files with 178 additions and 22 deletions
+4 -2
View File
@@ -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":
+8 -4
View File
@@ -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:
+4 -2
View File
@@ -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."""
+7 -3
View File
@@ -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."""
+4 -2
View File
@@ -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.
+8 -4
View File
@@ -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(
+4 -2
View File
@@ -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())
+3 -2
View File
@@ -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 "
@@ -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
@@ -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.