Skip to content

fix(gateway): scope authz allowlist reads to the routed profile - #61985

Closed
rlaehddus302 wants to merge 5 commits into
NousResearch:mainfrom
rlaehddus302:fix/multiplex-authz-allowlist-scope
Closed

fix(gateway): scope authz allowlist reads to the routed profile#61985
rlaehddus302 wants to merge 5 commits into
NousResearch:mainfrom
rlaehddus302:fix/multiplex-authz-allowlist-scope

Conversation

@rlaehddus302

Copy link
Copy Markdown
Contributor

Summary

  • _is_user_authorized and _get_unauthorized_dm_behavior read *_ALLOWED_USERS / *_ALLOW_ALL_USERS / GATEWAY_ALLOWED_USERS via raw os.getenv, which only ever sees the process-global environment.
  • Under gateway.multiplex_profiles, a secondary profile's .env is intentionally never merged into os.environ (_profile_runtime_scope only installs a per-turn secret scope, to keep credentials isolated) — so a secondary profile's own allowlist configuration was silently ignored, and the allowlist actually enforced was whichever profile started the gateway process.
  • This is an authz isolation gap, not a credential-leak issue: fix(gateway): isolate multiplexed profile routing #57417 already fixed per-profile credential (token) resolution; this PR closes a separate gap in request-time authorization allowlist resolution that fix(gateway): isolate multiplexed profile routing #57417 doesn't touch (it doesn't modify authz_mixin.py's env-reading logic).

Reproduction (before this fix)

Two profiles (default, coder), each configuring a distinct MATTERMOST_ALLOWED_USERS:

  • default/.env: MATTERMOST_ALLOWED_USERS=user-a
  • coder/.env: MATTERMOST_ALLOWED_USERS=user-b

With multiplexing on, user-b messaging the coder profile's bot was rejected ("Unauthorized user"), while user-a was authorized on both bots — the coder profile's own allowlist was never consulted.

Fix

  • Add _profile_scope_for(profile): enters the routed profile's secret scope for one authz decision, mirroring the existing _pairing_store_for per-profile isolation pattern and the if multiplex_profiles: with _profile_runtime_scope(...) guard already used elsewhere in the gateway. No-op (nullcontext) when multiplexing is off, so single-profile gateways are unaffected.
  • Add a scope-aware _getenv local to this module, mirroring gateway.config._getenv / hermes_cli.runtime_provider._getenv (the existing convention for this in the codebase).
  • Split _is_user_authorized / _get_unauthorized_dm_behavior so the allowlist-reading portion runs inside that scope, and replace their os.getenv calls with _getenv.

How to test

I wasn't able to get a Python 3.11–3.13 environment running locally to run scripts/run_tests.sh end-to-end (the checkout I had available was on 3.10). Rather than add pytest coverage I couldn't actually execute against the full suite, I verified the real, unmodified code path with a standalone script that stubs only gateway.run.logger (the one thing authz_mixin imports from that module at call time) and exercises GatewayAuthorizationMixin._is_user_authorized under a manually-installed agent.secret_scope scope per profile:

runner._profile_scope_for = <stub installing {"MATTERMOST_ALLOWED_USERS": ...} per profile via agent.secret_scope>

assert runner._is_user_authorized(source(user_id="user-a", profile=None))    is True   # default's own user
assert runner._is_user_authorized(source(user_id="user-b", profile=None))    is False  # not in default's allowlist
assert runner._is_user_authorized(source(user_id="user-b", profile="coder")) is True   # coder's own user
assert runner._is_user_authorized(source(user_id="user-a", profile="coder")) is False  # no longer leaks in via default's allowlist

# non-multiplex regression check
runner2.config.multiplex_profiles = False
assert runner2._is_user_authorized(source(user_id="user-a", profile=None)) is True  # legacy os.environ path unchanged

All assertions pass. Happy to add this as a proper tests/gateway/ pytest module (following the existing test_multiplex_profile_authz.py / test_multiplex_credential_isolation.py patterns) if a maintainer wants it before merge — I only left it out of the diff because I couldn't run it against the full suite myself in this environment.

