fix(#3961): complete credential scrub with non-registry agent creds (CUSTOM_API_KEY, AWS/Bedrock)

Codex re-gate found the registry-derived scrub still missed credential env vars
the agent resolves via raw os.getenv() that aren't in PROVIDER_REGISTRY:
CUSTOM_API_KEY (hermes_cli/models.py), AWS_BEARER_TOKEN_BEDROCK
(hermes_cli/model_switch.py), AZURE_ANTHROPIC_KEY (runtime_provider.py), and the
boto3 AWS_* credential family (agent/bedrock_adapter.py). Same cross-profile leak
class on custom/AWS-backed provider quota + detached-worker paths.

- Add _NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES fail-closed set, union into
  _profile_secret_env_names so BOTH leak sites pick it up.
- _account_usage_subprocess_env now relies on the centralized scrub set (WebUI-
  only fallback only if the api.profiles import fails), dropping the partial
  local AWS pair.
- 2 more regressions (quota subprocess + detached worker) covering CUSTOM_API_KEY
  + AWS family, fail without the fix.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-22 19:19:56 +00:00
parent 1293f0301e
commit 4eb069be97
3 changed files with 98 additions and 10 deletions

View file

@ -885,15 +885,46 @@ def filter_runtime_env_for_gateway_parity(env: dict[str, str]) -> dict[str, str]
return filtered
# Credential env vars the agent runtime resolves via raw os.getenv() that are
# NOT in hermes_cli.auth.PROVIDER_REGISTRY (so the registry-derived scrub set
# would miss them). Fail-closed list — verified against the installed agent:
# CUSTOM_API_KEY hermes_cli/models.py (generic custom provider key)
# AZURE_ANTHROPIC_KEY hermes_cli/runtime_provider.py (Azure-hosted Anthropic)
# AWS_BEARER_TOKEN_BEDROCK hermes_cli/model_switch.py (Bedrock bearer token)
# AWS_* agent/bedrock_adapter.py (boto3 credential chain)
# Stripping these in a profile-scoped read prevents an empty named profile from
# inheriting the server-process credential (#3961 residual cross-profile leak).
_NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES: tuple[str, ...] = (
"CUSTOM_API_KEY",
"AZURE_ANTHROPIC_KEY",
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_PROFILE",
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
"AWS_WEB_IDENTITY_TOKEN_FILE",
)
def _agent_registry_credential_env_names() -> set[str]:
"""Credential env-var names the *agent* runtime reads (hermes_cli auth
registry), e.g. OAuth/token-flow providers like Anthropic's ANTHROPIC_TOKEN
and CLAUDE_CODE_OAUTH_TOKEN. The WebUI's own _PROVIDER_ENV_VAR map omits
these (they aren't WebUI-settable API keys), so a profile scrub built only
from the WebUI map would leave them in os.environ — letting an empty named
profile inherit the server-process Anthropic token on the quota subprocess
and detached-worker model-rebuild paths (#3961 residual leak)."""
names: set[str] = set()
"""Credential env-var names the *agent* runtime reads, beyond the WebUI's own
settable-key map. Two sources:
1. ``hermes_cli.auth.PROVIDER_REGISTRY[*].api_key_env_vars`` — every provider
the agent CLI knows, incl. OAuth/token-flow providers like Anthropic's
``ANTHROPIC_TOKEN`` / ``CLAUDE_CODE_OAUTH_TOKEN`` that the WebUI's own
``_PROVIDER_ENV_VAR`` map omits (they aren't WebUI-settable API keys).
2. ``_NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES`` — a fail-closed fallback for
credential env vars the agent resolves via raw ``os.getenv()`` that are NOT
in the auth registry (the generic ``CUSTOM_API_KEY`` and the AWS/Bedrock
credential family the bedrock adapter relies on).
A profile scrub built only from the WebUI map would leave all of these in
``os.environ`` — letting an empty named profile inherit the server-process
credential on the quota subprocess and detached-worker model-rebuild paths
(#3961 residual cross-profile leak)."""
names: set[str] = set(_NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES)
try:
from hermes_cli.auth import PROVIDER_REGISTRY

View file

@ -1404,13 +1404,17 @@ def _account_usage_subprocess_env(home: Path, provider: str, api_key: str | None
except Exception:
_thread_ctx = None
if bool(getattr(_thread_ctx, "block_process_env_fallback", False)):
# Rely on the centralized profile scrub set (api.profiles), which unions
# the WebUI provider env vars + the agent auth registry + the non-registry
# agent credential fallback (CUSTOM_API_KEY, AWS/Bedrock family). Falling
# back to the WebUI-only set keeps the probe fail-closed if that import
# fails. (#3961 — don't leave a partial local AWS set here.)
_strip = set(_PROVIDER_CREDENTIAL_ENV_VARS)
_strip.update({"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"})
try:
from api.profiles import _profile_secret_env_names, get_active_hermes_home
_strip.update(_profile_secret_env_names(get_active_hermes_home()))
except Exception:
pass
_strip.update({"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"})
for env_name in _strip:
env.pop(env_name, None)
env["HERMES_HOME"] = str(Path(home))

View file

@ -793,3 +793,56 @@ def test_detached_worker_scope_scrubs_anthropic_token_aliases(monkeypatch, tmp_p
assert os.environ.get("ANTHROPIC_TOKEN") == "process-default-anthropic-token"
assert os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") == "process-default-oauth-token"
def test_account_usage_subprocess_env_strips_non_registry_agent_creds(monkeypatch, tmp_path):
"""Quota probes must not inherit process-default credential env vars the agent
resolves via raw os.getenv() but that are NOT in the auth registry — the
generic CUSTOM_API_KEY and the AWS/Bedrock credential family. Otherwise a
custom/AWS-backed provider quota probe leaks the server-process credential
to an empty named profile (#3961 residual leak class)."""
from api.providers import _account_usage_subprocess_env
base = tmp_path / ".hermes"
work_home = base / "profiles" / "work"
work_home.mkdir(parents=True)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
monkeypatch.setenv("CUSTOM_API_KEY", "process-default-custom-key")
monkeypatch.setenv("AWS_PROFILE", "process-default-aws-profile")
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "process-default-bedrock-token")
profiles.set_request_profile("work")
try:
with profiles.profile_env_for_active_request_readonly("quota probe"):
env = _account_usage_subprocess_env(work_home, "openai", None)
finally:
profiles.clear_request_profile()
assert env["HERMES_HOME"] == str(work_home)
assert "CUSTOM_API_KEY" not in env
assert "AWS_PROFILE" not in env
assert "AWS_BEARER_TOKEN_BEDROCK" not in env
def test_detached_worker_scope_scrubs_non_registry_agent_creds(monkeypatch, tmp_path):
"""Detached/sync model-rebuild scope must scrub the non-registry agent
credential env vars (CUSTOM_API_KEY, AWS/Bedrock family) too — the agent's
custom-provider and bedrock-adapter paths resolve them via raw os.getenv(),
so an empty named profile must not see the server-process value (#3961)."""
base = tmp_path / ".hermes"
work_home = base / "profiles" / "work"
work_home.mkdir(parents=True)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
monkeypatch.setenv("CUSTOM_API_KEY", "process-default-custom-key")
monkeypatch.setenv("AWS_PROFILE", "process-default-aws-profile")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "process-default-aws-secret")
with profiles.profile_scope_for_detached_worker("work", "test-worker"):
assert os.environ.get("CUSTOM_API_KEY") is None
assert os.environ.get("AWS_PROFILE") is None
assert os.environ.get("AWS_SECRET_ACCESS_KEY") is None
assert config._thread_local_env_value("CUSTOM_API_KEY") == ""
assert os.environ.get("CUSTOM_API_KEY") == "process-default-custom-key"
assert os.environ.get("AWS_PROFILE") == "process-default-aws-profile"
assert os.environ.get("AWS_SECRET_ACCESS_KEY") == "process-default-aws-secret"