From 288fdc1a4c8c987e96a1de9d4e96228a78f198a2 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:11:00 +0530 Subject: [PATCH] fix(auth): accept a non-production Portal's own inference host when the operator selected it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token minted by a non-production Portal is meant to be spent at that environment's own inference gateway, and the Portal's refresh response names that host. The allowlist applied to Portal-returned inference URLs was production-only, so the value was refused as "not in allowlist" and healed to the production host — a token the production Portal never issued, sent to the production gateway, which 401s it. Every hosted non-production instance hit this on every gateway turn once #108319 made the deploy-wide NOUS_INFERENCE_BASE_URL invisible inside a routed profile scope (by design, #65941). The widening is keyed on the operator's trusted HERMES_PORTAL_BASE_URL override, never on the stored portal_base_url: when that override names a Portal outside the production allowlist, any https host under the Nous domain is accepted; otherwise the strict production set stands. So a poisoned auth.json cannot widen the set, a production-Portal session that finds a foreign inference URL in its state is still refused and healed, and the bearer can only ever go to a Nous-owned host. No environment is named in code. Because the override is read through the profile scope (previous commit), each multiplexed profile decides for itself. Validation: 4 invariant tests (accepted only under a non-production override; look-alike domains, dotless suffix and http still refused; stored portal alone does not widen; the decision follows the profile scope under multiplex) — the new-behaviour ones red on the previous commit. Main's existing validation tests are unchanged and green. Live receipt for the symptom and the fixed chain on a hosted instance: #111589. Based on #102863 and its rebase onto the decomposed auth_nous.py in #111589, whose portal-keyed pairing this replaces with the same behaviour and no environment literals. Co-authored-by: Ben Barclay --- hermes_cli/auth_nous.py | 33 ++++++++- .../test_nous_nonproduction_inference_host.py | 74 +++++++++++++++++++ .../docs/reference/environment-variables.md | 2 +- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index cf7567bbcb..698d19c3a2 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -108,6 +108,37 @@ _ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({ # Free-tier (anonymous) host: serves the single ``nous/welcome`` model. "welcome-api.nousresearch.com"}) +# Every Nous inference gateway, production or not, lives under this domain. Consulted only when +# the operator has pointed the process at a non-production Portal (see below). +_NOUS_INFERENCE_HOST_SUFFIX = ".nousresearch.com" + + +def _operator_selected_non_production_portal() -> bool: + """True when the trusted ``HERMES_PORTAL_BASE_URL`` override names a Portal outside the + production allowlist — the operator has deliberately put this profile on another environment. + + A token minted by that Portal is meant to be spent at that environment's own inference + gateway, and the Portal's refresh response names it. Keyed on the operator override, never on + the stored ``portal_base_url``, so a poisoned auth.json cannot widen the allowlist and a + production-Portal session that finds a foreign inference URL in its state is still refused. + """ + override = _nous_portal_env_override() + if not override: + return False + from hermes_cli.auth import _NOUS_PORTAL_ALLOWED_HOSTS + host = urlparse(override).hostname + return bool(host) and host not in _NOUS_PORTAL_ALLOWED_HOSTS # unparseable override: fail closed + + +def _nous_inference_host_allowed(hostname: Optional[str]) -> bool: + """Production hosts always; any Nous-domain host when the operator selected another Portal.""" + if hostname in _ALLOWED_NOUS_INFERENCE_HOSTS: + return True + if not hostname or not hostname.endswith(_NOUS_INFERENCE_HOST_SUFFIX): + return False + labels = hostname.removesuffix(_NOUS_INFERENCE_HOST_SUFFIX).split(".") + return all(labels) and _operator_selected_non_production_portal() + def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]: """Validate a Portal-returned inference URL against the host allowlist. @@ -126,7 +157,7 @@ def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[st logger.warning( "nous: refusing non-https inference URL scheme %r from Portal response", parsed.scheme) return None - if parsed.hostname not in _ALLOWED_NOUS_INFERENCE_HOSTS: + if not _nous_inference_host_allowed(parsed.hostname): logger.warning( "nous: refusing inference URL host %r from Portal response " "(not in allowlist); falling back to default", diff --git a/tests/hermes_cli/test_nous_nonproduction_inference_host.py b/tests/hermes_cli/test_nous_nonproduction_inference_host.py index 276c7a895e..0e43bdece7 100644 --- a/tests/hermes_cli/test_nous_nonproduction_inference_host.py +++ b/tests/hermes_cli/test_nous_nonproduction_inference_host.py @@ -45,3 +45,77 @@ def test_portal_env_override_is_read_through_the_profile_scope(monkeypatch): ss.reset_secret_scope(token) finally: ss.set_multiplex_active(False) + + +@pytest.mark.parametrize( + "portal_override, url, expected", + [ + # The bug: the operator's non-production Portal returns its own inference host; it survives. + (NONPROD_PORTAL, NONPROD_INFERENCE, NONPROD_INFERENCE), + # The protection: with no override, or a production override, only production hosts pass. + (None, NONPROD_INFERENCE, None), + (PROD_PORTAL, NONPROD_INFERENCE, None), + # Production stays valid under any override; the rule widens, never narrows. + (None, PROD_INFERENCE, PROD_INFERENCE), + (NONPROD_PORTAL, PROD_INFERENCE, PROD_INFERENCE), + ], +) +def test_inference_host_follows_the_operator_selected_portal(monkeypatch, portal_override, url, expected): + monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False) + if portal_override is None: + monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False) + else: + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", portal_override) + assert auth_nous._validate_nous_inference_url_from_network(url) == expected + + +def test_non_production_portal_never_admits_a_foreign_or_non_https_host(monkeypatch): + """The widening is bounded to Nous-domain https hosts: a look-alike domain, a bare + ``nousresearch.com`` suffix without the dot, and plain http are all still refused.""" + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", NONPROD_PORTAL) + for url in ("https://attacker.example/v1", "https://evilnousresearch.com/v1", + "https://nousresearch.com.attacker.example/v1", "http://inference.example-env.nousresearch.com/v1", + "https://.nousresearch.com/v1", "https://a..nousresearch.com/v1"): + assert auth_nous._validate_nous_inference_url_from_network(url) is None, url + # A malformed override (no parseable host) must not select the wider set either. + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", "localhost:8000") + assert auth_nous._validate_nous_inference_url_from_network(NONPROD_INFERENCE) is None + + +def test_stored_portal_alone_does_not_widen(monkeypatch): + """A poisoned auth.json cannot select the wider set: the stored portal_base_url is a + network-provenance value, only the operator override counts. ``_healed_nous_inference_url`` + therefore heals a foreign host to production unless the operator chose that environment.""" + monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False) + monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False) + state = {"portal_base_url": NONPROD_PORTAL, "inference_base_url": NONPROD_INFERENCE, "client_id": "cid"} + _portal, stored_inference, _effective, _ = auth_nous._nous_effective_routing(state) + assert stored_inference == auth_nous.DEFAULT_NOUS_INFERENCE_URL + refreshed = {"inference_base_url": NONPROD_INFERENCE} + assert auth_nous._healed_nous_inference_url(refreshed) == auth_nous.DEFAULT_NOUS_INFERENCE_URL + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", NONPROD_PORTAL) + assert auth_nous._healed_nous_inference_url(refreshed) == NONPROD_INFERENCE + + +def test_widening_follows_the_profile_scope_under_multiplex(monkeypatch): + """Under multiplexing the decision uses the routed profile's own override: a secondary whose + scope lacks it stays on the strict set even when the process env carries a non-production + Portal (the default profile's), and a scope that carries one gets its environment's host.""" + from agent import secret_scope as ss + + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", NONPROD_PORTAL) + monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False) + ss.set_multiplex_active(True) + try: + token = ss.set_secret_scope({}) + try: + assert auth_nous._validate_nous_inference_url_from_network(NONPROD_INFERENCE) is None + finally: + ss.reset_secret_scope(token) + token = ss.set_secret_scope({"HERMES_PORTAL_BASE_URL": NONPROD_PORTAL}) + try: + assert auth_nous._validate_nous_inference_url_from_network(NONPROD_INFERENCE) == NONPROD_INFERENCE + finally: + ss.reset_secret_scope(token) + finally: + ss.set_multiplex_active(False) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index b7c6cdef9e..159c8ed0a6 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -138,7 +138,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | Variable | Description | |----------|-------------| -| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | +| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing). When it points at a non-production Portal, that Portal's own `*.nousresearch.com` inference host is accepted, so `NOUS_INFERENCE_BASE_URL` is not also required. Per-profile under multiplexing: set it in the served profile's `.env`. | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | | `HERMES_NOUS_TIMEOUT_SECONDS` | HTTP timeout for Nous credential / token flows |