Platforms tested

The changed logic is platform-agnostic (same os.getenv pattern applied to every *_ALLOWED_USERS var); verified with Platform.MATTERMOST on Linux (WSL2).

Related: #57417 (fixes credential isolation; this PR addresses the separate authz-allowlist isolation gap it doesn't cover).

@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/gateway Gateway runner, session dispatch, delivery area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data P2 Medium — degraded but workaround exists labels Jul 10, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

Security evidence:

  • trust boundary: gateway authorization decides whether an external Telegram group/forum message is accepted or rejected before the agent handles it.
  • source/sink/invariant: legacy TELEGRAM_GROUP_ALLOWED_USERS values that are Telegram chat IDs must still authorize only the listed chat, warn once, and deny other chats instead of crashing inside the authorization check.
  • current-main reproduction: the run-root profile-scope probe imported current-main gateway/authz_mixin.py and reproduced the multiplex allowlist isolation gap this PR targets.
  • PR-head or patch-replay validation: the same probe imported PR-head gateway/authz_mixin.py and verified routed-profile Mattermost allowlists are scoped correctly, but focused gateway tests on PR head failed tests/gateway/test_unauthorized_dm_behavior.py::{test_telegram_group_users_legacy_chat_ids_still_authorize,test_telegram_group_users_legacy_does_not_cross_chats,test_telegram_group_users_mixed_sender_and_legacy_chat} with NameError: name 'logger' is not defined.
  • positive/negative cases: the PR-head probe covered the intended routed-profile allowlist isolation path and the failing focused tests covered legacy Telegram chat-ID authorization for listed chats, denial for other chats, and mixed sender/chat allowlist entries.
  • residual bypass search: the reproduced failure is limited to the legacy Telegram group-ID warning branch introduced into _is_user_authorized_scoped(); no CodeRabbit finding or separate residual bypass was reported.
  • reviewer validation: CodeRabbit ran against the PR patch after local validation and reported zero findings; the failure above was reproduced locally and is not from CodeRabbit.

gateway/authz_mixin.py:602 still uses logger.warning(...) in the legacy Telegram group-ID allowlist branch, but this PR moved that branch into _is_user_authorized_scoped(). The lazy from gateway.run import logger import remains local to the outer _is_user_authorized() frame, so it is not visible from the new helper. As a result, any configured legacy negative chat ID in TELEGRAM_GROUP_ALLOWED_USERS now raises before the method can authorize the listed chat or deny a different one. Please keep the lazy logger import available in the scoped helper as well, or otherwise make that warning path resolve logger without reintroducing the import cycle.

Signed: GPT-5.5-xhigh in Codex

@rlaehddus302

Copy link
Copy Markdown
Contributor Author

Fixed in e1300322d — moved the from gateway.run import logger import into _is_user_authorized_scoped() (the frame that actually uses it now). Reproduced the legacy TELEGRAM_GROUP_ALLOWED_USERS chat-ID branch locally: it now authorizes a listed chat, denies an unlisted one, and no longer raises NameError.

@egilewski

Copy link
Copy Markdown
Contributor

fully addressed

