Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Comment thread
exiao marked this conversation as resolved.
resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
if resolved_claude_token:
return resolved_claude_token
Expand Down
16 changes: 11 additions & 5 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions tests/agent/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions tests/agent/test_credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading