fix(auth): accept a non-production Portal's own inference host when the operator selected it

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 <ben@nousresearch.com>
This commit is contained in:
kshitijk4poor
2026-09-15 16:11:00 +05:30
committed by kshitij
parent 9366f59533
commit 288fdc1a4c
3 changed files with 107 additions and 2 deletions
+32 -1
View File
@@ -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",
@@ -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)
@@ -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 |