Security evidence:

  • trust boundary: gateway authorization decides whether an external routed-profile Mattermost message is accepted or rejected before the agent handles it.
  • source/sink/invariant: per-profile *_ALLOWED_USERS, *_ALLOW_ALL_USERS, and GATEWAY_ALLOWED_USERS reads must resolve through the routed profile's secret scope under gateway.multiplex_profiles, while legacy Telegram chat-ID compatibility must still authorize only the listed chat without crashing.
  • current-main reproduction: the run-root profile-scope probe imported current-main gateway/authz_mixin.py and reproduced the isolation gap: coder profile messages still used process-global MATTERMOST_ALLOWED_USERS=user-a, authorizing user-a and denying scoped user-b.
  • PR-head or patch-replay validation: the meaningful PR patch was replayed onto current GitHub main caf557be5b4c9ae75b3a7566d65d3df2c701c5df; the same probe imported the replayed tree and verified coder profile authorization denied user-a and authorized scoped user-b.
  • positive/negative cases: replay validation covered default-profile allowlist behavior, secondary-profile allowlist isolation, legacy Telegram chat-ID authorization, denial for other chats, mixed sender/chat entries, and existing multiplex secret-scope behavior.
  • residual bypass search: changed env reads in gateway/authz_mixin.py now use the scope-aware _getenv; no remaining raw os.getenv authz allowlist read or legacy logger NameError path was found in the changed module.
  • reviewer validation: focused replay tests passed, including the three legacy Telegram tests that previously exposed NameError, nine multiplex/scope tests, git diff --check, and CodeRabbit completed on gateway/authz_mixin.py with zero findings.

I reviewed a run-owned patch replay against current GitHub main; that validates the meaningful authz change on current code but does not by itself prove the submitted branch has no stale-history merge work left.

Signed: GPT-5.5-xhigh in Codex

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing the request-time allowlist leak; current main still has the raw os.getenv reads this targets (gateway/authz_mixin.py:459-465, :701-707).

Problems

  • The new scope is selected from source.profile (gateway/authz_mixin.py:361), but secondary adapters register _make_adapter_auth_check(adapter.platform) without their profile (gateway/run.py:8591). The callback creates a profile-less SessionSource (gateway/run.py:8788-8794), so Slack/Discord external-context authorization still resolves the active profile rather than the secondary adapter profile.
  • The diff adds no regression coverage for scoped .env allowlists or the unauthorized-DM fallback. Existing tests/gateway/test_multiplex_profile_authz.py covers adapter policy selection, not profile secret-scope reads.

Suggested changes

  • Bind profile_name into the secondary adapter authorization callback and place it on the callback's SessionSource.
  • Add positive/negative two-profile tests for direct authz, unauthorized-DM behavior, and the adapter callback path.

Automated hermes-sweeper review.

Comment thread gateway/authz_mixin.py
@@ -314,6 +358,19 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
):
return True

with self._profile_scope_for(source.profile):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scopes correctly only when the caller stamps source.profile. Secondary adapters register _make_adapter_auth_check(adapter.platform) at gateway/run.py:8591; that callback constructs a profile-less SessionSource at gateway/run.py:8788-8794, so its Slack/Discord context checks still select the active profile. Bind profile_name into that callback and set it on its source.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — bound profile_name into _make_adapter_auth_check and stamped it onto the callback's SessionSource (gateway/run.py:8611, 8785-8814), so secondary-adapter auth callbacks now resolve their own multiplex profile instead of the active one. Added regression tests covering both the secondary-profile callback (stamps its own profile) and the primary/no-profile callback (still resolves the active profile). All 83 authz/telegram-authz tests pass.

Commit: f4d9be5eb

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 11, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

Security evidence:

  • trust boundary: Slack and Discord adapters fetch prior thread/channel messages and use the gateway authorization callback to decide whether each external sender is trusted or must be marked [unverified] before its content enters LLM context.
  • source/sink/invariant: a channel routed through gateway.profile_routes must check fetched senders against that routed profile's allowlist, not the active/default profile's allowlist.
  • current-main reproduction: the current-main callback constructs a profile-less SessionSource, so it has no routed-profile context for the allowlist check.
  • PR-head or patch-replay validation: after replaying this PR onto current GitHub main, a Slack channel route to profile coder resolved correctly for a normal source, but the primary adapter callback still denied scoped coder-user and authorized default-profile default-user for that same channel.
  • positive/negative cases: direct routed-profile authz and the new secondary-adapter callback passed; both directions of the shared-primary-adapter callback inversion reproduced.
  • residual bypass search: the new profile_name is supplied only when registering secondary adapters. Primary startup/reconnect still register _make_adapter_auth_check(adapter.platform), and the helper never resolves gateway.profile_routes from its platform/chat context.
  • reviewer validation: 160 focused gateway tests passed, and a focused route/callback probe reproduced this residual behavior on the current-main patch replay.

