security(vertex): route credential/project/region resolution through the profile secret scope

agent/vertex_adapter.py resolved VERTEX_CREDENTIALS_PATH,
GOOGLE_APPLICATION_CREDENTIALS, VERTEX_PROJECT_ID, and VERTEX_REGION via raw
os.environ.get() instead of the profile-scoped get_secret() every other
credential lookup in hermes_cli/runtime_provider.py uses. In a multiplex
gateway serving several profiles from one process, os.environ still holds
whichever profile's .env python-dotenv loaded at boot — so a raw read here
let one profile's turn silently mint a Vertex OAuth2 token from, and get
billed against, a different profile's GCP service account. No error, no
fail-closed guard: the multiplex UnscopedSecretError protection was bypassed
entirely because these reads never went through get_secret().

- _resolve_credentials_path/_resolve_project_override/_resolve_region now
  call agent.secret_scope.get_secret(), matching the _getenv() pattern
  already used for every other provider's credentials.
- get_vertex_credentials()'s ADC fallback (google.auth.default()) reads
  GOOGLE_APPLICATION_CREDENTIALS from os.environ internally, bypassing
  get_secret() entirely — closed with a narrow guard: when multiplexing is
  active and this profile's scope has no Vertex credentials of its own, but
  os.environ still carries a value (left by a different profile's boot-time
  dotenv load), refuse ADC rather than silently authenticate as a stranger.
- Zero behavior change for single-profile installs: get_secret() falls
  through to os.environ transparently whenever multiplexing is off.

Same bug class as the already-fixed _HERMES_OAUTH_FILE/_AUTH_JSON_PATH/
HOOKS_DIR cross-profile leaks, now closed for Vertex's OAuth2 credential
path.
This commit is contained in:
srojk34 2026-07-02 02:16:13 +03:00 committed by kshitij
parent 2f7c51a3e2
commit 7f64cce96d
2 changed files with 92 additions and 3 deletions

View file

@ -21,6 +21,8 @@ import os
import time
from typing import Optional, Tuple
from agent.secret_scope import get_secret as _get_secret, is_multiplex_active
# Ensure google-auth is installed before importing. The [vertex] extra is no
# longer in [all] per the lazy-install policy added 2026-05-12 — lazy_deps
# handles on-demand installation so the Vertex provider still works for users
@ -65,7 +67,7 @@ def _resolve_region(explicit: Optional[str] = None) -> str:
"""Region precedence: explicit arg > VERTEX_REGION env > config.yaml > default."""
if explicit:
return explicit
env_region = os.environ.get("VERTEX_REGION", "").strip()
env_region = (_get_secret("VERTEX_REGION") or "").strip()
if env_region:
return env_region
cfg_region = str(_vertex_config().get("region") or "").strip()
@ -78,7 +80,7 @@ def _resolve_project_override() -> Optional[str]:
Returns None when neither is set (the credentials' embedded project_id
is used in that case).
"""
env_project = os.environ.get("VERTEX_PROJECT_ID", "").strip()
env_project = (_get_secret("VERTEX_PROJECT_ID") or "").strip()
if env_project:
return env_project
cfg_project = str(_vertex_config().get("project_id") or "").strip()
@ -88,8 +90,14 @@ def _resolve_project_override() -> Optional[str]:
def _resolve_credentials_path(explicit: Optional[str]) -> Optional[str]:
if explicit and os.path.exists(explicit):
return explicit
# Routed through get_secret (not a raw os.environ read): in a multiplex
# gateway serving several profiles from one process, os.environ reflects
# whichever profile's .env happened to be loaded at boot, not the profile
# the current turn belongs to. Reading it directly here would let one
# profile mint Vertex tokens from — and get billed against — a different
# profile's service-account file. See agent/secret_scope.py.
for env_var in ("VERTEX_CREDENTIALS_PATH", "GOOGLE_APPLICATION_CREDENTIALS"):
path = os.environ.get(env_var)
path = _get_secret(env_var)
if path and os.path.exists(path):
return path
return None
@ -123,6 +131,24 @@ def get_vertex_credentials(credentials_path: Optional[str] = None) -> Tuple[Opti
)
project_id = creds.project_id
else:
# google.auth.default() reads GOOGLE_APPLICATION_CREDENTIALS
# straight from os.environ internally — it has no notion of
# the profile secret scope. _resolve_credentials_path already
# confirmed (via get_secret) that *this* profile doesn't
# define the var, but python-dotenv's load_dotenv() mutates
# os.environ at boot for whichever profile happened to load
# first, so a raw os.environ read here can still pick up a
# different profile's service-account path. Refuse rather
# than silently authenticating under a stranger's identity.
if is_multiplex_active() and os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"):
logger.warning(
"Vertex ADC skipped for this profile: "
"GOOGLE_APPLICATION_CREDENTIALS is set in the process "
"environment (from another profile's .env) but not in "
"this profile's own config. Set VERTEX_CREDENTIALS_PATH "
"in this profile's .env instead of relying on ADC."
)
return None, None
creds, project_id = google.auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)

View file

@ -149,6 +149,69 @@ def test_missing_google_auth_returns_none(monkeypatch):
assert va.get_vertex_credentials() == (None, None)
def test_multiplex_scope_takes_precedence_over_raw_environ(vertex_adapter, monkeypatch):
"""In a multiplex gateway, a profile's own secret scope must win over a
stale value in process os.environ left behind by another profile's
dotenv load at boot otherwise Profile B's turn could resolve Profile
A's Vertex project (or worse, its credentials file path)."""
from agent import secret_scope
monkeypatch.setenv("VERTEX_PROJECT_ID", "other-profile-project")
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope({"VERTEX_PROJECT_ID": "this-profile-project"})
try:
assert vertex_adapter._resolve_project_override() == "this-profile-project"
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)
def test_multiplex_unscoped_read_fails_closed(vertex_adapter, monkeypatch):
"""A credential read with no profile scope installed while multiplexing
is active must raise rather than silently fall back to (possibly another
profile's) raw os.environ value."""
from agent import secret_scope
monkeypatch.setenv("VERTEX_PROJECT_ID", "leaked-project")
secret_scope.set_multiplex_active(True)
try:
with pytest.raises(secret_scope.UnscopedSecretError):
vertex_adapter._resolve_project_override()
finally:
secret_scope.set_multiplex_active(False)
def test_adc_refuses_foreign_profile_google_application_credentials(
vertex_adapter, monkeypatch, tmp_path
):
"""When this profile's scope defines no Vertex credentials, but os.environ
still carries a *different* profile's GOOGLE_APPLICATION_CREDENTIALS (left
there by python-dotenv at gateway boot), ADC must not silently mint a
token under that foreign service account."""
from agent import secret_scope
sa_file = tmp_path / "other_profile_sa.json"
sa_file.write_text('{"project_id": "other-profile"}')
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", str(sa_file))
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope({}) # this profile defines nothing
try:
assert vertex_adapter.get_vertex_credentials() == (None, None)
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)
def test_adc_still_works_when_not_multiplexed(vertex_adapter):
"""Single-profile (non-gateway) installs must see zero behavior change:
ADC still resolves normally when multiplexing is off, scope or not."""
token, base = vertex_adapter.get_vertex_config()
assert token == "ya29.FAKE"
assert "adc-project" in base
def test_adc_failure_falls_back_to_service_account(monkeypatch, tmp_path):
"""When ADC refresh fails but a service-account JSON exists, use the SA."""
for var in ("VERTEX_PROJECT_ID", "VERTEX_REGION", "GOOGLE_CLOUD_PROJECT"):