From 4eb069be97bf9ac7f20d58a70aaf663add121ee0 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 19:19:56 +0000 Subject: [PATCH] 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 --- api/profiles.py | 47 +++++++++++++--- api/providers.py | 8 ++- ...test_issue3957_profile_providers_models.py | 53 +++++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index e2812f66..3783316f 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -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 diff --git a/api/providers.py b/api/providers.py index dc1ec76f..ec4a1b49 100644 --- a/api/providers.py +++ b/api/providers.py @@ -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)) diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index cf5a4ecf..8ba3c68b 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -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" +