fix(auth): restore secret-scope isolation for fallback API key resolution - #74340
Conversation
resolve_entry_api_key() and the duplicated _fallback_entry_api_key() read key_env via a raw os.getenv(), bypassing per-profile secret scoping in the multiplexed gateway. Under multiplexing this can hand a fallback request another profile's credential. Both now resolve through agent.secret_scope.get_secret(), which reads the active profile scope when multiplexing is on and falls back to os.environ unchanged when it's off, so single-profile behavior is preserved. Closes NousResearch#74311
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing the shared and auxiliary fallback resolvers; the premise is verified on current main (hermes_cli/fallback_config.py:28-30, agent/auxiliary_client.py:4739-4741). The proposed get_secret() routing matches the isolation contract in agent/secret_scope.py:150-177 and preserves non-multiplexed environment fallback.
Problems
- The same fallback-entry shape is still resolved with raw
os.getenv()in the init-time fallback atagent/agent_init.py:1224-1232and in ordinary runtime failover atagent/chat_completion_helpers.py:1791-1807. Both pass that value asexplicit_api_key, so the cross-profile credential bypass remains on active main-agent fallback paths. - The added tests cover only the shared resolver, not either remaining main-agent path.
Suggested changes
- Reuse
resolve_entry_api_key()in both remaining fallback paths and add a scoped-versus-process-environment regression test for main-agent failover.
This is an automated hermes-sweeper review.
…et_scope agent_init.py's init-time fallback and chat_completion_helpers.py's try_activate_fallback() still read key_env via raw os.getenv(), missing the per-profile secret scope installed by the multiplexed gateway (same bug fixed for fallback_config.py/auxiliary_client.py in this PR). Both now delegate to hermes_cli.fallback_config.resolve_entry_api_key(), and the Ollama Cloud OLLAMA_API_KEY read now goes through agent.secret_scope.get_secret() too. agent_init.py's fallback loop had no try/except around key resolution (unlike the other three call sites), so a fail-closed UnscopedSecretError under multiplexing would have crashed init instead of skipping to the next fallback entry — added the same skip-and-continue handling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
suggesting changes The shared fallback-entry helper now resolves an entry-level
Security evidence:
Signed: GPT-5.6-sol-xhigh in Codex |
Summary
resolve_entry_api_key()(hermes_cli/fallback_config.py) resolved a fallback entry'skey_envvia a rawos.getenv(), ignoring the per-profile secret scope installed by the multiplexed gateway.agent/auxiliary_client.pyhad an independent, near-identical_fallback_entry_api_key()with the same raw-os.getenv()bug.agent.secret_scope.get_secret()contract, and the auxiliary-client copy now delegates to the single centralized resolver instead of re-implementing it.Root Cause
The gateway multiplexes several profiles from one process. Each profile's secrets are installed into a context-local scope (
agent.secret_scope.set_secret_scope) precisely so one profile's credentials never leak into another profile's turn — process-globalos.environcan hold whichever profile's.envwas loaded most recently (or, under multiplexing, is not authoritative at all).resolve_entry_api_key()bypassed that scope entirely:Every caller of this function —
hermes_cli/cli_agent_setup_mixin.py(CLI-side fallback),gateway/run.py(_try_resolve_fallback_provider), andcron/scheduler.py(scheduled-job fallback) — inherited the bug because none of them re-scope the key afterward; they trustresolve_entry_api_key()to already return the right value. When a fallback entry'skey_envname happened to collide with (or simply read) a stale/other-profile value sitting inos.environ, a fallback request could run under the wrong profile's credential.agent/auxiliary_client.pyhad_fallback_entry_api_key(), a separate implementation of the exact same inline-api_key-then-key_envlogic, with the identical raw-os.getenv()flaw.Fix
resolve_entry_api_key()now resolveskey_envviaagent.secret_scope.get_secret():os.environexactly as before — no behavior change for existing non-gateway callers.get_secret()fails closed (UnscopedSecretError) instead of silently reading a stranger's credential. All three call sites already wrap this call intry/exceptand skip to the next fallback entry, so this fails safe rather than crashing.agent/auxiliary_client.py's_fallback_entry_api_key()now delegates tohermes_cli.fallback_config.resolve_entry_api_key()instead of re-implementing the same lookup, so there's one canonical, scope-aware implementation.cli_agent_setup_mixin.py,gateway/run.py,cron/scheduler.py) required no changes — they inherit the fix automatically and were verified to still import/compile cleanly.Diff is surgical: no changes to unrelated fallback-chain merging or provider-resolution logic.
Test Plan
test_key_env_resolves_from_active_secret_scope_not_raw_envtotests/hermes_cli/test_fallback_config.py: installs an active secret scope withfake-active-profile-keywhileos.environholds a differentfake-other-profile-key, asserts the scoped key wins.test_key_env_falls_back_to_env_when_no_active_scope: with no scope installed, asserts resolution still returns the env var (no regression for single-profile users).python -m pytest tests/hermes_cli/test_fallback_config.py -v— all 10 tests pass (8 pre-existing + 2 new).hermes_cli.cli_agent_setup_mixin,gateway.run,cron.scheduler, andagent.auxiliary_clientall import cleanly after the change (no circular-import regressions).Closes #74311