gateway/run.py:_make_adapter_auth_check() still uses the active/default profile for the shared primary Slack/Discord adapter's fetched-context authorization. That leaves profile-routed channels on the wrong allowlist: content from a user allowed only by the routed profile is marked unverified, while content from a user allowed only by the default profile is treated as verified. Please make this callback resolve the applicable profile route (including the routing context needed by supported route shapes) before constructing SessionSource, and add a regression test that exercises a primary adapter callback for a channel routed to another profile.

Review setup: I reviewed a run-owned patch replay against current GitHub main because the submitted branch conflicts with current history; this does not mean the submitted branch itself merges cleanly.

Signed: GPT-5.6-sol-xhigh in Codex

rlaehddus302 and others added 5 commits July 16, 2026 22:26
…r multiplex_profiles

_is_user_authorized and _get_unauthorized_dm_behavior read *_ALLOWED_USERS,
*_ALLOW_ALL_USERS, and GATEWAY_ALLOWED_USERS via raw os.getenv, which always
resolves to the process-global environment (the profile that started the
gateway). Under gateway.multiplex_profiles, a secondary profile's own .env
values are never mutated into os.environ (by design, to keep credentials
isolated), so its allowlist configuration was silently ignored in favor of
the active profile's.

Add a small profile-scope helper (_profile_scope_for, mirroring the existing
_pairing_store_for isolation pattern) and a scope-aware _getenv (mirroring
gateway.config._getenv), then split both methods so the env-reading portion
runs inside the routed profile's secret scope. Single-profile gateways are
unaffected: _profile_scope_for is a no-op (nullcontext) when
multiplex_profiles is off, so _getenv falls through to the same os.environ
read as before.

Verified against the real (unmodified otherwise) code path: with two
profiles configured with distinct MATTERMOST_ALLOWED_USERS values, each
profile's allowlist now isolates correctly, and the non-multiplex path is
unchanged.
The lazy 'from gateway.run import logger' import stayed in the outer _is_user_authorized() frame after the previous commit split its body into _is_user_authorized_scoped(). The legacy TELEGRAM_GROUP_ALLOWED_USERS chat-ID compat branch (moved into the scoped helper) still calls logger.warning(...), which raised NameError since that name was never imported in the new frame.

Move the import to _is_user_authorized_scoped(), the only place that now uses it. Verified the legacy chat-ID branch authorizes a listed chat, denies an unlisted one, and no longer raises.

Reported by automated review (CodeRabbit/Codex) on PR NousResearch#61985.
_make_adapter_auth_check built a profile-less SessionSource for
secondary multiplex adapters' external-context authorization (Slack/
Discord thread-reply sender checks), so it kept resolving the active
profile's allowlist scope instead of the adapter's own profile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…back

The shared primary Slack/Discord adapter registers its fetched-context
authorization callback without a profile, so a channel routed elsewhere by
`gateway.profile_routes` was checked against the active profile's allowlist:
users allowed only by the routed profile were marked `[unverified]`, while
default-profile users were treated as verified.

Resolve the route via `_profile_name_for_source` when no profile is bound.
Route matching is conjunctive, so guild- and thread-scoped routes only match
when that context is supplied — thread it from the Slack/Discord fetch paths
through `_is_sender_authorized`. The context is forwarded only to callbacks
marked `_accepts_route_ctx`, so the existing 3-arg callbacks keep the legacy
call unchanged.

An explicitly bound `profile_name` (secondary adapters) still wins over
routes, and `_profile_name_for_source` returns None when multiplexing is off
or no route matches, leaving unrouted gateways unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nv reads

`_auth_env` fell back to `os.getenv` whenever the profile scope did not define
a var. In a multiplexer `os.environ` holds whichever profile started the
process, so a routed profile that simply omits an allowlist var was authorized
against the starting profile's allowlist — the cross-profile leak the scoping
exists to prevent.

