Skip to content

fix(gateway): make Discord/Telegram allow/deny gates per-profile under multiplex - #75970

Merged
teknium1 merged 2 commits into
mainfrom
fix/discord-gates-per-profile
Aug 1, 2026
Merged

fix(gateway): make Discord/Telegram allow/deny gates per-profile under multiplex#75970
teknium1 merged 2 commits into
mainfrom
fix/discord-gates-per-profile

Conversation

@teknium1

@teknium1 teknium1 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Under gateway.multiplex_profiles, Discord/Telegram authorization gates read process-global env populated first-writer-wins — profile A's allow/deny lists and allow-all flags governed profile B. All gate reads are now per-adapter-instance.

Changes

  • gateway/authz_mixin.py: new _platform_gate_env — under multiplex with a profile scope installed, a scope miss returns the default instead of falling through to another profile's env value; single-profile behavior identical.
  • Discord adapter: connect() snapshots all 11 gate env vars per-adapter in the owning profile's scope; accessors (snapshot → config.extra → scoped env) replace every raw os.getenv gate read (message, slash, component, fail-closed, backfill, allow-bots); YAML loads seed PlatformConfig.extra and skip env writes under multiplex; removed the unguarded runtime DISCORD_ALLOWED_USERS env clobber (multiplex only).
  • Telegram adapter: same class fixed — intake prefilter, callback-auth fallbacks, chat/topic/thread getters now scope-aware; YAML→env bridge skipped for profile-scoped loads.

Deferred (non-security, listed for follow-up)

