fix(gateway): per-profile Discord/Telegram allow-deny gates under multiplex_profiles

Under gateway.multiplex_profiles, Discord and Telegram authorization gates
(allowed/ignored channels, allowed users/roles, allow-all flags) were read
from process-global os.environ, populated first-writer-wins by the YAML->env
bridge in each adapter's _apply_yaml_config. The first profile to initialize
pinned its allow/deny lists — and its ALLOW_ALL flags — for every other
profile in the process (issue #72348, incl. the Telegram mirror reported in
the thread).

Fix (per-adapter-instance gate reads, whole class):

- gateway/authz_mixin.py: new _platform_gate_env — scope-authoritative gate
  read: under an installed profile secret scope with multiplex active, a
  missing key returns the default instead of falling through to os.environ
  (which may hold another profile's value). Single-profile behavior is
  byte-identical to os.getenv.
- Discord adapter:
  - connect() snapshots all gate env vars (_GATE_ENV_KEYS) inside the owning
    profile's runtime scope into a per-adapter dict; new accessors
    (_get_allowed_channels/_get_ignored_channels/_get_allowed_users/
    _get_allowed_roles/_get_no_thread_channels/_discord_allow_all_users/
    _gateway_allow_all_users/_get_allow_bots) resolve snapshot -> config.extra
    -> scope-aware env, replacing every raw os.getenv gate read: on_message
    channel gates, _is_allowed_user allow-all flags, slash authorization,
    fail-closed diagnostics, missed-message backfill, bot-message gating,
    and _component_check_auth (component buttons).
  - _apply_yaml_config always seeds gate values into PlatformConfig.extra
    (incl. new allowed_roles / allow_all_users keys) and SKIPS the
    process-global env writes when loading a profile-scoped config under
    multiplex; the legacy first-writer env bridge is preserved verbatim for
    single-profile deployments.
  - _resolve_allowed_usernames no longer unconditionally rewrites
    os.environ[DISCORD_ALLOWED_USERS] — under multiplex the resolved IDs stay
    adapter-local (snapshot refresh); single-profile keeps the env rewrite.
- Telegram adapter (mirror of the same class): intake prefilter and
  callback-auth fallbacks, _telegram_auth_env_configured, and the
  allowed/ignored chats-topics-threads getters now read via the scoped gate
  reader; _apply_yaml_config skips authorization env writes for
  profile-scoped loads and seeds free_response_chats/ignored_threads extras.

Regression tests (tests/plugins/platforms/test_discord_gate_isolation.py):
two adapter instances with different allow-lists enforce their OWN lists
order-independently across message, slash, and component gates; negative
allow-all case proves profile A's open-access flag cannot authorize profile
B; username-resolution env-clobber; YAML-bridge seeding/skip matrix; and the
Telegram scoped-reader matrix. Sabotage-verified: reverting either the
Discord snapshot accessors or the Telegram scoped reader fails 12/2 tests
respectively.

Credit: builds on the per-adapter accessor direction of PR #72427
(@JonthanaHanh) and the scope-aware-reader approach validated live on v0.19.0
by @yournetworkplug-ctrl for the Telegram mirror; scope corrections from
jackjin1997's and cal88's analysis in the issue thread (allow-all flags,
unguarded username-resolution env write, per-site channel reads).

Fixes #72348
This commit is contained in:
Teknium 2026-08-01 00:04:55 -07:00
parent 8babfe95b4
commit 81c0691e17
4 changed files with 786 additions and 84 deletions

View File

@ -43,6 +43,35 @@ def _auth_env(name: str, default: str = "") -> str:
return (os.getenv(name) or default).strip()
def _platform_gate_env(name: str, default: str = "") -> str:
"""Read a platform allow/deny gate env var with per-profile isolation.
Like ``_auth_env`` but authoritative under multiplex: when a profile
secret scope is installed AND multiplexing is active, a key absent from
the scope returns ``default`` instead of falling through to
``os.environ``. Under multiplex the process env may hold ANOTHER
profile's first-writer-bridged value (the YAML→env bridges in the
Discord/Telegram adapters' ``_apply_yaml_config`` are first-writer-wins),
so falling through would leak profile A's allowlist into profile B
(issue #72348). Single-profile deployments — no scope installed, or
multiplex off behave exactly like the legacy ``os.getenv`` read.
"""
if not name:
return default
try:
from agent.secret_scope import current_secret_scope, is_multiplex_active
scope = current_secret_scope()
if scope is not None and is_multiplex_active():
val = scope.get(name)
if val is None:
return default
return str(val).strip()
except Exception:
pass
return (os.getenv(name) or default).strip()
def _coerce_allow_set(raw) -> set[str]:
"""Parse allowlist values from config or env var into a set of strings.

View File

@ -345,6 +345,68 @@ def _clean_discord_id(entry: str) -> str:
return entry.strip()
# ── per-profile gate env reads (issue #72348) ────────────────────────────
# Under gateway.multiplex_profiles, os.environ is process-global and the
# YAML→env bridge in _apply_yaml_config is first-writer-wins, so a raw
# os.getenv() on an allow/deny gate can return ANOTHER profile's value.
# _scoped_gate_env reads the active profile's secret scope when one is
# installed (secondary adapters connect — and their discord.py event tasks
# are created — inside _profile_runtime_scope, so the contextvar propagates)
# and falls back to os.getenv only outside multiplex.
# Authorization/gate env vars snapshotted per-adapter at connect() time.
_GATE_ENV_KEYS = (
"DISCORD_ALLOWED_USERS",
"DISCORD_ALLOWED_ROLES",
"DISCORD_ALLOWED_CHANNELS",
"DISCORD_IGNORED_CHANNELS",
"DISCORD_NO_THREAD_CHANNELS",
"DISCORD_FREE_RESPONSE_CHANNELS",
"DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS",
"DISCORD_ALLOW_ALL_USERS",
"DISCORD_ALLOW_BOTS",
"GATEWAY_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
)
def _scoped_gate_env(name: str, default: str = "") -> str:
"""Scope-aware gate env read: profile secret scope first under multiplex."""
try:
from gateway.authz_mixin import _platform_gate_env
return _platform_gate_env(name, default)
except Exception:
return (os.getenv(name) or default).strip()
def _multiplex_active() -> bool:
"""True when the gateway is running in multiplex_profiles mode."""
try:
from agent.secret_scope import is_multiplex_active
return bool(is_multiplex_active())
except Exception:
return False
def _profile_scoped_config_load() -> bool:
"""True when the current config load belongs to a multiplexed profile.
Secondary profile configs load inside ``_profile_runtime_scope`` (secret
scope installed + multiplex active). In that case the YAMLenv bridge in
``_apply_yaml_config`` must NOT write process-global env vars: the values
belong to one profile only and the first-writer-wins guard would pin them
for every other profile (issue #72348).
"""
try:
from agent.secret_scope import current_secret_scope, is_multiplex_active
return bool(is_multiplex_active() and current_secret_scope() is not None)
except Exception:
return False
def check_discord_requirements() -> bool:
"""Check if Discord dependencies are available.
@ -918,6 +980,10 @@ class DiscordAdapter(BasePlatformAdapter):
self._ready_event = asyncio.Event()
self._allowed_user_ids: set = set() # For button approval authorization
self._allowed_role_ids: set = set() # For DISCORD_ALLOWED_ROLES filtering
# Per-adapter snapshot of authorization gate env vars, captured inside
# the owning profile's runtime scope during connect(). None until then;
# accessors fall back to live scope-aware reads (issue #72348).
self._gate_env_snapshot: Optional[Dict[str, str]] = None
self.gateway_runner = None # Set by gateway/run.py for cross-platform delivery
# Voice channel state (per-guild)
self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient
@ -1154,22 +1220,18 @@ class DiscordAdapter(BasePlatformAdapter):
if not self._acquire_platform_lock('discord-bot-token', self.config.token, 'Discord bot token'):
return False
# Snapshot this profile's gate env vars (issue #72348): connect()
# runs inside the owning profile's runtime scope under multiplex,
# so the snapshot holds THIS adapter's values, immune to the
# first-writer-wins process-global env bridge.
self._snapshot_gate_env()
# Parse allowed user entries (may contain usernames or IDs)
allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
if allowed_env:
self._allowed_user_ids = {
_clean_discord_id(uid) for uid in allowed_env.split(",")
if uid.strip()
}
self._allowed_user_ids = self._get_allowed_users()
# Parse DISCORD_ALLOWED_ROLES — comma-separated role IDs.
# Users with ANY of these roles can interact with the bot.
roles_env = os.getenv("DISCORD_ALLOWED_ROLES", "")
if roles_env:
self._allowed_role_ids = {
int(rid.strip()) for rid in roles_env.split(",")
if rid.strip().isdigit()
}
self._allowed_role_ids = self._get_allowed_roles()
# Set up intents.
# Message Content is required for normal text replies.
@ -1341,7 +1403,7 @@ class DiscordAdapter(BasePlatformAdapter):
role_authorized = False
if getattr(message.author, "bot", False):
allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
allow_bots = self._get_allow_bots()
if allow_bots == "none":
return False, False
if allow_bots == "mentions" and not self._self_is_explicitly_mentioned(message):
@ -2016,13 +2078,9 @@ class DiscordAdapter(BasePlatformAdapter):
raw = str(raw or "")
if raw.strip():
return {item.strip() for item in raw.split(",") if item.strip()}
raw = os.getenv("DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", "")
raw = self._gate_env("DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS")
if not raw.strip():
allowed = {
item.strip()
for item in os.getenv("DISCORD_ALLOWED_CHANNELS", "").split(",")
if item.strip()
}
allowed = self._get_allowed_channels()
return allowed | self._discord_free_response_channels()
return {item.strip() for item in raw.split(",") if item.strip()}
@ -4415,10 +4473,9 @@ class DiscordAdapter(BasePlatformAdapter):
"""True when *channel_ids* intersect ``DISCORD_ALLOWED_CHANNELS``."""
if not channel_ids:
return False
allowed_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "").strip()
if not allowed_raw:
allowed = self._get_allowed_channels()
if not allowed:
return False
allowed = {c.strip() for c in allowed_raw.split(",") if c.strip()}
if "*" in allowed:
return True
return bool(channel_ids & allowed)
@ -4484,9 +4541,9 @@ class DiscordAdapter(BasePlatformAdapter):
return True
if not has_users and not has_roles:
if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
if self._discord_allow_all_users():
return True
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
if self._gateway_allow_all_users():
return True
# Channel-scoped guild access requires validated channel context.
# Do not treat DISCORD_ALLOWED_CHANNELS alone as a user-wide bypass
@ -4559,11 +4616,11 @@ class DiscordAdapter(BasePlatformAdapter):
allowed_roles = getattr(self, "_allowed_role_ids", set()) or set()
if allowed_users or allowed_roles:
return
if os.getenv("DISCORD_ALLOWED_CHANNELS", "").strip():
if self._get_allowed_channels():
return
if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
if self._discord_allow_all_users():
return
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
if self._gateway_allow_all_users():
return
self._warned_fail_closed_default = True
logger.warning(
@ -4642,9 +4699,8 @@ class DiscordAdapter(BasePlatformAdapter):
else None,
)
allowed_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "")
if allowed_raw:
allowed = {c.strip() for c in allowed_raw.split(",") if c.strip()}
allowed = self._get_allowed_channels()
if allowed:
if "*" not in allowed:
if not channel_ids:
# Channel policy is configured but the interaction
@ -4659,9 +4715,8 @@ class DiscordAdapter(BasePlatformAdapter):
# Ignored beats allowed: even when a thread's parent channel
# is on the allowlist, an explicit DISCORD_IGNORED_CHANNELS
# entry on the thread or its parent rejects the interaction.
ignored_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "")
if ignored_raw and channel_ids:
ignored = {c.strip() for c in ignored_raw.split(",") if c.strip()}
ignored = self._get_ignored_channels()
if ignored and channel_ids:
if "*" in ignored or (channel_keys & ignored):
return (False, "channel in DISCORD_IGNORED_CHANNELS")
@ -5188,9 +5243,19 @@ class DiscordAdapter(BasePlatformAdapter):
if to_resolve:
print(f"[{self.name}] Could not resolve usernames: {', '.join(to_resolve)}")
# Update internal set and env var so gateway auth checks use IDs
# Update the internal set. Keep the resolved IDs adapter-local first:
# under multiplex_profiles, writing os.environ here would clobber
# every OTHER profile's DISCORD_ALLOWED_USERS after this adapter's
# on_ready — an unguarded runtime mutation of process-global state
# (issue #72348). Refresh this adapter's own snapshot instead.
self._allowed_user_ids = numeric_ids
os.environ["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids))
snap = getattr(self, "_gate_env_snapshot", None)
if snap is not None:
snap["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids))
if not _multiplex_active():
# Single-profile: preserve the legacy env rewrite so the gateway's
# env-based auth checks match the resolved numeric IDs.
os.environ["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids))
if resolved_count:
print(f"[{self.name}] Updated DISCORD_ALLOWED_USERS with {resolved_count} resolved ID(s)")
@ -6034,6 +6099,99 @@ class DiscordAdapter(BasePlatformAdapter):
and getattr(att, "waveform", None) is not None
)
# ── per-adapter authorization gates (issue #72348) ───────────────────
# Under gateway.multiplex_profiles every Discord adapter must enforce
# ITS OWN profile's allow/deny lists. os.environ is process-global and
# the YAML→env bridge is first-writer-wins, so raw os.getenv reads here
# would leak profile A's gates into profile B. Each accessor reads, in
# order: the per-adapter env snapshot taken inside the owning profile's
# runtime scope at connect() (authoritative under multiplex), then this
# adapter's PlatformConfig.extra (per-profile YAML), with the live
# scope-aware env read as the pre-connect fallback. Single-profile
# deployments resolve to plain os.getenv, unchanged.
def _snapshot_gate_env(self) -> None:
"""Capture authorization env vars for THIS adapter's profile.
Must be called inside the owning profile's runtime scope (connect()
runs there under multiplex) so the snapshot holds the profile's own
values, not whichever profile bridged os.environ first.
"""
self._gate_env_snapshot = {
key: _scoped_gate_env(key) for key in _GATE_ENV_KEYS
}
def _gate_env(self, name: str, default: str = "") -> str:
"""Read a gate env var from this adapter's snapshot (scope fallback)."""
snap = getattr(self, "_gate_env_snapshot", None)
if snap is not None and name in snap:
return snap[name] or default
return _scoped_gate_env(name, default)
def _gate_raw(self, extra_key: str, env_key: str):
"""Resolve one gate value: env/snapshot first (legacy precedence), then extra."""
val = self._gate_env(env_key)
if val:
return val
extra = getattr(getattr(self, "config", None), "extra", None)
if isinstance(extra, dict):
return extra.get(extra_key)
return None
@staticmethod
def _gate_csv_set(raw) -> set:
if raw is None:
return set()
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
def _get_allowed_channels(self) -> set:
"""This adapter's DISCORD_ALLOWED_CHANNELS gate (per-profile)."""
return self._gate_csv_set(self._gate_raw("allowed_channels", "DISCORD_ALLOWED_CHANNELS"))
def _get_ignored_channels(self) -> set:
"""This adapter's DISCORD_IGNORED_CHANNELS gate (per-profile)."""
return self._gate_csv_set(self._gate_raw("ignored_channels", "DISCORD_IGNORED_CHANNELS"))
def _get_no_thread_channels(self) -> set:
"""This adapter's DISCORD_NO_THREAD_CHANNELS list (per-profile)."""
return self._gate_csv_set(self._gate_raw("no_thread_channels", "DISCORD_NO_THREAD_CHANNELS"))
def _get_allowed_users(self) -> set:
"""This adapter's DISCORD_ALLOWED_USERS entries (per-profile, cleaned)."""
raw = self._gate_raw("allow_from", "DISCORD_ALLOWED_USERS")
if raw is None:
extra = getattr(getattr(self, "config", None), "extra", None)
if isinstance(extra, dict):
raw = extra.get("allowed_users")
return {
_clean_discord_id(str(entry))
for entry in self._gate_csv_set(raw)
if _clean_discord_id(str(entry))
}
def _get_allowed_roles(self) -> set:
"""This adapter's DISCORD_ALLOWED_ROLES role IDs (per-profile)."""
raw = self._gate_raw("allowed_roles", "DISCORD_ALLOWED_ROLES")
return {
int(str(entry).strip()) for entry in self._gate_csv_set(raw)
if str(entry).strip().isdigit()
}
def _discord_allow_all_users(self) -> bool:
"""Per-profile DISCORD_ALLOW_ALL_USERS flag."""
raw = self._gate_raw("allow_all_users", "DISCORD_ALLOW_ALL_USERS")
return str(raw or "").strip().lower() in {"true", "1", "yes"}
def _gateway_allow_all_users(self) -> bool:
"""Per-profile GATEWAY_ALLOW_ALL_USERS flag."""
return self._gate_env("GATEWAY_ALLOW_ALL_USERS").strip().lower() in {"true", "1", "yes"}
def _get_allow_bots(self) -> str:
"""Per-profile DISCORD_ALLOW_BOTS mode (none|mentions|all)."""
return self._gate_env("DISCORD_ALLOW_BOTS", "none").lower().strip() or "none"
def _discord_free_response_channels(self) -> set:
"""Return Discord channel IDs/names where no bot mention is required.
@ -6043,7 +6201,7 @@ class DiscordAdapter(BasePlatformAdapter):
"""
raw = self.config.extra.get("free_response_channels")
if raw is None:
raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
raw = self._gate_env("DISCORD_FREE_RESPONSE_CHANNELS")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
# Coerce non-list scalars (str/int/float) to str before splitting.
@ -6243,7 +6401,7 @@ class DiscordAdapter(BasePlatformAdapter):
return ""
# Determine which bot messages to include in context
allow_bots_raw = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
allow_bots_raw = self._get_allow_bots()
include_other_bots = allow_bots_raw != "none"
# Use the in-memory cache to narrow the fetch window on hot paths.
@ -7451,16 +7609,14 @@ class DiscordAdapter(BasePlatformAdapter):
channel_keys = self._discord_channel_keys(message, parent_channel_id)
# Check allowed channels - if set, only respond in these channels
allowed_channels_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "")
if allowed_channels_raw:
allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()}
allowed_channels = self._get_allowed_channels()
if allowed_channels:
if "*" not in allowed_channels and not (channel_keys & allowed_channels):
logger.debug("[%s] Ignoring message in non-allowed channel: %s", self.name, channel_keys)
return False
# Check ignored channels - never respond even when mentioned
ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "")
ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()}
ignored_channels = self._get_ignored_channels()
if "*" in ignored_channels or (channel_keys & ignored_channels):
logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_keys)
return False
@ -7499,8 +7655,7 @@ class DiscordAdapter(BasePlatformAdapter):
# no_thread_channels: channels where bot responds directly without thread.
auto_threaded_channel = None
if not is_thread and not isinstance(message.channel, discord.DMChannel):
no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
no_thread_channels = self._get_no_thread_channels()
skip_thread = bool(channel_keys & no_thread_channels) or is_free_channel
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"}
is_reply_message = getattr(message, "type", None) == discord.MessageType.reply
@ -8010,15 +8165,20 @@ def _component_check_auth(
if user is None or getattr(user, "id", None) is None:
return False
if os.getenv("DISCORD_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
# Scope-aware reads (issue #72348): component interactions are dispatched
# from discord.py tasks descended from the task created inside the owning
# profile's runtime scope, so the profile's secret-scope contextvar is
# inherited here. Under multiplex a raw os.getenv could return ANOTHER
# profile's allow-all flag and authorize a click on this profile's bot.
if _scoped_gate_env("DISCORD_ALLOW_ALL_USERS").strip().lower() in {"true", "1", "yes"}:
return True
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"true", "1", "yes"}:
if _scoped_gate_env("GATEWAY_ALLOW_ALL_USERS").strip().lower() in {"true", "1", "yes"}:
return True
user_set = {str(uid).strip() for uid in (allowed_user_ids or set()) if str(uid).strip()}
global_allowed = {
uid.strip()
for uid in os.getenv("GATEWAY_ALLOWED_USERS", "").split(",")
for uid in _scoped_gate_env("GATEWAY_ALLOWED_USERS").split(",")
if uid.strip()
}
user_set.update(global_allowed)
@ -9671,14 +9831,42 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
candidate_extra = discord_platform_cfg.get("extra")
if isinstance(candidate_extra, dict):
platform_extra_cfg = candidate_extra
seeded_extra = {}
# Authorization gate keys are ALWAYS seeded into PlatformConfig.extra so
# every adapter carries its own profile's allow/deny lists (issue #72348).
# The os.environ writes below remain first-writer-wins for legacy env-only
# consumers, but are skipped for profile-scoped loads under multiplex —
# a secondary profile's gates must never land in process-global env where
# they'd become another profile's policy.
_skip_env_bridge = _profile_scoped_config_load()
allowed_users_cfg = (
discord_cfg["allow_from"] if "allow_from" in discord_cfg
else platform_extra_cfg.get("allow_from")
)
if allowed_users_cfg is not None and not os.getenv("DISCORD_ALLOWED_USERS"):
if allowed_users_cfg is not None:
if isinstance(allowed_users_cfg, list):
allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg)
os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg)
seeded_extra["allow_from"] = str(allowed_users_cfg)
if not _skip_env_bridge and not os.getenv("DISCORD_ALLOWED_USERS"):
os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg)
allowed_roles_cfg = (
discord_cfg["allowed_roles"] if "allowed_roles" in discord_cfg
else platform_extra_cfg.get("allowed_roles")
)
if allowed_roles_cfg is not None:
if isinstance(allowed_roles_cfg, list):
allowed_roles_cfg = ",".join(str(v) for v in allowed_roles_cfg)
seeded_extra["allowed_roles"] = str(allowed_roles_cfg)
if not _skip_env_bridge and not os.getenv("DISCORD_ALLOWED_ROLES"):
os.environ["DISCORD_ALLOWED_ROLES"] = str(allowed_roles_cfg)
allow_all_cfg = (
discord_cfg["allow_all_users"] if "allow_all_users" in discord_cfg
else platform_extra_cfg.get("allow_all_users")
)
if allow_all_cfg is not None:
seeded_extra["allow_all_users"] = str(allow_all_cfg).lower()
if not _skip_env_bridge and not os.getenv("DISCORD_ALLOW_ALL_USERS"):
os.environ["DISCORD_ALLOW_ALL_USERS"] = str(allow_all_cfg).lower()
approval_mentions_cfg = (
discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg
else platform_extra_cfg.get("approval_mentions")
@ -9686,36 +9874,43 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"):
os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower()
frc = discord_cfg.get("free_response_channels")
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
if frc is not None:
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
seeded_extra["free_response_channels"] = str(frc)
if not _skip_env_bridge and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
seeded_extra = {}
backfill_cfg = discord_cfg.get("missed_message_backfill")
if isinstance(backfill_cfg, dict):
seeded_extra["missed_message_backfill"] = dict(backfill_cfg)
# ignored_channels: channels where bot never responds (even when mentioned)
ic = discord_cfg.get("ignored_channels")
if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
if ic is not None:
if isinstance(ic, list):
ic = ",".join(str(v) for v in ic)
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
seeded_extra["ignored_channels"] = str(ic)
if not _skip_env_bridge and not os.getenv("DISCORD_IGNORED_CHANNELS"):
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
ac = discord_cfg.get("allowed_channels")
if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
if ac is not None:
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
seeded_extra["allowed_channels"] = str(ac)
if not _skip_env_bridge and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
# no_thread_channels: channels where bot responds directly without creating thread
ntc = discord_cfg.get("no_thread_channels")
if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
if ntc is not None:
if isinstance(ntc, list):
ntc = ",".join(str(v) for v in ntc)
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
seeded_extra["no_thread_channels"] = str(ntc)
if not _skip_env_bridge and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
# history_backfill: recover missed channel messages for shared sessions
# when require_mention is active. Fetches messages between bot turns
# and prepends them to the user message for context.

View File

@ -38,6 +38,23 @@ def _redact_telegram_error_text(error: object) -> str:
return "<telegram error redacted>"
def _scoped_gate_env(name: str, default: str = "") -> str:
"""Read a TELEGRAM_*/GATEWAY_* authorization gate env var per-profile.
Under gateway.multiplex_profiles the process env is first-writer-wins
(the YAMLenv bridge in ``_apply_yaml_config``), so a raw ``os.getenv``
can return ANOTHER profile's allowlist (issue #72348, Telegram mirror).
Reads the active profile's secret scope when installed; falls back to
``os.getenv`` outside multiplex identical single-profile behavior.
"""
try:
from gateway.authz_mixin import _platform_gate_env
return _platform_gate_env(name, default)
except Exception:
return (os.getenv(name) or default).strip()
def _consume_abandoned_task(task: asyncio.Task) -> None:
"""Observe a detached task's terminal exception to avoid noisy loop logs."""
try:
@ -947,13 +964,13 @@ class TelegramAdapter(BasePlatformAdapter):
exc_info=True,
)
allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip()
allowed_csv = _scoped_gate_env("TELEGRAM_ALLOWED_USERS").strip()
if not allowed_csv:
# Fail-closed: no allowlist means deny by default.
# The runner auth path in _is_user_authorized() handles
# GATEWAY_ALLOW_ALL_USERS; this fallback must not silently
# allow everyone (fixes #24457).
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
return _scoped_gate_env("GATEWAY_ALLOW_ALL_USERS").lower() in {"true", "1", "yes"}
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
return "*" in allowed_ids or normalized_user_id in allowed_ids
@ -1027,7 +1044,7 @@ class TelegramAdapter(BasePlatformAdapter):
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
)
return any(os.getenv(key, "").strip() for key in keys)
return any(_scoped_gate_env(key).strip() for key in keys)
def _should_pass_unauthorized_dm_for_pairing(self, source) -> bool:
"""Return True when an unauthorized DM must still reach gateway pairing.
@ -1137,7 +1154,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
if authorized is None:
allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip()
allowed_csv = _scoped_gate_env("TELEGRAM_ALLOWED_USERS").strip()
if not allowed_csv:
return True
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
@ -7771,7 +7788,7 @@ class TelegramAdapter(BasePlatformAdapter):
def _telegram_free_response_chats(self) -> set[str]:
raw = self.config.extra.get("free_response_chats")
if raw is None:
raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "")
raw = _scoped_gate_env("TELEGRAM_FREE_RESPONSE_CHATS")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
@ -7785,7 +7802,7 @@ class TelegramAdapter(BasePlatformAdapter):
"""
raw = self.config.extra.get("free_response_topics")
if raw is None:
raw = os.getenv("TELEGRAM_FREE_RESPONSE_TOPICS", "")
raw = _scoped_gate_env("TELEGRAM_FREE_RESPONSE_TOPICS")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
@ -7812,7 +7829,7 @@ class TelegramAdapter(BasePlatformAdapter):
"""
raw = self.config.extra.get("allowed_chats")
if raw is None:
raw = os.getenv("TELEGRAM_ALLOWED_CHATS", "")
raw = _scoped_gate_env("TELEGRAM_ALLOWED_CHATS")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
@ -7821,7 +7838,7 @@ class TelegramAdapter(BasePlatformAdapter):
"""Return Telegram chats authorized at group scope."""
raw = self.config.extra.get("group_allowed_chats")
if raw is None:
raw = os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS", "")
raw = _scoped_gate_env("TELEGRAM_GROUP_ALLOWED_CHATS")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
@ -7851,7 +7868,7 @@ class TelegramAdapter(BasePlatformAdapter):
"""
raw = self.config.extra.get("allowed_topics")
if raw is None:
raw = os.getenv("TELEGRAM_ALLOWED_TOPICS", "")
raw = _scoped_gate_env("TELEGRAM_ALLOWED_TOPICS")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
return {part.strip() for part in str(raw).split(",") if part.strip()}
@ -7859,7 +7876,7 @@ class TelegramAdapter(BasePlatformAdapter):
def _telegram_ignored_threads(self) -> set[int]:
raw = self.config.extra.get("ignored_threads")
if raw is None:
raw = os.getenv("TELEGRAM_IGNORED_THREADS", "")
raw = _scoped_gate_env("TELEGRAM_IGNORED_THREADS")
if isinstance(raw, list):
values = raw
@ -9955,6 +9972,18 @@ def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None:
import json as _json
extras: dict = {}
# Under multiplex, a secondary profile's config loads inside its runtime
# scope; its authorization gate values must NOT be written to the
# process-global env, where first-writer-wins would pin them for every
# other profile (issue #72348 Telegram mirror). They are seeded into
# PlatformConfig.extra / read via the profile secret scope instead.
try:
from agent.secret_scope import current_secret_scope, is_multiplex_active
_skip_env_bridge = bool(is_multiplex_active() and current_secret_scope() is not None)
except Exception:
_skip_env_bridge = False
if "disable_topic_auto_rename" in telegram_cfg:
extras.setdefault("disable_topic_auto_rename", telegram_cfg["disable_topic_auto_rename"])
@ -9972,30 +10001,41 @@ def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None:
if "observe_unmentioned_group_messages" in telegram_cfg and not os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"):
os.environ["TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] = str(telegram_cfg["observe_unmentioned_group_messages"]).lower()
frc = telegram_cfg.get("free_response_chats")
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
if frc is not None:
extras.setdefault("free_response_chats", frc)
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
if not _skip_env_bridge and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
frt = telegram_cfg.get("free_response_topics")
if frt is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_TOPICS"):
if frt is not None:
if isinstance(frt, list):
frt = ",".join(str(v) for v in frt)
os.environ["TELEGRAM_FREE_RESPONSE_TOPICS"] = str(frt)
if not _skip_env_bridge and not os.getenv("TELEGRAM_FREE_RESPONSE_TOPICS"):
os.environ["TELEGRAM_FREE_RESPONSE_TOPICS"] = str(frt)
ac = telegram_cfg.get("allowed_chats")
if ac is not None and not os.getenv("TELEGRAM_ALLOWED_CHATS"):
if ac is not None:
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac)
# NOTE: no extras seed here — gateway/config.py's shared-key loop
# already bridges ``allowed_chats`` into PlatformConfig.extra with its
# original type, and the apply_yaml_config merge would clobber it.
if not _skip_env_bridge and not os.getenv("TELEGRAM_ALLOWED_CHATS"):
os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac)
allowed_topics = telegram_cfg.get("allowed_topics")
if allowed_topics is not None and not os.getenv("TELEGRAM_ALLOWED_TOPICS"):
if allowed_topics is not None:
if isinstance(allowed_topics, list):
allowed_topics = ",".join(str(v) for v in allowed_topics)
os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics)
# extras seed intentionally omitted (shared-key loop bridges allowed_topics).
if not _skip_env_bridge and not os.getenv("TELEGRAM_ALLOWED_TOPICS"):
os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics)
ignored_threads = telegram_cfg.get("ignored_threads")
if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"):
if ignored_threads is not None:
extras.setdefault("ignored_threads", ignored_threads)
if isinstance(ignored_threads, list):
ignored_threads = ",".join(str(v) for v in ignored_threads)
os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads)
if not _skip_env_bridge and not os.getenv("TELEGRAM_IGNORED_THREADS"):
os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads)
if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
@ -10009,20 +10049,24 @@ def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None:
_rtm_str = "off" if _telegram_rtm is False else str(_telegram_rtm).lower()
os.environ["TELEGRAM_REPLY_TO_MODE"] = _rtm_str
allowed_users = telegram_cfg.get("allow_from")
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
if allowed_users is not None:
if isinstance(allowed_users, list):
allowed_users = ",".join(str(v) for v in allowed_users)
os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users)
if not _skip_env_bridge and not os.getenv("TELEGRAM_ALLOWED_USERS"):
os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users)
group_allowed_users = telegram_cfg.get("group_allow_from") or _telegram_extra.get("group_allow_from")
if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
if group_allowed_users is not None:
if isinstance(group_allowed_users, list):
group_allowed_users = ",".join(str(v) for v in group_allowed_users)
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users)
if not _skip_env_bridge and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users)
group_allowed_chats = telegram_cfg.get("group_allowed_chats") or _telegram_extra.get("group_allowed_chats")
if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"):
if group_allowed_chats is not None:
if isinstance(group_allowed_chats, list):
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
# extras seed intentionally omitted (shared-key loop bridges group_allowed_chats).
if not _skip_env_bridge and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"):
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
for _key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages", "free_response_topics"):
if _key in telegram_cfg:
extras.setdefault(_key, telegram_cfg[_key])

