Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
if resolved_claude_token:
return resolved_claude_token
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
Loading