`agent.secret_scope.get_secret` already treats an installed scope as
authoritative and does not fall through to `os.environ`; mirror that rule here
for the blank-value case. Outside any scope (single-profile gateways) the read
stays plain `os.getenv`, unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rlaehddus302
rlaehddus302 force-pushed the fix/multiplex-authz-allowlist-scope branch from f4d9be5 to 36ded63 Compare July 16, 2026 14:01
teknium1 pushed a commit that referenced this pull request Jul 16, 2026
Subset of PR #61985: _make_adapter_auth_check gains a profile_name
parameter and secondary-profile adapters (started in
_start_one_profile_adapters) bind it, so the auth callback's
SessionSource resolves the routed profile's adapter and pairing store
instead of silently falling back to the default profile. This is the
gap left open by the #65629 merge — adapter-internal auth checks (e.g.
Slack thread-context fetch) fire outside the wrapped message handler.

The PR's authz_mixin.py hunks are dropped: main's _auth_env (merged via
PR #65629) already covers the scoped allowlist reads they targeted.
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #65700 — the gateway/run.py + tests portion of your PR was cherry-picked onto current main with your authorship preserved in git log (rebase merge). Your _make_adapter_auth_check(profile_name=...) stamping fixed exactly the gap the earlier authz merge (#65629) left open: adapter-internal auth checks fire outside the wrapped message handler, so they resolved the default profile's adapter and pairing store for secondary chats.

The gateway/authz_mixin.py hunks were dropped — main's _auth_env (merged via #65629 four days after you opened this) covers the same scoped allowlist reads, and your per-decision scope approach conflicted textually with it. The run.py stamp + both regression tests landed intact. Thanks!

@teknium1 teknium1 closed this Jul 16, 2026
@rlaehddus302
rlaehddus302 deleted the fix/multiplex-authz-allowlist-scope branch July 16, 2026 15:01
@teknium1 teknium1 added the area/profiles Multi-profile isolation, HERMES_HOME scoping label Jul 19, 2026
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
Subset of PR NousResearch#61985: _make_adapter_auth_check gains a profile_name
parameter and secondary-profile adapters (started in
_start_one_profile_adapters) bind it, so the auth callback's
SessionSource resolves the routed profile's adapter and pairing store
instead of silently falling back to the default profile. This is the
gap left open by the NousResearch#65629 merge — adapter-internal auth checks (e.g.
Slack thread-context fetch) fire outside the wrapped message handler.

The PR's authz_mixin.py hunks are dropped: main's _auth_env (merged via
PR NousResearch#65629) already covers the scoped allowlist reads they targeted.
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
yournetworkplug-ctrl added a commit to yournetworkplug-ctrl/hermes-agent that referenced this pull request Jul 29, 2026
… multiplex_profiles

Replace raw os.getenv("TELEGRAM_ALLOWED_USERS") reads in the Telegram
adapter's pre-filter with gateway.authz_mixin._auth_env so the served
profile's .env is consulted when its secret scope is installed. This
mirrors the gateway-layer fix in PRs NousResearch#61985/NousResearch#65629/NousResearch#65700 down to the
adapter pre-filter, fixing the Telegram mirror of issue NousResearch#72348.

See README.md for details, scope notes, and end-to-end test evidence.

Fixes NousResearch#72348 (Telegram mirror)
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
Subset of PR NousResearch#61985: _make_adapter_auth_check gains a profile_name
parameter and secondary-profile adapters (started in
_start_one_profile_adapters) bind it, so the auth callback's
SessionSource resolves the routed profile's adapter and pairing store
instead of silently falling back to the default profile. This is the
gap left open by the NousResearch#65629 merge — adapter-internal auth checks (e.g.
Slack thread-context fetch) fire outside the wrapped message handler.

The PR's authz_mixin.py hunks are dropped: main's _auth_env (merged via
PR NousResearch#65629) already covers the scoped allowlist reads they targeted.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
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 P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages 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.

4 participants