View File

@ -0,0 +1,434 @@
"""Per-profile isolation of Discord/Telegram allow/deny gates (issue #72348).
Under ``gateway.multiplex_profiles: true`` every adapter must enforce ITS OWN
profile's allow/deny lists. The historical bugs:
1. First-writer-wins YAMLenv bridge: the first profile's
``_apply_yaml_config`` wrote ``DISCORD_ALLOWED_CHANNELS`` (etc.) into the
process-global ``os.environ``; later profiles' values were dropped.
2. Inbound gates read ``os.getenv`` directly, so every adapter enforced the
FIRST profile's channel/user/role allowlists.
3. Allow-all flags (``DISCORD_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS``)
read from process env: profile A opting in to open access opened profile B.
4. ``_resolve_allowed_usernames`` unconditionally rewrote
``os.environ["DISCORD_ALLOWED_USERS"]`` at runtime.
These tests build two adapter instances with different gate snapshots/extras
and assert each enforces only its own lists, order-independently.
"""
import os
import pytest
from gateway.config import Platform, PlatformConfig
from plugins.platforms.discord.adapter import DiscordAdapter, _GATE_ENV_KEYS
GATE_VARS = [
"DISCORD_ALLOWED_CHANNELS",
"DISCORD_IGNORED_CHANNELS",
"DISCORD_ALLOWED_USERS",
"DISCORD_ALLOWED_ROLES",
"DISCORD_ALLOW_ALL_USERS",
"GATEWAY_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"DISCORD_NO_THREAD_CHANNELS",
"DISCORD_FREE_RESPONSE_CHANNELS",
"DISCORD_ALLOW_BOTS",
]
@pytest.fixture(autouse=True)
def _clean_gate_env(monkeypatch):
for var in GATE_VARS:
monkeypatch.delenv(var, raising=False)
yield
# monkeypatch.delenv on an ABSENT var records nothing, so env writes made
# during the test (e.g. _apply_yaml_config's legacy bridge) would leak
# into later test modules. Scrub explicitly.
for var in GATE_VARS:
os.environ.pop(var, None)
def _adapter(extra: dict | None = None) -> DiscordAdapter:
adapter = object.__new__(DiscordAdapter)
adapter.platform = Platform.DISCORD
adapter.config = PlatformConfig(enabled=True, token="x", extra=dict(extra or {}))
adapter._gate_env_snapshot = None
adapter._allowed_user_ids = set()
adapter._allowed_role_ids = set()
return adapter
def _snapshot(adapter: DiscordAdapter, values: dict) -> None:
"""Simulate the connect()-time per-profile snapshot."""
adapter._gate_env_snapshot = {key: values.get(key, "") for key in _GATE_ENV_KEYS}
class TestTwoAdapterChannelIsolation:
"""Two adapters with different allowed_channels enforce their OWN lists."""
def test_snapshots_isolate_allowed_channels(self):
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_CHANNELS": "111"})
_snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"})
assert a._get_allowed_channels() == {"111"}
assert b._get_allowed_channels() == {"222"}
def test_order_independent(self):
# Reverse construction order — the winner must not change.
b = _adapter()
_snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"})
a = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_CHANNELS": "111"})
assert a._discord_channel_ids_allowed({"111"}) is True
assert a._discord_channel_ids_allowed({"222"}) is False
assert b._discord_channel_ids_allowed({"222"}) is True
assert b._discord_channel_ids_allowed({"111"}) is False
def test_extras_isolate_allowed_channels_without_snapshot(self):
"""Config-extra seeding isolates gates even before connect()."""
a = _adapter({"allowed_channels": "111"})
b = _adapter({"allowed_channels": "222"})
assert a._get_allowed_channels() == {"111"}
assert b._get_allowed_channels() == {"222"}
def test_process_env_does_not_leak_into_snapshotted_adapter(self, monkeypatch):
"""A first-writer process-global env value must not override a
snapshotted adapter's own (empty) gate."""
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "999")
b = _adapter({"allowed_channels": "222"})
_snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"})
assert b._get_allowed_channels() == {"222"}
def test_ignored_channels_isolated(self):
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_IGNORED_CHANNELS": "311"})
_snapshot(b, {"DISCORD_IGNORED_CHANNELS": "322"})
assert a._get_ignored_channels() == {"311"}
assert b._get_ignored_channels() == {"322"}
class TestTwoAdapterUserRoleIsolation:
def test_allowed_users_isolated(self):
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_USERS": "1001,<@1002>"})
_snapshot(b, {"DISCORD_ALLOWED_USERS": "2001"})
assert a._get_allowed_users() == {"1001", "1002"}
assert b._get_allowed_users() == {"2001"}
def test_allowed_roles_isolated(self):
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_ROLES": "31,32"})
_snapshot(b, {"DISCORD_ALLOWED_ROLES": "41"})
assert a._get_allowed_roles() == {31, 32}
assert b._get_allowed_roles() == {41}
def test_is_allowed_user_enforces_own_list(self, monkeypatch):
# Pairing store must not interfere.
monkeypatch.setattr(
DiscordAdapter, "_is_pairing_approved_user", lambda self, uid: False
)
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_USERS": "1001"})
_snapshot(b, {"DISCORD_ALLOWED_USERS": "2001"})
a._allowed_user_ids = a._get_allowed_users()
b._allowed_user_ids = b._get_allowed_users()
assert a._is_allowed_user("1001") is True
assert a._is_allowed_user("2001") is False
assert b._is_allowed_user("2001") is True
assert b._is_allowed_user("1001") is False
class TestAllowAllFlagIsolation:
"""Profile A's allow-all flag must never authorize profile B (negative case)."""
def test_discord_allow_all_isolated(self, monkeypatch):
monkeypatch.setattr(
DiscordAdapter, "_is_pairing_approved_user", lambda self, uid: False
)
open_profile = _adapter()
closed_profile = _adapter()
_snapshot(open_profile, {"DISCORD_ALLOW_ALL_USERS": "true"})
_snapshot(closed_profile, {})
assert open_profile._is_allowed_user("555") is True
assert closed_profile._is_allowed_user("555") is False
def test_env_allow_all_does_not_open_snapshotted_adapter(self, monkeypatch):
"""First-writer env DISCORD_ALLOW_ALL_USERS=true (profile A) must not
open a snapshotted profile B."""
monkeypatch.setattr(
DiscordAdapter, "_is_pairing_approved_user", lambda self, uid: False
)
monkeypatch.setenv("DISCORD_ALLOW_ALL_USERS", "true")
b = _adapter()
_snapshot(b, {}) # profile B: no allow-all, no allowlists
assert b._discord_allow_all_users() is False
assert b._is_allowed_user("555") is False
def test_gateway_allow_all_isolated(self, monkeypatch):
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
b = _adapter()
_snapshot(b, {})
assert b._gateway_allow_all_users() is False
class TestSlashGateIsolation:
"""Slash-command channel gates use per-adapter values too."""
def test_evaluate_slash_channel_gate_per_adapter(self, monkeypatch):
import types
import discord as discord_lib
a = _adapter()
b = _adapter()
_snapshot(a, {"DISCORD_ALLOWED_CHANNELS": "111"})
_snapshot(b, {"DISCORD_ALLOWED_CHANNELS": "222"})
def _keys(self, chan, parent):
return {str(getattr(chan, "id", ""))}
monkeypatch.setattr(
DiscordAdapter, "_discord_channel_keys_from_channel", _keys
)
monkeypatch.setattr(
DiscordAdapter, "_get_parent_channel_id", lambda self, c: None
)
chan = types.SimpleNamespace(id=111)
interaction = types.SimpleNamespace(
channel=chan, channel_id=111, user=types.SimpleNamespace(id=999, roles=[]),
)
# channel 111: allowed for A's gate...
allowed_a, reason_a = a._evaluate_slash_authorization(interaction)
# ...but B must reject it on ITS channel gate.
allowed_b, reason_b = b._evaluate_slash_authorization(interaction)
assert reason_a != "channel not in DISCORD_ALLOWED_CHANNELS"
assert allowed_b is False
assert reason_b == "channel not in DISCORD_ALLOWED_CHANNELS"
class TestUsernameResolutionEnvWrite:
"""_resolve_allowed_usernames must not clobber process env under multiplex."""
@pytest.mark.asyncio
async def test_no_env_write_when_multiplex_active(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "999")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
adapter = _adapter()
_snapshot(adapter, {"DISCORD_ALLOWED_USERS": "teknium"})
adapter._allowed_user_ids = {"teknium"}
member = type(
"M",
(),
{
"id": 12345,
"name": "teknium",
"display_name": "teknium",
"global_name": "teknium",
"discriminator": "0",
},
)()
guild = type(
"G", (), {"members": [member], "member_count": 1, "name": "g"},
)()
adapter._client = type("C", (), {"guilds": [guild]})()
await adapter._resolve_allowed_usernames()
assert adapter._allowed_user_ids == {"12345"}
# Snapshot updated for this adapter only.
assert adapter._gate_env_snapshot["DISCORD_ALLOWED_USERS"] == "12345"
# Process-global env untouched — other profiles unaffected.
assert os.environ["DISCORD_ALLOWED_USERS"] == "999"
@pytest.mark.asyncio
async def test_env_write_preserved_single_profile(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "teknium")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
adapter = _adapter()
adapter._allowed_user_ids = {"teknium"}
member = type(
"M",
(),
{
"id": 12345,
"name": "teknium",
"display_name": "teknium",
"global_name": "teknium",
"discriminator": "0",
},
)()
guild = type(
"G", (), {"members": [member], "member_count": 1, "name": "g"},
)()
adapter._client = type("C", (), {"guilds": [guild]})()
await adapter._resolve_allowed_usernames()
# Legacy single-profile behavior: env rewritten to resolved IDs.
assert os.environ["DISCORD_ALLOWED_USERS"] == "12345"
class TestYamlBridgeSeeding:
"""_apply_yaml_config seeds gates into extra and skips env writes when
loading a profile-scoped config under multiplex."""
def test_seeds_extra_and_bridges_env_single_profile(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.discord.adapter import _apply_yaml_config
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
seeded = _apply_yaml_config(
{},
{
"allowed_channels": ["111", "112"],
"ignored_channels": "333",
"allow_from": ["1001"],
"allowed_roles": [31],
"allow_all_users": False,
},
)
assert seeded["allowed_channels"] == "111,112"
assert seeded["ignored_channels"] == "333"
assert seeded["allow_from"] == "1001"
assert seeded["allowed_roles"] == "31"
assert seeded["allow_all_users"] == "false"
# Legacy env bridge preserved for single-profile deployments.
assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "111,112"
def test_profile_scoped_load_skips_env_bridge(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.discord.adapter import _apply_yaml_config
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({"SOME": "scope"})
try:
seeded = _apply_yaml_config(
{}, {"allowed_channels": "222", "allow_from": "2001"},
)
finally:
secret_scope.reset_secret_scope(token)
# Gates still seeded per-adapter...
assert seeded["allowed_channels"] == "222"
assert seeded["allow_from"] == "2001"
# ...but process-global env stays clean: no cross-profile leak.
assert os.getenv("DISCORD_ALLOWED_CHANNELS") is None
assert os.getenv("DISCORD_ALLOWED_USERS") is None
def test_first_writer_env_does_not_mask_second_profile_extras(self, monkeypatch):
"""End-to-end shape of the original repro: profile A bridges env first;
profile B (scoped load) still gets ITS channels via extras."""
from agent import secret_scope
from plugins.platforms.discord.adapter import _apply_yaml_config
# Profile A: single first load (multiplex flag not yet set).
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
seeded_a = _apply_yaml_config({}, {"allowed_channels": "111"})
assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "111"
# Profile B: scoped load under multiplex.
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({})
try:
seeded_b = _apply_yaml_config({}, {"allowed_channels": "222"})
finally:
secret_scope.reset_secret_scope(token)
a = _adapter(seeded_a)
b = _adapter(seeded_b)
# B's snapshot taken inside its (empty-env) profile scope.
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({})
try:
b._snapshot_gate_env()
finally:
secret_scope.reset_secret_scope(token)
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
a._snapshot_gate_env()
assert a._get_allowed_channels() == {"111"}
assert b._get_allowed_channels() == {"222"}
class TestTelegramGateIsolation:
"""Telegram mirror (reported by @yournetworkplug-ctrl in #72348)."""
def test_scoped_gate_env_prefers_profile_scope(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.telegram.adapter import _scoped_gate_env
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope(
{"TELEGRAM_ALLOWED_USERS": "222222222"}
)
try:
assert _scoped_gate_env("TELEGRAM_ALLOWED_USERS") == "222222222"
finally:
secret_scope.reset_secret_scope(token)
def test_scoped_gate_env_authoritative_scope_miss(self, monkeypatch):
"""Under multiplex, a scope WITHOUT the key must not fall through to
another profile's process-env value."""
from agent import secret_scope
from plugins.platforms.telegram.adapter import _scoped_gate_env
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({})
try:
assert _scoped_gate_env("TELEGRAM_ALLOWED_USERS") == ""
finally:
secret_scope.reset_secret_scope(token)
def test_scoped_gate_env_single_profile_fallback(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.telegram.adapter import _scoped_gate_env
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111111111")
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
assert _scoped_gate_env("TELEGRAM_ALLOWED_USERS") == "111111111"
def test_telegram_yaml_bridge_skipped_for_scoped_profile(self, monkeypatch):
from agent import secret_scope
from plugins.platforms.telegram.adapter import _apply_yaml_config
monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False)
monkeypatch.delenv("TELEGRAM_ALLOWED_USERS", raising=False)
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
token = secret_scope.set_secret_scope({})
try:
extras = _apply_yaml_config(
{}, {"allowed_chats": ["-100200"], "allow_from": "222222222"},
)
finally:
secret_scope.reset_secret_scope(token)
# allowed_chats reaches PlatformConfig.extra via the shared-key loop
# in gateway/config.py (type-preserving); _apply_yaml_config must not
# write either gate into the process-global env for a scoped profile.
assert extras is None or "allowed_chats" not in extras
assert os.getenv("TELEGRAM_ALLOWED_CHATS") is None
assert os.getenv("TELEGRAM_ALLOWED_USERS") is None