fix(auth): a Portal-returned inference host is accepted only when the operator named it

#111809 accepted any *.nousresearch.com https host once the operator's Portal override
pointed at a non-production Portal. That let a network-provenance value — the Portal's
refresh response — pick any Nous-owned host as the bearer recipient, including hosts that
are not inference gateways. Owning the DNS suffix is not the same as being an authorized
recipient, and the validator's threat model (an injected refresh response) is exactly the
case a suffix rule fails to bound.

The recipient is now the operator's own NOUS_INFERENCE_BASE_URL: a non-production host
returned by the Portal is accepted exactly when it equals that override's host, otherwise
the strict production set stands. The Portal override grants nothing by itself. What the
operator gains over plain use of the override is that the Portal's value is then persisted
and used for the pricing scope and proxy instead of being healed to production, and the
per-turn "refusing inference URL host" warning stops. No environment is named in code.

Raised on #111809 review. Tests: recipient match accepted, unrelated Nous host refused, no
override refused, Portal override alone grants nothing, the match follows the profile scope
under multiplexing; the widening cases are red on main. Docs row for NOUS_INFERENCE_BASE_URL.

Co-authored-by: Ben Barclay <ben@nousresearch.com>
This commit is contained in:
kshitijk4poor
2026-09-15 16:39:06 +05:30
committed by kshitij
parent aaed2a238f
commit 569b4242a3
3 changed files with 75 additions and 28 deletions
+13 -26
View File
@@ -108,36 +108,23 @@ _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."""
"""Production hosts always; otherwise only the host the operator named in
``NOUS_INFERENCE_BASE_URL``.
A non-production Portal's refresh response names that environment's inference gateway. The
Portal-returned value is network provenance, so it does not get bearer-receive authority on
its own — not even for a Nous-owned host: the operator's explicit override is the authority,
and the network value is accepted exactly when it agrees with it. Then the persisted endpoint,
the pricing scope and the proxy all follow the environment the operator chose, and the
per-turn "refusing inference URL host" warning stops.
"""
if hostname in _ALLOWED_NOUS_INFERENCE_HOSTS:
return True
if not hostname or not hostname.endswith(_NOUS_INFERENCE_HOST_SUFFIX):
if not hostname:
return False
labels = hostname.removesuffix(_NOUS_INFERENCE_HOST_SUFFIX).split(".")
return all(labels) and _operator_selected_non_production_portal()
override = _nous_inference_env_override()
return override is not None and urlparse(override).hostname == hostname
def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]:
@@ -50,3 +50,63 @@ def test_routing_overrides_follow_the_profile_scope_and_fail_closed_without_one(
assert helper() is None, "a call that lost its profile scope has no authority over the launch value"
finally:
ss.set_multiplex_active(False)
@pytest.mark.parametrize(
"inference_override, url, expected",
[
# The operator named the environment's gateway; the Portal's matching value survives.
(ENV_INFERENCE, ENV_INFERENCE, ENV_INFERENCE),
# No operator authority: a non-production host is refused however it arrived.
(None, ENV_INFERENCE, None),
# Another Nous-owned host is not the named recipient — refused, DNS suffix or not.
(ENV_INFERENCE, OTHER_NOUS_HOST, None),
# Production is always valid; the rule widens, never narrows.
(None, PROD_INFERENCE, PROD_INFERENCE),
(ENV_INFERENCE, PROD_INFERENCE, PROD_INFERENCE),
],
)
def test_network_inference_host_needs_the_operator_named_recipient(monkeypatch, inference_override, url, expected):
if inference_override is None:
monkeypatch.delenv("NOUS_INFERENCE_BASE_URL", raising=False)
else:
monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", inference_override)
assert auth_nous._validate_nous_inference_url_from_network(url) == expected
def test_portal_override_alone_grants_nothing(monkeypatch):
"""A non-production Portal override, with or without a matching stored Portal, does not make
the Portal's returned host a bearer recipient; the healed value stays production. Only the
inference override does, and scheme checks still apply to it."""
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", NONPROD_PORTAL)
monkeypatch.delenv("NOUS_INFERENCE_BASE_URL", raising=False)
state = {"portal_base_url": NONPROD_PORTAL, "inference_base_url": ENV_INFERENCE, "client_id": "cid"}
portal, stored_inference, _effective, _ = auth_nous._nous_effective_routing(state)
assert portal == NONPROD_PORTAL and stored_inference == auth_nous.DEFAULT_NOUS_INFERENCE_URL
assert auth_nous._healed_nous_inference_url({"inference_base_url": ENV_INFERENCE}) == (
auth_nous.DEFAULT_NOUS_INFERENCE_URL)
monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", ENV_INFERENCE)
assert auth_nous._healed_nous_inference_url({"inference_base_url": ENV_INFERENCE}) == ENV_INFERENCE
assert auth_nous._validate_nous_inference_url_from_network(ENV_INFERENCE.replace("https", "http", 1)) is None
def test_recipient_match_follows_the_profile_scope_under_multiplex(monkeypatch):
"""The recipient is the routed profile's own override: a secondary without it refuses the
host even when the launch profile's process env names it."""
from agent import secret_scope as ss
monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", ENV_INFERENCE)
ss.set_multiplex_active(True)
try:
token = ss.set_secret_scope({})
try:
assert auth_nous._validate_nous_inference_url_from_network(ENV_INFERENCE) is None
finally:
ss.reset_secret_scope(token)
token = ss.set_secret_scope({"NOUS_INFERENCE_BASE_URL": ENV_INFERENCE})
try:
assert auth_nous._validate_nous_inference_url_from_network(ENV_INFERENCE) == ENV_INFERENCE
finally:
ss.reset_secret_scope(token)
finally:
ss.set_multiplex_active(False)
@@ -138,8 +138,8 @@ 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). 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_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing). Per-profile under multiplexing: set it in the served profile's `.env`. |
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL. Also the only non-production host a Portal response may name: when the Portal's returned inference URL matches this override it is accepted and persisted instead of being healed to production. Per-profile under multiplexing. |
| `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 |
| `HERMES_DUMP_REQUESTS` | Dump API request payloads to log files (`true`/`false`) |