Non-auth Discord env bridges (auto_thread, reactions, history_backfill, require_mention, reply_to_mode); Slack/Signal/WhatsApp sibling bridges; gateway _auth_env tightening (already scope-preferring via #65629/#65700).

Validation

21 new order-independent regression tests (two-adapter own-list enforcement, negative allow-all isolation, slash-gate isolation, env-clobber, bridge skip/seed matrix, Telegram scoped-reader matrix); sabotage-verified both platforms; ~1,100 related gateway tests pass (4 pre-existing flakes reproduce on clean main); ruff clean.

Credits: direction from #72427 (@JonthanaHanh), @yournetworkplug-ctrl's live-verified Telegram approach, thread analysis by @jackjin1997 / @cal88.

Fixes #72348

Infographic

PR infographic

@teknium1 teknium1 added the area/profiles Multi-profile isolation, HERMES_HOME scoping label Aug 1, 2026
@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins platform/discord Discord bot adapter platform/telegram Telegram bot adapter area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data needs-decision Awaiting maintainer decision before any implementation labels Aug 1, 2026
…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
@teknium1
teknium1 force-pushed the fix/discord-gates-per-profile branch from 8a99cfa to 54d8b3a Compare August 1, 2026 07:45
CI's plugin-test slice runs without the discord optional extra; the raw
import failed with ModuleNotFoundError while every other test in the
file uses injected mock modules.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on f35cca3

ℹ️ Info

Desktop E2E visual evidence · View test artifacts · View job

2 visual diffs.

inline evidence upload failed.

Failed to upload diff-1508682a2ae8-boot-ready-diff.png with gh image (exit code 1): Error uploading /home/runner/work/_temp/e2e-evidence/diff-1508682a2ae8-boot-ready-diff.png: step 0 (get upload token): uploadToken not found on repo page — do you have write access to NousResearch/hermes-agent? (or, if NousResearch enforces SAML SSO, authorize at https://github.com/orgs/NousResearch/sso)

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The adapter-local Discord/Telegram gates are substantially improved, but the gateway's authoritative authorization decision still reads the process-global first-profile allowlist. A focused two-profile probe reproduces the same cross-profile authorization failure on both current main and the PR head: profile B authorizes profile A's user and rejects its own configured user. Because this remaining path is the final inbound gateway gate before agent execution, the PR does not yet close the reported security boundary.

  • [P1] Gateway authorization still consumes another profile's allowlist (gateway/authz_mixin.py:601)
    Under multiplex, profile B's secret scope can legitimately omit DISCORD_ALLOWED_USERS because its allow_from is in config.yaml, while process-global os.environ still contains profile A's first-writer-bridged value. _is_user_authorized calls _auth_env here; _auth_env falls through to os.getenv on a scoped miss, so the nonempty profile-A value prevents the later adapter.config.extra fallback. In the validated case, B's configured user 222 was denied while A's user 111 was authorized against B's bot. That crosses the profile authorization boundary and can dispatch an attacker-controlled prompt into the wrong profile's agent/tool context.
    Remediation: Make authorization gate reads authoritative on a multiplex secret-scope miss (including platform/group/global allowlists and allow-all/bot/chat early gates), then fall through to the selected live adapter's config.extra for YAML policy. Add an end-to-end GatewayAuthorizationMixin regression with two profile adapters asserting both A-on-B denial and B-on-B authorization.

Security evidence:

  • trust boundary: Untrusted Discord/Telegram sender and chat identifiers enter a platform adapter, pass adapter-local gates, and then reach GatewayAuthorizationMixin._is_user_authorized before an event may invoke the profile's agent and tools. Under multiplex, each adapter, config.yaml, .env secret scope, pairing store, and resulting agent context must remain bound to one profile; process-global os.environ is outside that per-profile boundary.
  • source/sink/invariant: Sources are sender ID, chat ID/type, role/bot metadata, profile-stamped SessionSource, profile config.extra, scoped .env values, and legacy process env bridges. The security sink is returning True from _is_user_authorized, which permits agent dispatch. The claimed invariant is that only the selected profile's allow/deny policy may authorize that sink. _platform_gate_env and per-adapter snapshots validate scoped misses correctly, but the central sink still uses _auth_env/raw os.getenv and violates the invariant.
  • current-main reproduction: A focused probe dynamically loaded gateway/authz_mixin.py from bound current main 40e0e7a. With multiplex active, an empty profile-B secret scope, process env DISCORD_ALLOWED_USERS=111 (profile A), and B's live adapter config.extra.allow_from=[222], _is_user_authorized returned True for user 111 and False for user 222. This reproduces the reported first-writer cross-profile failure on current main.
  • PR-head or patch-replay validation: The same probe against checked-out PR head f35cca3 returned the identical pair: True for profile A's user on profile B and False for B's own configured user. A three-way git merge-tree of base d109138, current main, and PR head reported merged entries without conflict for the changed files, so the head behavior is coherently applicable to current main.
  • positive/negative cases: Negative case: profile B must reject user 111, who exists only in profile A's process-env allowlist; observed result was True. Positive case: profile B must accept user 222 from B's config.extra.allow_from; observed result was False because the leaked nonempty env allowlist bypassed the adapter-extra fallback. PR tests cover adapter snapshots, allow-all isolation, YAML seeding, and Telegram scoped reads, but do not exercise this final GatewayAuthorizationMixin decision.
  • residual bypass search: Searched both modified adapters and gateway code for raw DISCORD_, TELEGRAM_, and GATEWAY_* os.getenv reads and traced _auth_env callers. Residual central reads occur in _is_user_authorized for platform allowlists, group allowlists, global allowlists, allow-all, early group-chat authorization, and bot authorization; _get_unauthorized_dm_behavior also retains raw gate reads. The source-backed blocker is the directly reproduced platform-allowlist path at line 601; the adjacent reads should be converted and regression-tested as the same bug class.
  • reviewer validation: Inspected the complete production diff, profile runtime-scope construction, config shared-key bridging, adapter selection, authorization callback construction, and new tests. Ran the two-SHA behavioral probe successfully and compiled all changed Python/test files successfully. The focused pytest invocation could not run because the leased checkout's available Python has no pytest module; no network or dependency installation was attempted.

Uncertainty: The leased environment lacks pytest, so the repository's focused unit tests were not executed.; No live Discord or Telegram network session was exercised; validation used the real local authorization code with deterministic profile and sender inputs.

Signed: GPT-5.6-sol-xhigh in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/profiles Multi-profile isolation, HERMES_HOME scoping comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists platform/discord Discord bot adapter platform/telegram Telegram bot adapter sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Discord adapter allow/deny gates are process-global, breaking per-profile isolation under multiplex_profiles

3 participants