diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index cb80ffcfec66..73d0a911c0e3 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1540,7 +1540,23 @@ def resolve_anthropic_token() -> Optional[str]: Returns the token string or None. """ - creds = read_claude_code_credentials() + # Respect an explicit user suppression of the claude_code source BEFORE + # touching the Claude file / Keychain. The user can `hermes auth remove + # anthropic` the Claude Code credential, which does NOT delete + # ~/.claude/.credentials.json (Claude Code still owns it) but records a + # suppression marker so Hermes stops reading it. Honor that marker for EVERY + # profile, including the default/process-owner: skip the read entirely so a + # suppressed source never touches the file or the macOS Keychain, and never + # resurrects the removed credential at source #3 or via the + # _prefer_refreshable_claude_code_token shadowing at sources #1/#2. + try: + from hermes_cli.auth import is_source_suppressed + + claude_code_suppressed = is_source_suppressed("anthropic", "claude_code") + except Exception: + claude_code_suppressed = False + + creds = None if claude_code_suppressed else read_claude_code_credentials() # Reads route through get_secret so a multiplexed gateway resolves the # requesting profile's token (via the active _SECRET_SCOPE) instead of the @@ -1578,6 +1594,12 @@ def resolve_anthropic_token() -> Optional[str]: from agent.secret_scope import is_multiplex_active nondefault_scope = _scope_is_non_default_profile() and is_multiplex_active() + # NOTE: claude_code suppression is deliberately NOT folded into + # suppress_global_creds. suppress_global_creds also drives + # `profile_only=` on the pool lookup below; a profile that suppressed only + # the Claude-file source must still get the pool's global-root fallback for a + # valid inherited/manual OAuth pool entry. The Claude-file read is already + # gated above by claude_code_suppressed. suppress_global_creds = nondefault_scope or scope_has_api_key if suppress_global_creds: creds = None @@ -1602,8 +1624,11 @@ def resolve_anthropic_token() -> Optional[str]: # this reads the host's global default-profile ~/.claude record, which must # not resolve for another profile (passing creds=None here would just re-read # that global file — see _resolve_claude_code_token_from_credentials). The - # default profile owns ~/.claude, so it still resolves here as before. - if not suppress_global_creds: + # default profile owns ~/.claude, so it still resolves here as before — UNLESS + # the user suppressed the claude_code source, in which case even the default + # profile must not re-read the file here (creds=None above does NOT stop this + # resolver from re-reading the global file on its own). + if not suppress_global_creds and not claude_code_suppressed: resolved_claude_token = _resolve_claude_code_token_from_credentials(creds) if resolved_claude_token: return resolved_claude_token diff --git a/agent/credential_pool.py b/agent/credential_pool.py index edb64484bb30..6648ca80e847 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1866,13 +1866,19 @@ def _env_val(key: str) -> str: from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials - for source_name, creds in ( - ("hermes_pkce", read_hermes_oauth_credentials()), - ("claude_code", read_claude_code_credentials()), + # Check suppression BEFORE reading each source: read_claude_code_credentials() + # touches ~/.claude / the macOS Keychain, so a suppressed claude_code + # source must not be read at all (see resolve_anthropic_token's + # "not even read" contract). Pair each source with a lazy reader and + # only call it when the source is not suppressed. + for source_name, _reader in ( + ("hermes_pkce", read_hermes_oauth_credentials), + ("claude_code", read_claude_code_credentials), ): + if _is_suppressed(provider, source_name): + continue + creds = _reader() if creds and creds.get("accessToken"): - if _is_suppressed(provider, source_name): - continue active_sources.add(source_name) # Honor user-configured proxy base_url so that rotations to # this entry keep traffic on the proxy. Otherwise the entry diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index b89eedfad44d..78b0bcb9fffb 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -415,6 +415,66 @@ def test_falls_back_to_token(self, monkeypatch, tmp_path): monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "sk-ant-oat01-mytoken" + def test_suppressed_claude_code_source_is_not_read(self, monkeypatch, tmp_path): + """A user-suppressed claude_code source must not resolve, even for the + default profile. `hermes auth remove anthropic` leaves the Claude file + in place (Claude Code owns it) but records a suppression marker; the + Claude-file read (source #3) must honor it rather than resurrect the + removed credential. + """ + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + # A valid, non-expired Claude Code credential file exists on disk. + cred_file = tmp_path / ".claude" / ".credentials.json" + cred_file.parent.mkdir(parents=True) + cred_file.write_text(json.dumps({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-claudefile", + "refreshToken": "r", + "expiresAt": int(time.time() * 1000) + 3600_000, + } + })) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + # Without suppression it resolves the Claude file token (control). + monkeypatch.setattr( + "hermes_cli.auth.is_source_suppressed", + lambda provider, source: False, + ) + assert resolve_anthropic_token() == "sk-ant-oat01-claudefile" + + # With the claude_code source suppressed, it must NOT be read. + monkeypatch.setattr( + "hermes_cli.auth.is_source_suppressed", + lambda provider, source: provider == "anthropic" and source == "claude_code", + ) + assert resolve_anthropic_token() is None + + def test_suppressed_claude_code_source_is_not_even_read(self, monkeypatch, tmp_path): + """Suppression must be checked BEFORE read_claude_code_credentials so a + suppressed source never touches the Claude file / macOS Keychain at all. + """ + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr( + "hermes_cli.auth.is_source_suppressed", + lambda provider, source: provider == "anthropic" and source == "claude_code", + ) + calls = {"n": 0} + + def _boom(): + calls["n"] += 1 + raise AssertionError("read_claude_code_credentials must not be called when suppressed") + + monkeypatch.setattr( + "agent.anthropic_adapter.read_claude_code_credentials", _boom + ) + assert resolve_anthropic_token() is None + assert calls["n"] == 0 + def test_returns_none_with_no_creds(self, monkeypatch, tmp_path): monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index ae28580d8586..98ad3a098ed7 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1631,6 +1631,59 @@ def test_singleton_seed_does_not_clobber_manual_oauth_entry(tmp_path, monkeypatc assert {entry.source for entry in entries} == {"manual:hermes_pkce", "hermes_pkce"} +def test_load_pool_does_not_read_claude_code_when_suppressed(tmp_path, monkeypatch): + """A suppressed claude_code source must not be READ during pool seeding. + + Regression: _seed_from_singletons evaluated read_claude_code_credentials() + when building the (source, creds) list, before checking suppression, so the + Claude file / macOS Keychain was still touched despite the suppression + marker. Suppression must be checked before the read. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + _write_auth_store( + tmp_path, + { + "version": 1, + "providers": {}, + "suppressed_sources": {"anthropic": ["claude_code"]}, + }, + ) + + monkeypatch.setattr( + "agent.anthropic_adapter.read_hermes_oauth_credentials", + lambda: None, + ) + + def _boom(): + raise AssertionError( + "read_claude_code_credentials must not be called when claude_code is suppressed" + ) + + monkeypatch.setattr( + "agent.anthropic_adapter.read_claude_code_credentials", _boom + ) + # Reach the auto-discovery read loop: it is gated on the provider being + # explicitly configured, and skipped when the API-key path is explicit. + monkeypatch.setattr( + "hermes_cli.auth.is_provider_explicitly_configured", + lambda provider: provider == "anthropic", + ) + + from agent.credential_pool import PooledCredential, _seed_from_singletons + + entries: list[PooledCredential] = [] + # _seed_from_singletons builds the (source, reader) list and, before this + # fix, eagerly called read_claude_code_credentials() there — _boom would + # fire even though claude_code is suppressed. After the fix, suppression is + # checked first and the reader is never called. + changed, active = _seed_from_singletons("anthropic", entries) + assert "claude_code" not in active + assert "claude_code" not in {entry.source for entry in entries} + + def test_load_pool_prefers_anthropic_env_token_over_file_backed_oauth(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)