fix(discord): read gate config from per-adapter extra, not process-global env (#72348) - #72427
Conversation
…obal env (NousResearch#72348) Under multiplex_profiles, multiple Discord adapters share a single process-global os.environ. The allow/deny gates (allowed_channels, ignored_channels, allowed_users, allowed_roles) were read via os.getenv(), causing first-writer-wins: the first profile to initialize pins the gate values for all other profiles. Fix by: 1. Seeding gate values into seeded_extra in _apply_yaml_config() so they flow through PlatformConfig.extra (per-adapter isolated). 2. Adding _get_allowed_channels(), _get_ignored_channels(), _get_allowed_users(), _get_allowed_roles() helper methods that read from self.config.extra first, falling back to os.getenv for backward compatibility with non-config adapters. 3. Updating the critical call sites in _handle_message() and connect() to use the new helpers. This matches the existing pattern used by require_mention and free_response_channels which already read from self.config.extra. Fixes NousResearch#72348
|
The issue premise is valid, but this head does not yet fix the multiplex reproduction and introduces a startup exception.
There are no new regressions in this PR, so the reported “7 existing tests” cannot exercise any of these paths. The minimum failing-first test is two adapters with distinct gates, initialized A→B and B→A, asserting normal messages + slash/component authorization remain isolated. Add a negative case where A enables allow-all and B does not, plus username resolution after This needs a policy object/single adapter-local accessor used by all consumers, not four partial helpers whose fallback is the shared state that caused the bug. Signed: GPT-5.6-sol-xhigh in Codex |
|
suggesting changes The patch introduces per-adapter gate readers, and those readers correctly prefer PlatformConfig.extra when it is present, but the producer side does not satisfy that contract and several authorization sinks still bypass it. A focused two-profile probe reproduced current main's process-global first-writer behavior. On the PR head, an allow_from configuration raises UnboundLocalError before the seed dictionary is initialized; the loader catches that exception and discards the hook result. Independently, channel seed values are only returned while the corresponding global environment variable is empty, so only the first profile receives an extra value. Finally, channel-based user authorization and Discord slash authorization still read process-global channel gates directly. These defects preserve cross-profile authorization coupling and can apply one profile's trusted channel boundary to another adapter, so the change is not mergeable as a security fix.
Security evidence:
Uncertainty: A live Discord multiplex end-to-end run was not possible without service credentials and network access.; The full test suite and focused existing tests were not runnable because pytest is absent from the available Python environment.; The runtime order in which profiles populate global variables may differ by deployment; the order changes which profile's policy leaks but does not remove the demonstrated coupling. Signed: GPT-5.6-sol-xhigh in Codex |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for pursuing a real multiplex authorization-isolation defect. Current main still bridges Discord gates through first-writer process-global environment variables (plugins/platforms/discord/adapter.py:9678-9712) and still consumes them on message, channel-only, and slash paths (plugins/platforms/discord/adapter.py:4418, 4645, 4662, 7454).
Problems
- In PR head
09c2300f,seeded_extrais written before its existing initialization (plugins/platforms/discord/adapter.py:9482versus9498), so configuredallow_fromcan raise during config application. - The new seeds remain inside the existing
not os.getenv(...)guards. Profile B therefore still receives no adapter-local gate after profile A populated the shared variable; the diff also has noallowed_rolesseed. - The change leaves direct global authorization reads outside
connect()and_handle_message(), including channel-only and slash checks (plugins/platforms/discord/adapter.py:4418,4645,4662) and allow-all checks (4487-4489,8013-8015).
Suggested changes
- Build one complete adapter-local policy independently for each profile, then use it at every authorization sink. Keep any environment bridge strictly as legacy fallback.
- Add order-independent two-profile regression tests for YAML and scoped
.envinputs, normal messages, slash authorization, and a negative cross-profile allow-all case.
Automated hermes-sweeper review.
| 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["allowed_users"] = str(allowed_users_cfg) |
There was a problem hiding this comment.
seeded_extra is initialized later in this function, so this write raises UnboundLocalError when allow_from is configured. Initialize and populate the per-profile seed before this block; keep only the legacy os.environ write behind the existing environment-precedence guard.
…tiplex_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
…tiplex_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
|
The per-adapter gate direction you took here landed via PR #75970 (#75970), credited in the commit. Your PR pointed the right way — per-adapter reads instead of process env — but had a few gaps flagged in review (seeded_extra before init, first-writer seed guards, uncovered slash/allow-all sites), so #75970 implements the full class: all 11 Discord gate vars, slash/component/backfill paths, and the Telegram mirror, with 21 isolation tests. Fixes #72348. Thanks for breaking the trail. |
…tiplex_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 NousResearch#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 NousResearch#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 NousResearch#72348
…tiplex_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 NousResearch#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 NousResearch#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 NousResearch#72348
Summary
Fixes #72348. Under
gateway.multiplex_profiles: true, multiple Discord adapters share a single process-globalos.environ. The allow/deny gates (allowed_channels,ignored_channels,allowed_users,allowed_roles) were read viaos.getenv(), causing first-writer-wins: the first profile to initialize pins the gate values for all other profiles.Changes
seeded_extrain_apply_yaml_config()so they flow throughPlatformConfig.extra(per-adapter isolated)._get_allowed_channels(),_get_ignored_channels(),_get_allowed_users(),_get_allowed_roles()helper methods that read fromself.config.extrafirst, falling back toos.getenvfor backward compatibility._handle_message()andconnect()to use the new helpers.This matches the existing pattern used by
require_mentionandfree_response_channelswhich already read fromself.config.extra.Testing
python3 -m py_compile plugins/platforms/discord/adapter.py-- OKruff check plugins/platforms/discord/adapter.py-- All checks passed