From d52a1c25e03b4edccb974618d3b53752516b6a54 Mon Sep 17 00:00:00 2001 From: joaomarcos Date: Wed, 29 Jul 2026 16:30:28 -0300 Subject: [PATCH] fix(auth): resolve fallback api keys through secret_scope, not raw env 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 #74311 --- agent/auxiliary_client.py | 17 +++++++++-------- hermes_cli/fallback_config.py | 13 +++++++++++-- tests/hermes_cli/test_fallback_config.py | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 21649aa5bc..88b206be7c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -4741,14 +4741,15 @@ def _try_configured_fallback_for_unavailable_client( def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]: - """Resolve inline or env-backed API key from a fallback-chain entry.""" - explicit = str(entry.get("api_key") or "").strip() - if explicit: - return explicit - key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip() - if key_env: - return os.getenv(key_env, "").strip() or None - return None + """Resolve inline or env-backed API key from a fallback-chain entry. + + Delegates to the centralized, secret-scope-aware resolver so this path + doesn't leak another profile's credential via a raw ``os.getenv`` under + gateway multiplexing (see ``hermes_cli.fallback_config.resolve_entry_api_key``). + """ + from hermes_cli.fallback_config import resolve_entry_api_key + + return resolve_entry_api_key(entry) def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]: diff --git a/hermes_cli/fallback_config.py b/hermes_cli/fallback_config.py index b18aecb6cb..2cce7a2d37 100644 --- a/hermes_cli/fallback_config.py +++ b/hermes_cli/fallback_config.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from typing import Any @@ -19,6 +18,14 @@ def resolve_entry_api_key(entry: dict[str, Any] | None) -> str | None: holding the key; ``api_key_env`` accepted as an alias). Returns None when neither yields a non-empty value, letting ``resolve_runtime_provider`` fall through to the provider's standard credential resolution. + + ``key_env`` is resolved through ``agent.secret_scope.get_secret`` rather + than a raw ``os.getenv`` — in a multiplexed gateway a bare env read would + ignore the active profile's scope and can return another profile's + credential. ``get_secret`` already implements the right fallback: it + reads ``os.environ`` when there's no active multiplexed scope (matching + prior single-profile behavior), and fails closed only when multiplexing + is active with no scope installed. """ if not isinstance(entry, dict): return None @@ -27,7 +34,9 @@ def resolve_entry_api_key(entry: dict[str, Any] | None) -> str | None: return inline key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip() if key_env: - return os.getenv(key_env, "").strip() or None + from agent.secret_scope import get_secret + + return (get_secret(key_env) or "").strip() or None return None diff --git a/tests/hermes_cli/test_fallback_config.py b/tests/hermes_cli/test_fallback_config.py index d9d368a549..3d6da5604b 100644 --- a/tests/hermes_cli/test_fallback_config.py +++ b/tests/hermes_cli/test_fallback_config.py @@ -1,5 +1,6 @@ """Tests for hermes_cli/fallback_config.py — fallback entry API-key resolution.""" +from agent.secret_scope import reset_secret_scope, set_secret_scope from hermes_cli.fallback_config import resolve_entry_api_key @@ -18,3 +19,21 @@ class TestResolveEntryApiKey: monkeypatch.setenv("FB_KEY", "env-key") entry = {"api_key": " ", "key_env": "FB_KEY"} assert resolve_entry_api_key(entry) == "env-key" + + def test_key_env_resolves_from_active_secret_scope_not_raw_env(self, monkeypatch): + # Multiplexed gateway: os.environ holds another profile's key, but the + # active per-turn secret scope holds this profile's key. The scoped + # value must win — a raw os.getenv() would leak the other profile's + # credential (issue #74311). + monkeypatch.setenv("FB_KEY", "fake-other-profile-key") + token = set_secret_scope({"FB_KEY": "fake-active-profile-key"}) + try: + assert resolve_entry_api_key({"key_env": "FB_KEY"}) == "fake-active-profile-key" + finally: + reset_secret_scope(token) + + def test_key_env_falls_back_to_env_when_no_active_scope(self, monkeypatch): + # Non-multiplexed / single-profile behavior must be unchanged: with no + # secret scope installed, resolution still reads os.environ. + monkeypatch.setenv("FB_KEY", "env-key") + assert resolve_entry_api_key({"key_env": "FB_KEY"}) == "env-key"