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:
+13
-26
@@ -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`) |
|
||||
|
||||
Reference in New Issue
Block a user