Make Nexus privacy mode a runtime switch - #48
Conversation
…l proxy The wizard hardcoded the direct Nexus endpoint, which is the path where TLS terminates at Cloudflare and prompts are readable there. A checkbox on the Nexus configuration screen now switches model.base_url to the nexus-local-proxy package on the same DAppNode, which verifies the Gateway's AWS Nitro attestation against a pinned policy and encrypts bodies with EHBP past that point. It is a toggle in this package rather than a separate package: a fork would double maintenance and split users. The base URL is written in two places -- env.OPENAI_BASE_URL and model.base_url in the generated config.yaml -- and both now go through one nexusBaseUrl() helper so they cannot drift apart. config.yaml is the load-bearing one; the env vars get cleared by config migration on boot. Default is off. The proxy fails closed, so opting in should be a deliberate choice made after reading what it changes rather than something a user discovers when inference stops working. The toggle copy states that trade-off rather than presenting it as free -- a verification failure surfaces as connection errors with no silent fallback to the unprotected path -- and links to the proxy's verification page. No manifest dependency is declared. nexus-local-proxy has never been published to the DAppNode registry: resolving it by name returns NOREPO and it does not appear in a registry search, because only its IPFS artifacts exist and it has been installed by hash. A dependencies entry would therefore fail to resolve at install time and break installing Hermes at all. The toggle copy carries the requirement instead -- it names the package, says this one will not install it, and tells the user to leave the toggle off until they have. Two supporting fixes in patch-config.py: - The context_size lookup was gated on the literal string nexus-api.dappnode.com in base_url. With Private mode on that gate is false, so model.context_length would never be set and every model would silently use Hermes' 256K fallback -- wrong for all of them (Deepseek V4 Flash is 1048576, MiniMax M2.7 is 204800). It now recognises either Nexus route, and falls back to the public catalog when the configured endpoint has no /models, which is the case on nexus-local-proxy releases before 0.1.1. - That lookup has in fact never worked. Cloudflare fronts nexus-api.dappnode.com and 403s the default Python-urllib User-Agent, so the fetch always failed and every Nexus user has been running on the 256K fallback. Upstream Hermes already guards against the same WAF behaviour in providers/base.py. Sending a real User-Agent fixes it; verified live, returning 1048576 and 204800 for the two models above, both directly and through a running proxy. Verified: wizard JS syntax-checked, and buildEnv()/buildConfigYaml() exercised against a DOM stub to confirm both write sites flip together with the toggle while the API key and context_length still land in config.yaml. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dependency came out earlier because nexus-local-proxy.dnp.dappnode.eth had no registry entry -- resolving it by name returned NOREPO, so declaring it would have broken installing Hermes at all. It is now shipped as a core package via DNP_CORE, so it is guaranteed present and the dependency resolves. That makes the toggle copy accurate again: it no longer tells the user to go install the proxy themselves, only what routing through it changes and that it fails closed. Requires nexus-local-proxy 0.2.0 and the DNP_CORE release that carries it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts commit 5467e02.
Hermes could already be pointed at the attested local proxy, but only while running the setup wizard: the choice was baked in at configuration time, so trying it meant walking back through provider selection and re-entering the Nexus API key. Nothing about the two routes justifies that. Direct and private are the same account, the same key, the same provider and the same model ids -- the only difference is model.base_url. So this makes it a switch on the Dashboard tab that flips exactly that one value and restarts Hermes to apply it. - dappnode/nexus_mode.py owns both endpoints and the direct/private distinction, and edits config.yaml with a real YAML parser rather than pattern-matching config text. patch-config.py now imports the constants from it so the boot-time patch and the runtime switch cannot drift apart. - GET/POST /api/nexus/mode in the wizard server read and flip the mode. The script runs as the hermes user, for the same reason `hermes status` does: a root-owned config.yaml would leave the gateway unable to write it. - The switch refuses to enable private mode while nexus-local-proxy is unreachable. The proxy fails closed, so without that check flipping it would take Hermes offline with a bare connection error instead of a message explaining what to install. - When the proxy is up, the card shows what is actually attested -- the verification status and the Gateway build being verified -- rather than only that a port is open. - Re-running the wizard now seeds its checkbox from the mode in force. It previously defaulted to off, so re-running it would silently write the direct endpoint back and downgrade the user without saying so. Verified against the live TEE Gateway: the probe reports status "verified" for Gateway build 893f4c9, and a private/direct round trip leaves api_key, provider, default and context_length untouched. Also verified inside the built image, where the venv Python runs the script and patch-config.py still imports it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Dappnode bot has built and pinned the built packages to an IPFS node, for commit: 73f1b5d This is a development version and should only be installed for testing purposes.
Hash: (by dappnodebot/build-action) |
CodeQL flagged the substring match, and it is worse here than the generic
rule suggests. `NEXUS_PROXY_HOST in base_url` is true for
http://nexus-local-proxy.dappnode.private.evil.com:3301/v1
so that endpoint would be reported as private mode and the dashboard would
tell the user their prompts were encrypted to an attested enclave while they
went somewhere else entirely. A privacy indicator that can be spoofed into
claiming a guarantee it cannot back is worse than no indicator.
Parse the URL and compare the hostname exactly. A base_url written without a
scheme still resolves, so nothing that used to be recognised stops being
recognised; the comparison is exact either way.
Verified against the spoofing cases the substring version accepted: the marker
appearing as a subdomain prefix, in a path, in a query string and in a
fragment are all now correctly not_nexus, while both real endpoints, a
trailing slash and a schemeless host still classify correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
❌ Package harness failed
Run overview
Baseline vs candidate
Hard-check reasons
Container comparison
Container inventoryBaseline
Candidate No container snapshot was available. Runtime analysis advisory⚪ Inconclusive — Log analysis was unavailable
✅ No new runtime findings were reported. Analyzer diagnostics
Cleanup and diagnostics
Deterministic checks decide the verdict. Runtime log analysis is advisory. TropiBot publishes bounded summaries and never posts raw logs. |
The proxy package moved to dappnode/DNP_NEXUS_PROXY and took the core naming convention with it, so its internal DNS name changed: nexus-local-proxy.dappnode.private -> nexus-proxy.dappnode.private Hermes hardcodes that host in the switch, the reachability probe, the manifest link and the skill docs, so all of them move with it. A config written before the rename still names the old host, which no longer resolves, so private mode would fail forever with no indication of why. Two things handle that: the old host is still recognised as private mode, since the user did choose private and the report should stay honest, and the boot patch repoints it to the new host before anything else reads base_url. The rename does not weaken the hostname check: matching stays exact, so nexus-proxy.dappnode.private.evil.com is still not_nexus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…issing The private-mode copy was written for someone who already knows what a TLS terminator and an attestation are. It explained the mechanism instead of the promise, and it was long enough that nobody would read it. Say the promise instead, briefly: On — only the TEE can read your prompts Off — your prompts can be read in transit Three things replace the old wall of text. "TEE (trusted execution environment)" instead of "AWS Nitro Enclave" -- the vendor and the hardware are not what a user is deciding about. "We cannot read them, and neither can anyone in between" instead of naming which intermediary terminates TLS. And "the proxy verifies it for you automatically, and you can check the proof yourself" instead of a paragraph about failing closed. The exact mechanism is still in the source comments, where the audience is maintainers. The unreachable-proxy message now links to the Dappstore install page rather than telling the user to go find a package by name. nexus_mode.py owns that URL alongside the other endpoints, so it reaches the UI through the same state object and cannot drift. Also: Dappnode, not DAppNode, throughout the prose. Identifiers keep their existing spelling, since DAppNodePackage- and DAppNodeCore- are real names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "off" copy read as a warning about the state the user is already in and did nothing about: your prompts can be read, and here is the bad news. Nobody chose that state, so warning them about it is just nagging. Off now describes what is available rather than what is wrong: Off — available on this Dappnode Turn this on to encrypt your prompts so only the TEE running Nexus can read them. Same API key, same models, same prices. The wizard hint leads with "Optional." for the same reason. Private mode does cost something, so both the card and the wizard now say what. Checked against the live TEE Gateway rather than assumed: Auto Router (nexus/auto) production 200 (routed) TEE 500 normal models 200 200 private/* models — 200 streaming — 200 catalog 15 models 15 models (identical) Auto Router fails on the direct TEE endpoint too, so it is the TEE deployment rather than the proxy -- but private mode is the only way to reach that Gateway, so the effect on the user is the same and the caveat belongs here. PII masking is the second one, and comes from the signed egress manifest: the enclave may only dial its measured routes, and the Presidio analyzer is not among them, so masking cannot happen inside the TEE. Not confirmed against a masking-enabled key, which I did not have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Do not work in private mode yet" promises a fix nobody has committed to. Neither Auto Router nor PII masking has one, so the caveat states the fact and stops there. Also rewrites the SKILL.md section the agent reads back to users. It carried the framing the UI has already dropped — a table column headed "who can read the prompt in transit", Cloudflare named as the reader, "silent downgrade to the unprotected path". Hermes would have repeated all of that in conversation while the UI said something else. It now says what private mode gives you, in about half the words. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Private mode protects the prompt from the proxy to the Gateway. A private/ model carries it the rest of the way: the Gateway reaches those over an attested, encrypted transport that fails closed, so the prompt is protected end to end. That is the combination worth recommending, and nothing said so. The card, the wizard and the skill now say it. The model picker also badges them, because a recommendation the user has to act on is more useful next to the list than buried in prose. The badge reads the catalog's proof_mode rather than the private/ prefix. The prefix is a naming convention; labelling a model "private" because of its name would be a claim we cannot back if the two ever diverge. The wizard's model mapper dropped proof_mode, so it now passes it through as `attested`. The prefix survives only as a fallback for the static offline suggestion list. Verified against the live catalog: 5 of 15 models carry attested transport, and the badge matches that set exactly. Deliberately says private/ and not the provider's name in user-facing copy -- the provider may change, the id prefix is the stable thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🛠️ Package harness hit an infrastructure error
Run overview
Baseline vs candidate
Hard-check reasons
Container comparison
Container inventoryBaseline
Candidate No container snapshot was available. Runtime analysis advisory⚪ Inconclusive — Log analysis was unavailable
✅ No new runtime findings were reported. Analyzer diagnostics
Cleanup and diagnostics
Deterministic checks decide the verdict. Runtime log analysis is advisory. TropiBot publishes bounded summaries and never posts raw logs. |
Why
Hermes could already be pointed at the attested local proxy, but only while running the setup wizard — the choice was baked in at configuration time. Trying it meant walking back through provider selection and re-entering the Nexus API key.
Nothing about the two routes justifies that. Direct and private are the same account, the same key, the same provider (
custom) and the same model ids. The only difference ismodel.base_url:model.base_urlhttps://nexus-api.dappnode.com/v1http://nexus-local-proxy.dappnode.private:3301/v1So this makes it a switch on the Dashboard tab that flips that one value and restarts Hermes to apply it. No key re-entry, no provider change.
What's here
dappnode/nexus_mode.pyowns both endpoints and the direct/private distinction, and editsconfig.yamlwith a real YAML parser rather than pattern-matching config text.patch-config.pynow imports its constants so the boot-time patch and the runtime switch cannot drift apart.GET/POST /api/nexus/modein the wizard server read and flip the mode. The script runs as thehermesuser, for the same reasonhermes statusdoes — a root-ownedconfig.yamlleaves the gateway unable to write it.nexus-local-proxyis unreachable. The proxy fails closed, so without that check flipping it would take Hermes offline with a bare connection error instead of a message saying what to install.Verification
Against the live TEE Gateway, with a real
nexus-local-proxyrunning:api_key,provider,defaultandcontext_lengthuntouched409with the reason, and does not modifyconfig.yamlconfig.yaml, missingmodel:section and a bad mode argument all fail cleanlypatch-config.pystill imports itNote on dependencies
This does not add a
nexus-local-proxypackage dependency. That package is not published onchain yet (dappnode/DAppNodePackage-nexus-local-proxy#16 renumbers it to0.1.0for its first publish), so declaring a hard dependency would break installs. The switch probes for the proxy at runtime and tells the user to install it instead. The dependency can be added once the package is on the registry.🤖 Generated with Claude Code