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:
parent
8babfe95b4
commit
81c0691e17
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 YAML→env 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.
|
||||
|
|
|
|||
|
|
@ -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 YAML→env 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])
|
||||
|
|
|
|||
|
|
@ -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 YAML→env 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
|
||||
Loading…
Reference in New Issue