diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c67ddf2d9f..e984435bca 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -716,6 +716,57 @@ def get_active_provider() -> Optional[str]: return auth_store.get("active_provider") +def is_provider_explicitly_configured(provider_id: str) -> bool: + """Return True only if the user has explicitly configured this provider. + + Checks: + 1. active_provider in auth.json matches + 2. model.provider in config.yaml matches + 3. Provider-specific env vars are set (e.g. ANTHROPIC_API_KEY) + + This is used to gate auto-discovery of external credentials (e.g. + Claude Code's ~/.claude/.credentials.json) so they are never used + without the user's explicit choice. See PR #4210 for the same + pattern applied to the setup wizard gate. + """ + normalized = (provider_id or "").strip().lower() + + # 1. Check auth.json active_provider + try: + auth_store = _load_auth_store() + active = (auth_store.get("active_provider") or "").strip().lower() + if active and active == normalized: + return True + except Exception: + pass + + # 2. Check config.yaml model.provider + try: + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model") + if isinstance(model_cfg, dict): + cfg_provider = (model_cfg.get("provider") or "").strip().lower() + if cfg_provider == normalized: + return True + except Exception: + pass + + # 3. Check provider-specific env vars + # Exclude CLAUDE_CODE_OAUTH_TOKEN — it's set by Claude Code itself, + # not by the user explicitly configuring anthropic in Hermes. + _IMPLICIT_ENV_VARS = {"CLAUDE_CODE_OAUTH_TOKEN"} + pconfig = PROVIDER_REGISTRY.get(normalized) + if pconfig and pconfig.auth_type == "api_key": + for env_var in pconfig.api_key_env_vars: + if env_var in _IMPLICIT_ENV_VARS: + continue + if has_usable_secret(os.getenv(env_var, "")): + return True + + return False + + def clear_provider_auth(provider_id: Optional[str] = None) -> bool: """ Clear auth state for a provider. Used by `hermes logout`. diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py new file mode 100644 index 0000000000..2eacb71be7 --- /dev/null +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -0,0 +1,78 @@ +"""Tests for is_provider_explicitly_configured().""" + +import json +import os +import pytest + + +def _write_config(tmp_path, config: dict) -> None: + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + import yaml + (hermes_home / "config.yaml").write_text(yaml.dump(config)) + + +def _write_auth_store(tmp_path, payload: dict) -> None: + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps(payload, indent=2)) + + +def test_returns_false_when_no_config(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("anthropic") is False + + +def test_returns_true_when_active_provider_matches(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, { + "version": 1, + "providers": {}, + "active_provider": "anthropic", + }) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("anthropic") is True + + +def test_returns_true_when_config_provider_matches(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}}) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("anthropic") is True + + +def test_returns_false_when_config_provider_is_different(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_config(tmp_path, {"model": {"provider": "kimi-coding", "default": "kimi-k2"}}) + _write_auth_store(tmp_path, { + "version": 1, + "providers": {}, + "active_provider": None, + }) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("anthropic") is False + + +def test_returns_true_when_anthropic_env_var_set(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-realkey") + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("anthropic") is True + + +def test_claude_code_oauth_token_does_not_count_as_explicit(tmp_path, monkeypatch): + """CLAUDE_CODE_OAUTH_TOKEN is set by Claude Code, not the user — must not gate.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-auto-token") + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("anthropic") is False