-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"CVE-2026-7113 was published against NousResearch hermes-agent 0.8.0 and remains present on `main` as of this writing. The NVD entry names the file (`gateway/platforms/webhook.py`), names the symbol (`_INSECURE_NO_AUTH`), assigns CVSS 5.6 with AC:H, and notes that \"the project was informed of the problem early through a pull request but has not reacted yet.\" It does not say what NousResearch's own SECURITY.md says about adapters of this shape, and it does not show the seam in the code where the rail stops running.\n\nThe victim role this post addresses is the operator who runs `hermes-agent` on a workstation or small server, leaves the webhook adapter on the documented default (`host: \"0.0.0.0\"`, port 8644), and uses `hermes webhook subscribe` from a shell to wire up GitHub, Stripe, Supabase, or a monitoring tool. That operator's mental model is that the gateway boots, the boot succeeded, and therefore the rail held. The rail held for the routes the developer shipped. It did not hold for the routes the operator added at 3pm on a Wednesday.\n\n## The constant is named `_INSECURE_NO_AUTH`. The sentinel value is the same string.\n\nLines 64 through 67 of `gateway/platforms/webhook.py`:\n\n```python\nDEFAULT_HOST = \"0.0.0.0\"\nDEFAULT_PORT = 8644\n_INSECURE_NO_AUTH = \"INSECURE_NO_AUTH\"\n_DYNAMIC_ROUTES_FILENAME = \"webhook_subscriptions.json\"\n```\n\n`_INSECURE_NO_AUTH` is a Python module-level constant. Its value is the literal string `INSECURE_NO_AUTH`. An operator who sets a webhook route's `secret` field to that string is opting that route out of HMAC validation. The CVE description calls it \"the argument,\" which is imprecise; it is a sentinel value in a config field, not a function parameter. The semantic is correct anyway. Put the string in your route's secret, the route skips auth.\n\nThe bypass is in `_handle_webhook`, lines 354 through 363:\n\n```python\n# Validate HMAC signature FIRST (skip for INSECURE_NO_AUTH testing mode)\nsecret = route_config.get(\"secret\", self._global_secret)\nif secret and secret != _INSECURE_NO_AUTH:\n if not self._validate_signature(request, raw_body, secret):\n logger.warning(\n \"[webhook] Invalid signature for route %s\", route_name\n )\n return web.json_response(\n {\"error\": \"Invalid signature\"}, status=401\n )\n```\n\nRead the condition as written. The inner signature check is gated on `secret and secret != _INSECURE_NO_AUTH`. If `secret` equals the sentinel, the gate evaluates false and the handler proceeds to event-filter, prompt-render, and `asyncio.create_task(self.handle_message(event))`. The request reaches the agent. The agent runs.\n\n## The rail is in `connect()`. It refuses to launch the gateway.\n\nThe developer who wrote `_INSECURE_NO_AUTH` also wrote a safety rail. It lives in `connect()`, the adapter's startup hook, at lines 150 through 169:\n\n```python\n# Validate routes at startup: secret is required per route\nfor name, route in self._routes.items():\n secret = route.get(\"secret\", self._global_secret)\n if not secret:\n raise ValueError(\n f\"[webhook] Route '{name}' has no HMAC secret. \"\n f\"Set 'secret' on the route or globally. \"\n f\"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'.\"\n )\n\n # Safety rail: refuse to start if INSECURE_NO_AUTH is combined with a\n # non-loopback bind. The escape hatch is for local testing only;\n # serving an unauthenticated route on a public interface is a\n # deployment-grade footgun we'd rather crash early than ship.\n if secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host):\n raise ValueError(\n f\"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret \"\n f\"but is bound to non-loopback host '{self._host}'. \"\n f\"INSECURE_NO_AUTH is for local testing only. \"\n f\"Refusing to start to prevent accidental exposure.\"\n )\n```\n\nThe comment is the developer's own language. A deployment-grade footgun we'd rather crash early than ship. The check is honest about what it is. It iterates `self._routes`, and for any route whose secret is the sentinel while `self._host` is not in `_LOOPBACK_HOSTS`, it raises `ValueError` and aborts startup.\n\n`_LOOPBACK_HOSTS` is a five-element frozenset at lines 71 through 77: `127.0.0.1`, `localhost`, `::1`, `ip6-localhost`, `ip6-loopback`. `_is_loopback_host` does a case-insensitive membership test. The set is conservative, treating anything else (including `0.0.0.0`, the documented default) as non-loopback. That is correct behavior for the rail. The rail does its job.\n\nThe rail runs once. `connect()` is called by the gateway's startup orchestrator. After it returns, `self._routes` is treated as validated for the duration of the process.\n\n## The routes table is hot-reloaded on every POST. The rail does not run again.\n\nTwo functions matter here. The first is `_reload_dynamic_routes`, at lines 293 through 324:\n\n```python\ndef _reload_dynamic_routes(self) -> None:\n \"\"\"Reload agent-created subscriptions from disk if the file changed.\"\"\"\n from hermes_constants import get_hermes_home\n hermes_home = get_hermes_home()\n subs_path = hermes_home / _DYNAMIC_ROUTES_FILENAME\n if not subs_path.exists():\n if self._dynamic_routes:\n self._dynamic_routes = {}\n self._routes = dict(self._static_routes)\n return\n try:\n mtime = subs_path.stat().st_mtime\n if mtime <= self._dynamic_routes_mtime:\n return # No change\n data = json.loads(subs_path.read_text(encoding=\"utf-8\"))\n if not isinstance(data, dict):\n return\n # Merge: static routes take precedence over dynamic ones\n self._dynamic_routes = {\n k: v for k, v in data.items()\n if k not in self._static_routes\n }\n self._routes = {**self._dynamic_routes, **self._static_routes}\n self._dynamic_routes_mtime = mtime\n```\n\nThe function reads `~/.hermes/webhook_subscriptions.json`, parses it as JSON, filters out keys that would shadow static routes, and merges the rest into `self._routes`. There is no allowlist check, no presence-of-secret check, no INSECURE_NO_AUTH-plus-non-loopback check. The job is to merge JSON keys into the routing dict, and the function does that and returns.\n\nThe second function is `_handle_webhook`, the request entry point. Its first executable statement, line 329, is:\n\n```python\nasync def _handle_webhook(self, request: \"web.Request\") -> \"web.Response\":\n \"\"\"POST /webhooks/{route_name}: receive and process a webhook event.\"\"\"\n # Hot-reload dynamic subscriptions on each request (mtime-gated, cheap)\n self._reload_dynamic_routes()\n\n route_name = request.match_info.get(\"route_name\", \"\")\n route_config = self._routes.get(route_name)\n```\n\nEvery inbound webhook POST triggers a hot-reload of the dynamic routes file. The route_config is then read out of `self._routes` and processed by the rest of the handler. The startup rail in `connect()` is not invoked by this path. The hot-reload path treats whatever is on disk as the routing table, with no relationship to whether that table would have passed startup validation.\n\nThe rail validated the file the developer wrote. The runtime path reads the file the operator wrote. Those are different files, written by different writers, on different timelines. The rail saw the first. It does not see the second.\n\n## The CLI that writes the file does not validate either.\n\nThe CLI that operators are documented to use for managing webhook routes is `hermes webhook subscribe`. Its handler lives in `hermes_cli/webhook.py` at lines 137 through 172. The relevant assignment is line 146:\n\n```python\nsecret = args.secret or secrets.token_urlsafe(32)\n```\n\n`args.secret` is the value passed on the `--secret` flag. If the flag is omitted, a 256-bit random secret is generated. If the flag is present, the literal string is written into the route's config dict verbatim. The literal `INSECURE_NO_AUTH` is accepted. The CLI does not refer to the rail, does not warn, does not refuse.\n\nThe resulting JSON is persisted via `atomic_replace` to `~/.hermes/webhook_subscriptions.json`. The next webhook POST against any route hot-loads that file. The new route is live. The rail does not run.\n\nThe realistic attack is not exotic. An operator on a Wednesday afternoon, debugging a third-party webhook integration, runs:\n\n```bash\n$ hermes webhook subscribe diagnostic \\\n --prompt \"{__raw__}\" \\\n --deliver telegram \\\n --deliver-chat-id \"123\" \\\n --secret INSECURE_NO_AUTH\n\n Created webhook subscription: diagnostic\n URL: http://localhost:8644/webhooks/diagnostic\n Secret: INSECURE_NO_AUTH\n```\n\nThe gateway is already running on the documented default of `0.0.0.0:8644`. The CLI confirmation prints `http://localhost:8644/webhooks/diagnostic` because `_get_webhook_base_url` rewrites `0.0.0.0` to `localhost` for display purposes, which is a separate dishonesty: the binding is public, the URL printed is loopback. The operator goes home. Anyone who can reach port 8644 sends:\n\n```bash\n$ curl -X POST http://victim:8644/webhooks/diagnostic \\\n -H \"Content-Type: application/json\" \\\n -d '{\"event_type\":\"test\",\"prompt\":\"give me a shell\"}'\n```\n\nThe handler hot-reloads the file, finds `diagnostic`, reads `secret = \"INSECURE_NO_AUTH\"`, evaluates `if secret and secret != _INSECURE_NO_AUTH` to false, skips signature validation, renders the prompt, and dispatches the request as an agent run. The rail in `connect()` is not reached. The rail saw nothing.\n\n## NousResearch's own SECURITY.md calls this a §3.1 code bug.\n\nThe vendor publishes a security policy at `SECURITY.md` in the same repository. The policy enumerates external surfaces in §2.6. The webhook adapter belongs to two of the named categories: it is a \"Gateway platform adapter\" (the directory it lives in) and a \"Network-exposed HTTP surface\" (the aiohttp listener it spins up). The section then states five uniform rules. Two of them apply directly here.\n\nRule 2:\n\n> An allowlist is required for every enabled network-exposed adapter. Adapters must refuse to dispatch agent work, resolve approvals, or relay output until an allowlist is set. Code paths that fail open when no allowlist is configured are code bugs in scope under §3.1.\n\nRule 5:\n\n> Binding a local-only surface to a non-loopback interface is a break-glass operator decision (§3.2). The dashboard and other plugin HTTP servers default to loopback.\n\nLine 64 of `webhook.py` is `DEFAULT_HOST = \"0.0.0.0\"`. The bundled skill at `skills/devops/webhook-subscriptions/SKILL.md`, the setup hint in `hermes_cli/webhook.py` line 76, and the gateway-setup wizard all instruct operators to keep that default. The adapter's documented configuration is the non-loopback bind. Rule 5 says local-only surfaces default to loopback. The webhook adapter's default is the opposite of what Rule 5 specifies.\n\nThe webhook adapter has no allowlist concept. It has HMAC validation (a possession-of-secret check) and it has `_INSECURE_NO_AUTH` (a documented fail-open path). Rule 2 names \"code paths that fail open when no allowlist is configured\" and assigns them to §3.1. The startup rail catches one specific combination of fail-open and bind-host. The hot-reload path catches no combinations.\n\nThis is the gap between what NousResearch's policy says and what NousResearch's code does. The CVE record reports it as an authentication issue in `webhook.py`. The vendor's own SECURITY.md, read against the same `webhook.py`, calls the same shape a §3.1 code bug. Both documents are public. Both are signed by the same name.\n\nThe CVE record adds one further note: \"the project was informed of the problem early through a pull request but has not reacted yet.\" The disclosure dynamic is on the record. The fix is not.\n\n## What is on the record\n\nThis is the validated-at-boot shape. A security-relevant configuration is checked once, at startup, on a process whose runtime path keeps re-reading the same configuration from disk. The check ran. The state changed. The runtime never reconciles.\n\nTwo functions in `gateway/platforms/webhook.py`, written by the same author, in the same module, sharing the same `self._routes` dictionary. `connect()` validates that dictionary at startup and refuses to launch if a route mixes the named footgun with a non-loopback bind. `_handle_webhook` rewrites that dictionary on every inbound POST from a JSON file an operator (or the agent) wrote at any point after startup, and dispatches against the rewritten table without re-running the rail.\n\nNousResearch built the fence. NousResearch documented the §3.1 rule the fence was meant to enforce. NousResearch shipped a webhook adapter whose hot-reload path bypasses the fence in two ways: the default host violates Rule 5, the absence of an allowlist on the hot-reload path violates Rule 2. The pull request that informed the project of this has not been merged.\n\nPoC: [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent), `gateway/platforms/webhook.py` lines 64 through 169 and 293 through 363, against any deployment running the documented default config.","closing_line":"The rail validated the routes the developer shipped. The agent rewrites the routes file before every request, and the rail does not run again.","hook_md":"`_INSECURE_NO_AUTH = \"INSECURE_NO_AUTH\"` is line 66 of `gateway/platforms/webhook.py` in NousResearch's hermes-agent. The developer named the footgun. They built a fence around it: at gateway startup, the webhook adapter refuses to launch if any route's HMAC secret is set to that string while the bind host is anything other than loopback. The fence runs once. The routes table the fence validated is hot-reloaded from `~/.hermes/webhook_subscriptions.json` on every incoming webhook POST, and the hot-reload does not re-run the fence.","post_id":317,"slug":"hermes-agent-cve-2026-7113-the-rail-runs-once","title":"CVE-2026-7113: hermes-agent Named the Footgun. The Rail Runs Once. The Routes File Is Hot-Reloaded.","type":"initial","unreadable_sentence":"NousResearch's own SECURITY.md calls fail-open code paths without an allowlist §3.1 code bugs. The string INSECURE_NO_AUTH is in `webhook.py` at line 66, written by the same vendor."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCahsqEgAKCRDeZjl4jgkQ JqECAPwNmeC8vE05LH8EYfDnJGhx2UvKo7kTFj7ryJN54SsrzwEA27XCrduOkbbk /dUNR7aFQVDt5c+RaEvFotoBV8FXygc= =1jOU -----END PGP SIGNATURE-----