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
This commit is contained in:
joaomarcos
2026-07-29 16:30:28 -03:00
committed by Teknium
parent 87f5c5351a
commit d52a1c25e0
3 changed files with 39 additions and 10 deletions
+9 -8
View File
@@ -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]]:
+11 -2
View File
@@ -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
+19
View File
@@ -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"