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:
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user