diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e46780d2337..df0ccbe0350 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -666,6 +666,28 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str: return str(url or "").strip().rstrip("/") +# Hostnames (lowercase, exact) that the auxiliary Anthropic path is allowed to +# be pointed at via config.yaml model.base_url. Anything else falls back to the +# Anthropic default — operators routing main-session traffic through a +# non-Anthropic host (e.g. OpenRouter, OpenAI) with provider=anthropic in config +# must NOT have that foreign host leak into the auxiliary client. See #52608. +_ANTHROPIC_COMPATIBLE_HOSTS = frozenset({ + "api.anthropic.com", +}) + + +def _is_anthropic_compatible_host(url: str) -> bool: + """Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls.""" + if not url: + return False + try: + from urllib.parse import urlparse + host = (urlparse(url).hostname or "").strip().lower().rstrip(".") + return host in _ANTHROPIC_COMPATIBLE_HOSTS + except Exception: + return False + + def _nous_min_key_ttl_seconds() -> int: try: return max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))) @@ -2256,9 +2278,16 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona if not token: return None, None - # Allow base URL override from config.yaml model.base_url, but only - # when the configured provider is anthropic — otherwise a non-Anthropic - # base_url (e.g. Codex endpoint) would leak into Anthropic requests. + # Allow base URL override from config.yaml model.base_url, but only when: + # 1. the configured provider is anthropic (otherwise a non-Anthropic + # base_url, e.g. Codex endpoint, would leak into Anthropic requests), AND + # 2. the override URL actually points at an Anthropic-compatible endpoint. + # Without gate (2), operators who route main-session traffic through a + # non-Anthropic provider that accepts Anthropic-format requests (e.g. + # OpenRouter at openrouter.ai/api/v1, with provider=anthropic in config.yaml) + # would have every auxiliary side-channel call (memory extractors, + # reflection, vision, title generation) 401 from the foreign host — + # see issue #52608. base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL try: from hermes_cli.config import load_config @@ -2268,7 +2297,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona cfg_provider = str(model_cfg.get("provider") or "").strip().lower() if cfg_provider == "anthropic": cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/") - if cfg_base_url: + if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url): base_url = cfg_base_url except Exception: pass diff --git a/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py b/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py new file mode 100644 index 00000000000..bac71a2eab1 --- /dev/null +++ b/tests/agent/test_auxiliary_client_base_url_host_validation_52608.py @@ -0,0 +1,189 @@ +"""Regression tests for issue #52608. + +auxiliary_client `_try_anthropic()` must NOT apply `cfg["model"]["base_url"]` +when the configured base_url host is not an Anthropic-compatible endpoint +(e.g. OpenRouter, OpenAI). Operators routing main traffic through a +non-Anthropic provider's endpoint while keeping `provider: anthropic` would +otherwise have every side-channel call (memory extractors, reflection, +vision, title generation) 401 from the foreign host. +""" +from unittest.mock import MagicMock, patch + + +def _extract_base_url_passed_to_build(mock_build): + """Pull the base_url that `_try_anthropic()` actually handed to build_anthropic_client.""" + args, _kwargs = mock_build.call_args + # build_anthropic_client(token, base_url) per agent/auxiliary_client.py line 2180 + assert len(args) >= 2, f"expected (token, base_url), got args={args}" + return args[1] + + +class TestTryAnthropicBaseUrlHostValidation: + """Issue #52608: side-channel calls must not be sent to a non-Anthropic host.""" + + def test_openrouter_base_url_does_not_leak_into_auxiliary(self, tmp_path, monkeypatch): + """cfg.model.base_url=https://openrouter.ai/api/v1 must NOT override aux base_url.""" + import yaml + from agent.auxiliary_client import _try_anthropic + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "model": { + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "base_url": "https://openrouter.ai/api/v1", + } + })) + + with ( + patch( + "agent.auxiliary_client._select_pool_entry", return_value=(False, None) + ), + patch( + "agent.anthropic_adapter.resolve_anthropic_token", + return_value="***", + ), + patch( + "agent.anthropic_adapter.build_anthropic_client" + ) as mock_build, + ): + mock_build.return_value = MagicMock() + client, _model = _try_anthropic() + + assert client is not None, "auxiliary client must still be created" + actual = _extract_base_url_passed_to_build(mock_build) + assert actual == "https://api.anthropic.com", ( + f"Auxiliary client must use the Anthropic default base_url, " + f"not the operator's main-session override. Got: {actual!r}" + ) + + def test_anthropic_default_host_is_preserved(self, tmp_path, monkeypatch): + """The common case (operator sets model.base_url to api.anthropic.com) must still apply.""" + import yaml + from agent.auxiliary_client import _try_anthropic + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "model": { + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "base_url": "https://api.anthropic.com", + } + })) + + with ( + patch( + "agent.auxiliary_client._select_pool_entry", return_value=(False, None) + ), + patch( + "agent.anthropic_adapter.resolve_anthropic_token", + return_value="***", + ), + patch( + "agent.anthropic_adapter.build_anthropic_client" + ) as mock_build, + ): + mock_build.return_value = MagicMock() + client, _model = _try_anthropic() + + assert client is not None + actual = _extract_base_url_passed_to_build(mock_build) + assert actual == "https://api.anthropic.com" + + def test_openai_base_url_does_not_leak(self, tmp_path, monkeypatch): + """Generic non-Anthropic host must not be applied as auxiliary base_url.""" + import yaml + from agent.auxiliary_client import _try_anthropic + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "model": { + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "base_url": "https://api.openai.com/v1", + } + })) + + with ( + patch( + "agent.auxiliary_client._select_pool_entry", return_value=(False, None) + ), + patch( + "agent.anthropic_adapter.resolve_anthropic_token", + return_value="***", + ), + patch( + "agent.anthropic_adapter.build_anthropic_client" + ) as mock_build, + ): + mock_build.return_value = MagicMock() + client, _model = _try_anthropic() + + assert client is not None + actual = _extract_base_url_passed_to_build(mock_build) + assert actual == "https://api.anthropic.com", ( + f"Non-Anthropic host must not be applied. Got: {actual!r}" + ) + + def test_empty_base_url_falls_back_to_default(self, tmp_path, monkeypatch): + """Empty model.base_url must not crash and must fall back to default.""" + import yaml + from agent.auxiliary_client import _try_anthropic + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "model": { + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "base_url": "", + } + })) + + with ( + patch( + "agent.auxiliary_client._select_pool_entry", return_value=(False, None) + ), + patch( + "agent.anthropic_adapter.resolve_anthropic_token", + return_value="***", + ), + patch( + "agent.anthropic_adapter.build_anthropic_client" + ) as mock_build, + ): + mock_build.return_value = MagicMock() + client, _model = _try_anthropic() + + assert client is not None + actual = _extract_base_url_passed_to_build(mock_build) + assert actual == "https://api.anthropic.com" + + def test_anthropic_host_with_path_is_preserved(self, tmp_path, monkeypatch): + """api.anthropic.com with a path suffix must still pass the host check.""" + import yaml + from agent.auxiliary_client import _try_anthropic + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text(yaml.safe_dump({ + "model": { + "provider": "anthropic", + "model": "claude-haiku-4-5-20251001", + "base_url": "https://api.anthropic.com/v1/messages", + } + })) + + with ( + patch( + "agent.auxiliary_client._select_pool_entry", return_value=(False, None) + ), + patch( + "agent.anthropic_adapter.resolve_anthropic_token", + return_value="***", + ), + patch( + "agent.anthropic_adapter.build_anthropic_client" + ) as mock_build, + ): + mock_build.return_value = MagicMock() + client, _model = _try_anthropic() + + assert client is not None + actual = _extract_base_url_passed_to_build(mock_build) + assert actual == "https://api.anthropic.com/v1/messages", ( + f"Anthropic host with path must be preserved. Got: {actual!r}" + )