From 88e3a19a6b65093f16b4aef62a0a11a39a9cbaa7 Mon Sep 17 00:00:00 2001 From: Chad Date: Tue, 14 Jul 2026 07:18:56 -0400 Subject: [PATCH 1/5] review(runtime): preserve Anthropic config-dir selection --- agent/anthropic_adapter.py | 61 ++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index c124205c1782..bc0495c2eb40 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -926,18 +926,37 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]: return None +def _explicit_claude_config_dir() -> Optional[Path]: + """Return CLAUDE_CONFIG_DIR as a Path when the caller pins a Claude account. + + Claude Code supports account isolation through CLAUDE_CONFIG_DIR. Hermes + should honor the same convention so multi-account Claude wrappers can route + Anthropic OAuth requests to a specific account instead of the default + ~/.claude account or the global macOS Keychain entry. + """ + raw = os.getenv("CLAUDE_CONFIG_DIR", "").strip() + if not raw: + return None + return Path(raw).expanduser() + + def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]: - """Read Claude Code OAuth credentials from ~/.claude/.credentials.json. + """Read Claude Code OAuth credentials from the selected config directory. + + If CLAUDE_CONFIG_DIR is set, read only that account's .credentials.json. + Otherwise fall back to ~/.claude/.credentials.json. Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None. """ - cred_path = Path.home() / ".claude" / ".credentials.json" + config_dir = _explicit_claude_config_dir() + cred_path = (config_dir if config_dir else Path.home() / ".claude") / ".credentials.json" if not cred_path.exists(): + logger.debug("Claude Code credentials file does not exist: %s", cred_path) return None try: data = json.loads(cred_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError, IOError) as e: - logger.debug("Failed to read ~/.claude/.credentials.json: %s", e) + logger.debug("Failed to read Claude Code credentials file %s: %s", cred_path, e) return None oauth_data = data.get("claudeAiOauth") @@ -950,18 +969,20 @@ def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]: "accessToken": access_token, "refreshToken": oauth_data.get("refreshToken", ""), "expiresAt": oauth_data.get("expiresAt", 0), - "source": "claude_code_credentials_file", + "source": "claude_config_dir_credentials_file" if config_dir else "claude_code_credentials_file", } def read_claude_code_credentials() -> Optional[Dict[str, Any]]: """Read refreshable Claude Code OAuth credentials. - Reads from two possible sources and reconciles them: - 1. macOS Keychain (Darwin only) — "Claude Code-credentials" entry - 2. ~/.claude/.credentials.json file + Reads from these sources and reconciles them: + 1. CLAUDE_CONFIG_DIR/.credentials.json when CLAUDE_CONFIG_DIR is set + (strict account pinning; skip global Keychain/default account) + 2. macOS Keychain (Darwin only) — "Claude Code-credentials" entry + 3. ~/.claude/.credentials.json file - Selection rules when both are present: + Selection rules when the default Keychain/file sources are both present: - If exactly one is non-expired, prefer that one. (Handles the case where Claude Code refreshes one source but not the other — observed in the wild on Claude Code 2.1.x.) @@ -970,11 +991,14 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: This intentionally excludes ~/.claude.json primaryApiKey. Opencode's subscription flow is OAuth/setup-token based with refreshable credentials, - and native direct Anthropic provider usage should follow that path rather - than auto-detecting Claude's first-party managed key. + and native direct Anthropic provider usage should follow that path + rather than auto-detecting Claude's first-party managed key. Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None. """ + if _explicit_claude_config_dir(): + return _read_claude_code_credentials_from_file() + kc_creds = _read_claude_code_credentials_from_keychain() file_creds = _read_claude_code_credentials_from_file() @@ -1277,13 +1301,19 @@ def resolve_anthropic_token() -> Optional[str]: Priority: 1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes) 2. CLAUDE_CODE_OAUTH_TOKEN env var - 3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json) - — with automatic refresh if expired and a refresh token is available + 3. Claude Code credentials (CLAUDE_CONFIG_DIR when set, otherwise + ~/.claude.json or ~/.claude/.credentials.json) — with automatic + refresh if expired and a refresh token is available 4. Anthropic credential_pool OAuth entry (~/.hermes/auth.json) 5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback) + When CLAUDE_CONFIG_DIR is set, the selected Claude account is strict: + Hermes will not silently fall back to the default account, credential_pool, + or ANTHROPIC_API_KEY if that account's OAuth credentials are unusable. + Returns the token string or None. """ + explicit_config_dir = _explicit_claude_config_dir() creds = read_claude_code_credentials() # 1. Hermes-managed OAuth/setup token env var @@ -1307,6 +1337,13 @@ def resolve_anthropic_token() -> Optional[str]: if resolved_claude_token: return resolved_claude_token + if explicit_config_dir: + logger.warning( + "CLAUDE_CONFIG_DIR is set to %s but no usable Claude Code OAuth token was resolved; refusing API-key fallback", + explicit_config_dir, + ) + return None + # 4. Hermes credential_pool OAuth entry. resolved_pool_token = _resolve_anthropic_pool_token() if resolved_pool_token: From fac02c639aafed3388c1010e538ec17d9eecb6d6 Mon Sep 17 00:00:00 2001 From: Chad Date: Tue, 14 Jul 2026 09:08:54 -0400 Subject: [PATCH 2/5] fix(auth): isolate Anthropic config account selection --- agent/anthropic_adapter.py | 102 +++- .../agent/test_anthropic_config_isolation.py | 500 ++++++++++++++++++ 2 files changed, 584 insertions(+), 18 deletions(-) create mode 100644 tests/agent/test_anthropic_config_isolation.py diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index bc0495c2eb40..579baf5b25da 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -902,6 +902,9 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]: logger.debug("Keychain: no entry found for 'Claude Code-credentials'") return None + if not isinstance(result.stdout, str): + logger.debug("Keychain: credentials payload is not text") + return None raw = result.stdout.strip() if not raw: return None @@ -926,21 +929,47 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]: return None +class _ClaudeConfigDirError(ValueError): + """Raised when an explicit Claude account directory is not deterministic.""" + + def _explicit_claude_config_dir() -> Optional[Path]: - """Return CLAUDE_CONFIG_DIR as a Path when the caller pins a Claude account. + """Return the canonical CLAUDE_CONFIG_DIR for a pinned Claude account. Claude Code supports account isolation through CLAUDE_CONFIG_DIR. Hermes should honor the same convention so multi-account Claude wrappers can route Anthropic OAuth requests to a specific account instead of the default ~/.claude account or the global macOS Keychain entry. + + A configured path is an authority boundary: it must be absolute after + ``~`` expansion, resolve to an existing directory, and have no broken + symlink component. Invalid configured paths raise rather than degrading to + the default account. """ raw = os.getenv("CLAUDE_CONFIG_DIR", "").strip() if not raw: return None - return Path(raw).expanduser() + selected = Path(raw).expanduser() + if not selected.is_absolute(): + raise _ClaudeConfigDirError( + "CLAUDE_CONFIG_DIR must be absolute after user expansion" + ) + try: + canonical = selected.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise _ClaudeConfigDirError( + "CLAUDE_CONFIG_DIR does not resolve to an existing directory" + ) from exc + if not canonical.is_dir(): + raise _ClaudeConfigDirError( + "CLAUDE_CONFIG_DIR canonical target is not a directory" + ) + return canonical -def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]: +def _read_claude_code_credentials_from_file( + config_dir: Optional[Path] = None, +) -> Optional[Dict[str, Any]]: """Read Claude Code OAuth credentials from the selected config directory. If CLAUDE_CONFIG_DIR is set, read only that account's .credentials.json. @@ -948,7 +977,8 @@ def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]: Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None. """ - config_dir = _explicit_claude_config_dir() + if config_dir is None: + config_dir = _explicit_claude_config_dir() cred_path = (config_dir if config_dir else Path.home() / ".claude") / ".credentials.json" if not cred_path.exists(): logger.debug("Claude Code credentials file does not exist: %s", cred_path) @@ -996,8 +1026,13 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None. """ - if _explicit_claude_config_dir(): - return _read_claude_code_credentials_from_file() + try: + config_dir = _explicit_claude_config_dir() + except _ClaudeConfigDirError as exc: + logger.warning("Invalid CLAUDE_CONFIG_DIR; refusing credential fallback: %s", exc) + return None + if config_dir: + return _read_claude_code_credentials_from_file(config_dir) kc_creds = _read_claude_code_credentials_from_keychain() file_creds = _read_claude_code_credentials_from_file() @@ -1157,14 +1192,19 @@ def _write_claude_code_credentials( *, scopes: Optional[list] = None, ) -> None: - """Write refreshed credentials back to ~/.claude/.credentials.json. + """Write refreshed credentials to the selected or default Claude account. The optional *scopes* list (e.g. ``["user:inference", "user:profile", ...]``) is persisted so that Claude Code's own auth check recognises the credential as valid. Claude Code >=2.1.81 gates on the presence of ``"user:inference"`` in the stored scopes before it will use the token. """ - cred_path = Path.home() / ".claude" / ".credentials.json" + try: + config_dir = _explicit_claude_config_dir() + except _ClaudeConfigDirError as exc: + logger.warning("Invalid CLAUDE_CONFIG_DIR; refusing credential write: %s", exc) + return + cred_path = (config_dir if config_dir else Path.home() / ".claude") / ".credentials.json" try: # Read existing file to preserve other fields existing = {} @@ -1313,26 +1353,52 @@ def resolve_anthropic_token() -> Optional[str]: Returns the token string or None. """ - explicit_config_dir = _explicit_claude_config_dir() - creds = read_claude_code_credentials() + token = os.getenv("ANTHROPIC_TOKEN", "").strip() + cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + + try: + explicit_config_dir = _explicit_claude_config_dir() + except _ClaudeConfigDirError as exc: + # Explicit token sources remain higher priority than the selected + # directory. Without either token, an invalid selection is a strict + # failure and must not reach any default/keychain/pool/API-key source. + if token: + return token + if cc_token: + return cc_token + logger.warning("Invalid CLAUDE_CONFIG_DIR; refusing credential fallback: %s", exc) + return None + + creds = ( + _read_claude_code_credentials_from_file(explicit_config_dir) + if explicit_config_dir and not (token or cc_token) + else None + ) # 1. Hermes-managed OAuth/setup token env var - token = os.getenv("ANTHROPIC_TOKEN", "").strip() if token: - preferred = _prefer_refreshable_claude_code_token(token, creds) - if preferred: - return preferred + # Preserve the legacy refreshable-file preference only for the + # unpinned default account. An explicitly selected account is source + # #3 and cannot override source #1. + if explicit_config_dir is None: + creds = read_claude_code_credentials() + preferred = _prefer_refreshable_claude_code_token(token, creds) + if preferred: + return preferred return token # 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens) - cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip() if cc_token: - preferred = _prefer_refreshable_claude_code_token(cc_token, creds) - if preferred: - return preferred + if explicit_config_dir is None: + creds = read_claude_code_credentials() + preferred = _prefer_refreshable_claude_code_token(cc_token, creds) + if preferred: + return preferred return cc_token # 3. Claude Code credential file + if explicit_config_dir is None: + creds = read_claude_code_credentials() resolved_claude_token = _resolve_claude_code_token_from_credentials(creds) if resolved_claude_token: return resolved_claude_token diff --git a/tests/agent/test_anthropic_config_isolation.py b/tests/agent/test_anthropic_config_isolation.py new file mode 100644 index 000000000000..f72e8808e791 --- /dev/null +++ b/tests/agent/test_anthropic_config_isolation.py @@ -0,0 +1,500 @@ +"""Hermetic credential-boundary tests for Anthropic account selection. + +Every credential value is synthetic. The fixture redirects HOME and +HERMES_HOME before importing the adapter, stubs Keychain, pool, and OAuth +refresh access, and never reads operator credential state. +""" + +import importlib +import json +import logging +import time +from types import SimpleNamespace + +import pytest + + +FUTURE_MS = int(time.time() * 1000) + 3_600_000 +PAST_MS = 1 +_CREDENTIAL_ENV = ( + "ANTHROPIC_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", + "ANTHROPIC_API_KEY", +) + + +def _oauth_record(token, *, refresh="synthetic-refresh", expires_at=FUTURE_MS): + return { + "claudeAiOauth": { + "accessToken": token, + "refreshToken": refresh, + "expiresAt": expires_at, + } + } + + +def _write_record(config_dir, token, *, refresh="synthetic-refresh", expires_at=FUTURE_MS): + config_dir.mkdir(parents=True, exist_ok=True) + path = config_dir / ".credentials.json" + path.write_text( + json.dumps( + _oauth_record(token, refresh=refresh, expires_at=expires_at) + ), + encoding="utf-8", + ) + return path + + +@pytest.fixture +def isolated_anthropic(tmp_path, monkeypatch): + """Install synthetic roots and deny all external credential access.""" + home = tmp_path / "home" + hermes_home = tmp_path / "hermes" + cache = tmp_path / "cache" + home.mkdir() + hermes_home.mkdir() + cache.mkdir() + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("XDG_CACHE_HOME", str(cache)) + for name in _CREDENTIAL_ENV: + monkeypatch.delenv(name, raising=False) + + adapter = importlib.import_module("agent.anthropic_adapter") + pool_module = importlib.import_module("agent.credential_pool") + real_keychain_reader = adapter._read_claude_code_credentials_from_keychain + + keychain_calls = [] + pool_calls = [] + refresh_calls = [] + + def _empty_keychain(): + keychain_calls.append("read") + return None + + empty_pool = SimpleNamespace( + _available_entries=lambda **kwargs: pool_calls.append(kwargs) or [] + ) + + def _load_empty_pool(provider): + pool_calls.append({"provider": provider}) + return empty_pool + + def _deny_refresh(*args, **kwargs): + refresh_calls.append((args, kwargs)) + raise AssertionError("network refresh is forbidden in the isolated harness") + + monkeypatch.setattr( + adapter, "_read_claude_code_credentials_from_keychain", _empty_keychain + ) + monkeypatch.setattr(pool_module, "load_pool", _load_empty_pool) + monkeypatch.setattr(adapter, "refresh_anthropic_oauth_pure", _deny_refresh) + + return SimpleNamespace( + adapter=adapter, + home=home, + hermes_home=hermes_home, + cache=cache, + keychain_calls=keychain_calls, + pool_calls=pool_calls, + refresh_calls=refresh_calls, + pool_module=pool_module, + real_keychain_reader=real_keychain_reader, + ) + + +def test_anthropic_token_precedes_conflicting_explicit_and_selected_sources( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + selected = harness.home / "selected" + _write_record(selected, "synthetic-selected") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected)) + monkeypatch.setenv("ANTHROPIC_TOKEN", "synthetic-anthropic-env") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "synthetic-claude-env") + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() == "synthetic-anthropic-env" + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_claude_oauth_token_precedes_selected_and_lower_sources( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + selected = harness.home / "selected" + _write_record(selected, "synthetic-selected") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected)) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "synthetic-claude-env") + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() == "synthetic-claude-env" + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_explicit_token_survives_invalid_lower_priority_selection( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + monkeypatch.setenv("CLAUDE_CONFIG_DIR", "relative/account") + monkeypatch.setenv("ANTHROPIC_TOKEN", "synthetic-anthropic-env") + + assert harness.adapter.resolve_anthropic_token() == "synthetic-anthropic-env" + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_selected_config_directory_is_canonical_and_exclusive( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + selected = harness.home / "accounts" / "selected" + _write_record(selected, "synthetic-selected") + _write_record(harness.home / ".claude", "synthetic-default") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected.parent / ".." / "accounts" / "selected")) + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter._explicit_claude_config_dir() == selected.resolve() + assert harness.adapter.resolve_anthropic_token() == "synthetic-selected" + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_tilde_selected_directory_expands_inside_isolated_home( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + selected = harness.home / "claude-account" + _write_record(selected, "synthetic-selected") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", "~/claude-account") + + assert harness.adapter._explicit_claude_config_dir() == selected.resolve() + assert harness.adapter.resolve_anthropic_token() == "synthetic-selected" + + +def test_default_file_fallback_without_selection(isolated_anthropic): + harness = isolated_anthropic + _write_record(harness.home / ".claude", "synthetic-default") + + assert harness.adapter.resolve_anthropic_token() == "synthetic-default" + assert harness.keychain_calls == ["read"] + assert harness.pool_calls == [] + + +@pytest.mark.parametrize("empty_selection", ["", " "]) +def test_empty_or_whitespace_selection_preserves_default_fallback( + empty_selection, isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + _write_record(harness.home / ".claude", "synthetic-default") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", empty_selection) + + assert harness.adapter.resolve_anthropic_token() == "synthetic-default" + + +def test_keychain_fallback_without_selection(isolated_anthropic, monkeypatch): + harness = isolated_anthropic + monkeypatch.setattr( + harness.adapter, + "_read_claude_code_credentials_from_keychain", + lambda: { + "accessToken": "synthetic-keychain", + "refreshToken": "synthetic-refresh", + "expiresAt": FUTURE_MS, + "source": "macos_keychain", + }, + ) + + assert harness.adapter.resolve_anthropic_token() == "synthetic-keychain" + assert harness.pool_calls == [] + + +def test_non_text_keychain_payload_fails_closed(isolated_anthropic, monkeypatch): + harness = isolated_anthropic + monkeypatch.setattr(harness.adapter.platform, "system", lambda: "Darwin") + monkeypatch.setattr( + harness.adapter.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout=SimpleNamespace(), + ), + ) + + assert harness.real_keychain_reader() is None + + +def test_default_file_and_keychain_conflict_reconciles_by_freshness( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + _write_record( + harness.home / ".claude", + "synthetic-file-newer", + expires_at=FUTURE_MS, + ) + monkeypatch.setattr( + harness.adapter, + "_read_claude_code_credentials_from_keychain", + lambda: { + "accessToken": "synthetic-keychain-older", + "refreshToken": "synthetic-refresh", + "expiresAt": FUTURE_MS - 60_000, + "source": "macos_keychain", + }, + ) + + assert harness.adapter.resolve_anthropic_token() == "synthetic-file-newer" + + +def test_credential_pool_fallback_is_read_only(isolated_anthropic, monkeypatch): + harness = isolated_anthropic + captured = [] + entry = SimpleNamespace(auth_type="oauth", access_token="synthetic-pool") + pool = SimpleNamespace( + _available_entries=lambda **kwargs: captured.append(kwargs) or [entry] + ) + monkeypatch.setattr(harness.pool_module, "load_pool", lambda provider: pool) + + assert harness.adapter.resolve_anthropic_token() == "synthetic-pool" + assert captured == [{"clear_expired": False, "refresh": False}] + + +def test_api_key_fallback_without_higher_source(isolated_anthropic, monkeypatch): + harness = isolated_anthropic + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() == "synthetic-api-key" + + +def test_missing_credentials_returns_none(isolated_anthropic): + assert isolated_anthropic.adapter.resolve_anthropic_token() is None + + +@pytest.mark.parametrize("selected_value", ["relative/account", "./account"]) +def test_relative_selected_path_fails_closed( + selected_value, isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + _write_record(harness.home / ".claude", "synthetic-default") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", selected_value) + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() is None + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_nonexistent_selected_path_fails_closed(isolated_anthropic, monkeypatch): + harness = isolated_anthropic + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(harness.home / "missing")) + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() is None + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_existing_selected_directory_without_record_fails_closed( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + selected = harness.home / "empty-selected" + selected.mkdir() + _write_record(harness.home / ".claude", "synthetic-default") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected)) + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() is None + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_broken_symlink_selected_path_fails_closed(isolated_anthropic, monkeypatch): + harness = isolated_anthropic + link = harness.home / "broken-link" + link.symlink_to(harness.home / "missing-target", target_is_directory=True) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(link)) + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() is None + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_valid_directory_symlink_is_canonicalized_and_accepted( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + target = harness.home / "canonical-account" + _write_record(target, "synthetic-selected") + link = harness.home / "account-link" + link.symlink_to(target, target_is_directory=True) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(link)) + + assert harness.adapter._explicit_claude_config_dir() == target.resolve() + assert harness.adapter.resolve_anthropic_token() == "synthetic-selected" + + +def test_selected_path_resolving_to_file_fails_closed( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + selected_file = harness.home / "not-a-directory" + selected_file.write_text("synthetic", encoding="utf-8") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected_file)) + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() is None + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +@pytest.mark.parametrize( + "record", + [ + "{not-json", + json.dumps({"claudeAiOauth": {"refreshToken": "synthetic-refresh"}}), + ], +) +def test_malformed_or_tokenless_selected_record_fails_closed( + record, isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + selected = harness.home / "selected" + selected.mkdir() + (selected / ".credentials.json").write_text(record, encoding="utf-8") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected)) + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() is None + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_unreadable_selected_record_fails_closed(isolated_anthropic, monkeypatch): + harness = isolated_anthropic + selected = harness.home / "selected" + selected_path = _write_record(selected, "synthetic-selected") + original_read_text = harness.adapter.Path.read_text + + def _raise_for_selected(path, *args, **kwargs): + if path == selected_path: + raise OSError("synthetic unreadable record") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(harness.adapter.Path, "read_text", _raise_for_selected) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected)) + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() is None + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_selected_expired_record_refreshes_and_writes_only_selected_account( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + selected = harness.home / "selected" + selected_path = _write_record( + selected, + "synthetic-expired", + refresh="synthetic-refresh-old", + expires_at=PAST_MS, + ) + default_path = _write_record(harness.home / ".claude", "synthetic-default") + default_before = default_path.read_bytes() + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected)) + + calls = [] + + def _synthetic_refresh(refresh_token, *, use_json=False): + calls.append((refresh_token, use_json)) + return { + "access_token": "synthetic-refreshed", + "refresh_token": "synthetic-refresh-new", + "expires_at_ms": FUTURE_MS, + } + + monkeypatch.setattr( + harness.adapter, "refresh_anthropic_oauth_pure", _synthetic_refresh + ) + + assert harness.adapter.resolve_anthropic_token() == "synthetic-refreshed" + assert calls == [("synthetic-refresh-old", False)] + assert json.loads(selected_path.read_text(encoding="utf-8"))[ + "claudeAiOauth" + ]["accessToken"] == "synthetic-refreshed" + assert default_path.read_bytes() == default_before + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_selected_refresh_failure_does_not_fall_through( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + selected = harness.home / "selected" + _write_record( + selected, + "synthetic-expired", + refresh="synthetic-refresh-old", + expires_at=PAST_MS, + ) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected)) + monkeypatch.setenv("ANTHROPIC_API_KEY", "synthetic-api-key") + + assert harness.adapter.resolve_anthropic_token() is None + assert len(harness.refresh_calls) == 1 + assert harness.keychain_calls == [] + assert harness.pool_calls == [] + + +def test_config_isolation_across_accounts(isolated_anthropic, monkeypatch): + harness = isolated_anthropic + account_a = harness.home / "accounts" / "a" + account_b = harness.home / "accounts" / "b" + _write_record(account_a, "synthetic-account-a") + _write_record(account_b, "synthetic-account-b") + + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(account_a)) + assert harness.adapter.resolve_anthropic_token() == "synthetic-account-a" + + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(account_b)) + assert harness.adapter.resolve_anthropic_token() == "synthetic-account-b" + + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(account_a)) + assert harness.adapter.resolve_anthropic_token() == "synthetic-account-a" + + +def test_failure_diagnostics_do_not_expose_synthetic_secrets( + isolated_anthropic, monkeypatch, caplog +): + harness = isolated_anthropic + selected = harness.home / "selected" + synthetic_secrets = ( + "synthetic-access-secret", + "synthetic-refresh-secret", + "synthetic-api-secret", + ) + _write_record( + selected, + synthetic_secrets[0], + refresh=synthetic_secrets[1], + expires_at=PAST_MS, + ) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(selected)) + monkeypatch.setenv("ANTHROPIC_API_KEY", synthetic_secrets[2]) + + with caplog.at_level(logging.DEBUG, logger=harness.adapter.__name__): + assert harness.adapter.resolve_anthropic_token() is None + + log_text = caplog.text + for secret in synthetic_secrets: + assert secret not in log_text + assert "Authorization" not in log_text From 20a5ad2913699bc0a1b51f7ce2a26a07ecc4ec1a Mon Sep 17 00:00:00 2001 From: Chad Date: Tue, 14 Jul 2026 11:02:11 -0400 Subject: [PATCH 3/5] fix(auth): enforce selected Anthropic account boundary --- agent/credential_pool.py | 15 ++++++-- hermes_cli/runtime_provider.py | 6 +++ tests/agent/test_credential_pool.py | 37 +++++++++++++++++++ .../test_runtime_provider_resolution.py | 33 +++++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 2c7a4825e8d0..c061fa755806 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1837,10 +1837,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 ( + singleton_credentials = [ ("hermes_pkce", read_hermes_oauth_credentials()), - ("claude_code", read_claude_code_credentials()), - ): + ] + # CLAUDE_CONFIG_DIR is an explicit account-selection boundary. The + # selected credential may be used directly by the Anthropic adapter, + # but must never be copied into the shared rotation pool where another + # session/account could select it later. + if not (_get_secret("CLAUDE_CONFIG_DIR", "") or "").strip(): + singleton_credentials.append( + ("claude_code", read_claude_code_credentials()) + ) + + for source_name, creds in singleton_credentials: if creds and creds.get("accessToken"): if _is_suppressed(provider, source_name): continue diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index ddc7ccecd507..a8fc9e56d42d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1666,6 +1666,12 @@ def resolve_runtime_provider( return explicit_runtime should_use_pool = provider != "openrouter" + if provider == "anthropic" and _getenv("CLAUDE_CONFIG_DIR", "").strip(): + # A selected Claude config directory is an exclusive account boundary. + # Pool selection happens before resolve_anthropic_token(), so allowing + # the pool here could silently choose another account or bypass a + # malformed selected path instead of failing closed. + should_use_pool = False if provider == "openrouter": cfg_provider = str(model_cfg.get("provider") or "").strip().lower() cfg_base_url = str(model_cfg.get("base_url") or "").strip() diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index d9252a7829c8..b6bb7065d530 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1661,6 +1661,43 @@ def test_load_pool_prefers_anthropic_env_token_over_file_backed_oauth(tmp_path, assert entry.access_token == "env-override-token" +def test_selected_claude_config_credential_is_never_seeded_into_shared_pool( + tmp_path, monkeypatch +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "selected-account")) + 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": {}}) + monkeypatch.setattr( + "hermes_cli.auth.is_provider_explicitly_configured", lambda _pid: True + ) + monkeypatch.setattr( + "agent.anthropic_adapter.read_hermes_oauth_credentials", lambda: None + ) + selected_reader_calls = [] + + def _selected_reader(): + selected_reader_calls.append(True) + return { + "accessToken": "synthetic-selected-token", + "refreshToken": "synthetic-selected-refresh", + "expiresAt": int(time.time() * 1000) + 3_600_000, + } + + monkeypatch.setattr( + "agent.anthropic_adapter.read_claude_code_credentials", _selected_reader + ) + + from agent.credential_pool import load_pool + + pool = load_pool("anthropic") + + assert selected_reader_calls == [] + assert pool.entries() == [] + + def test_load_pool_api_key_path_skips_oauth_autodiscovery(tmp_path, monkeypatch): """API-key auth path: autodiscovered OAuth creds must NOT be seeded. diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index df47efcf8396..000bc426374e 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -45,6 +45,39 @@ def select(self): assert resolved["source"] == "manual" +@pytest.mark.parametrize("selected_dir", ["/synthetic/claude-account", "relative/account"]) +def test_anthropic_selected_config_dir_skips_pool_before_strict_resolution( + selected_dir, monkeypatch +): + """A selected account cannot be shadowed by an unrelated pool entry.""" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", selected_dir) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "anthropic") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: {"provider": "anthropic", "base_url": "https://api.anthropic.com"}, + ) + + def _pool_must_not_load(_provider): + raise AssertionError("selected Claude account must bypass credential pool") + + monkeypatch.setattr(rp, "load_pool", _pool_must_not_load) + if selected_dir.startswith("/"): + monkeypatch.setattr( + "agent.anthropic_adapter.resolve_anthropic_token", + lambda: "synthetic-selected-token", + ) + resolved = rp.resolve_runtime_provider(requested="anthropic") + assert resolved["api_key"] == "synthetic-selected-token" + else: + monkeypatch.setattr( + "agent.anthropic_adapter.resolve_anthropic_token", + lambda: None, + ) + with pytest.raises(rp.AuthError, match="No Anthropic credentials found"): + rp.resolve_runtime_provider(requested="anthropic") + + def test_resolve_runtime_provider_nous_pool_uses_env_base_url_override(monkeypatch): entry = SimpleNamespace( provider="nous", From 546275eff5f39773f58edef846bf2dd75d99288c Mon Sep 17 00:00:00 2001 From: Chad Date: Tue, 14 Jul 2026 11:06:30 -0400 Subject: [PATCH 4/5] test(auth): isolate legacy Anthropic refresh cases --- tests/agent/test_anthropic_adapter.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 9610f7fb57cb..26ebad50d616 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -507,6 +507,19 @@ def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exist class TestRefreshOauthToken: + @pytest.fixture(autouse=True) + def isolate_credential_sources(self, tmp_path, monkeypatch): + """Keep refresh tests from consulting the operator's credential stores.""" + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr( + "agent.anthropic_adapter._read_claude_code_credentials_from_keychain", + lambda: None, + ) + def test_returns_none_without_refresh_token(self): creds = {"accessToken": "expired", "refreshToken": "", "expiresAt": 0} assert _refresh_oauth_token(creds) is None From efdcf2728e29b0fd26eb3c4e161c9dc1ac6261a8 Mon Sep 17 00:00:00 2001 From: Chad Date: Tue, 14 Jul 2026 17:32:58 -0400 Subject: [PATCH 5/5] fix: enforce scoped Anthropic account authority --- agent/anthropic_adapter.py | 11 +- .../agent/test_anthropic_config_isolation.py | 105 ++++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 579baf5b25da..6cec10f5b5db 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -21,6 +21,7 @@ from pathlib import Path from urllib.parse import urlparse +from agent.secret_scope import get_secret as _get_secret from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple from utils import base_url_host_matches, normalize_proxy_env_vars @@ -946,7 +947,7 @@ def _explicit_claude_config_dir() -> Optional[Path]: symlink component. Invalid configured paths raise rather than degrading to the default account. """ - raw = os.getenv("CLAUDE_CONFIG_DIR", "").strip() + raw = (_get_secret("CLAUDE_CONFIG_DIR", "") or "").strip() if not raw: return None selected = Path(raw).expanduser() @@ -1353,8 +1354,8 @@ def resolve_anthropic_token() -> Optional[str]: Returns the token string or None. """ - token = os.getenv("ANTHROPIC_TOKEN", "").strip() - cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + token = (_get_secret("ANTHROPIC_TOKEN", "") or "").strip() + cc_token = (_get_secret("CLAUDE_CODE_OAUTH_TOKEN", "") or "").strip() try: explicit_config_dir = _explicit_claude_config_dir() @@ -1417,7 +1418,7 @@ def resolve_anthropic_token() -> Optional[str]: # 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY. # This remains as a compatibility fallback for pre-migration Hermes configs. - api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() + api_key = (_get_secret("ANTHROPIC_API_KEY", "") or "").strip() if api_key: return api_key @@ -1460,7 +1461,7 @@ def run_oauth_setup_token() -> Optional[str]: # Check env vars that may have been set for env_var in ("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_TOKEN"): - val = os.getenv(env_var, "").strip() + val = (_get_secret(env_var, "") or "").strip() if val: return val diff --git a/tests/agent/test_anthropic_config_isolation.py b/tests/agent/test_anthropic_config_isolation.py index f72e8808e791..c548d0067292 100644 --- a/tests/agent/test_anthropic_config_isolation.py +++ b/tests/agent/test_anthropic_config_isolation.py @@ -9,6 +9,8 @@ import json import logging import time +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier from types import SimpleNamespace import pytest @@ -472,6 +474,109 @@ def test_config_isolation_across_accounts(isolated_anthropic, monkeypatch): assert harness.adapter.resolve_anthropic_token() == "synthetic-account-a" +def test_profile_scope_overrides_process_env_for_selected_account( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + scoped_account = harness.home / "accounts" / "scoped" + process_account = harness.home / "accounts" / "process" + _write_record(scoped_account, "synthetic-scoped-account") + _write_record(process_account, "synthetic-process-account") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(process_account)) + + from agent.secret_scope import reset_secret_scope, set_secret_scope + + token = set_secret_scope({"CLAUDE_CONFIG_DIR": str(scoped_account)}) + try: + assert harness.adapter.resolve_anthropic_token() == "synthetic-scoped-account" + finally: + reset_secret_scope(token) + + +def test_profile_scope_controls_explicit_token_precedence( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + scoped_account = harness.home / "accounts" / "scoped" + process_account = harness.home / "accounts" / "process" + _write_record(scoped_account, "synthetic-scoped-account") + _write_record(process_account, "synthetic-process-account") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(process_account)) + monkeypatch.setenv("ANTHROPIC_TOKEN", "synthetic-process-token") + + from agent.secret_scope import reset_secret_scope, set_secret_scope + + token = set_secret_scope( + { + "CLAUDE_CONFIG_DIR": str(scoped_account), + "ANTHROPIC_TOKEN": "synthetic-scoped-token", + } + ) + try: + assert harness.adapter.resolve_anthropic_token() == "synthetic-scoped-token" + finally: + reset_secret_scope(token) + + +def test_multiplex_authority_transition_never_replays_process_credentials( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + scoped_account = harness.home / "accounts" / "scoped" + _write_record(scoped_account, "synthetic-scoped-account") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(harness.home / "process-account")) + monkeypatch.setenv("ANTHROPIC_TOKEN", "synthetic-process-token") + + from agent import secret_scope + + secret_scope.set_multiplex_active(True) + token = secret_scope.set_secret_scope({"CLAUDE_CONFIG_DIR": str(scoped_account)}) + try: + assert harness.adapter.resolve_anthropic_token() == "synthetic-scoped-account" + finally: + secret_scope.reset_secret_scope(token) + + try: + with pytest.raises(secret_scope.UnscopedSecretError): + harness.adapter.resolve_anthropic_token() + finally: + secret_scope.set_multiplex_active(False) + + +def test_concurrent_profile_scopes_do_not_cross_contaminate_selected_accounts( + isolated_anthropic, monkeypatch +): + harness = isolated_anthropic + accounts = [] + for index in range(8): + account = harness.home / "accounts" / str(index) + _write_record(account, f"synthetic-account-{index}") + accounts.append(account) + monkeypatch.setenv("ANTHROPIC_TOKEN", "synthetic-process-token") + barrier = Barrier(len(accounts)) + + from agent import secret_scope + + def resolve_for(index): + token = secret_scope.set_secret_scope( + {"CLAUDE_CONFIG_DIR": str(accounts[index])} + ) + try: + barrier.wait(timeout=5) + return harness.adapter.resolve_anthropic_token() + finally: + secret_scope.reset_secret_scope(token) + + secret_scope.set_multiplex_active(True) + try: + with ThreadPoolExecutor(max_workers=len(accounts)) as executor: + results = list(executor.map(resolve_for, range(len(accounts)))) + finally: + secret_scope.set_multiplex_active(False) + + assert results == [f"synthetic-account-{index}" for index in range(len(accounts))] + + def test_failure_diagnostics_do_not_expose_synthetic_secrets( isolated_anthropic, monkeypatch, caplog ):