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:
parent
1293f0301e
commit
4eb069be97
3 changed files with 98 additions and 10 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue