//nefariousplan

CVE-2026-7113: hermes-agent Named the Footgun. The Rail Runs Once. The Routes File Is Hot-Reloaded.

pattern

cve

_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.

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.

The 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.

The constant is named _INSECURE_NO_AUTH. The sentinel value is the same string.

Lines 64 through 67 of gateway/platforms/webhook.py:

DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8644
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"

_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.

The bypass is in _handle_webhook, lines 354 through 363:

# Validate HMAC signature FIRST (skip for INSECURE_NO_AUTH testing mode)
secret = route_config.get("secret", self._global_secret)
if secret and secret != _INSECURE_NO_AUTH:
    if not self._validate_signature(request, raw_body, secret):
        logger.warning(
            "[webhook] Invalid signature for route %s", route_name
        )
        return web.json_response(
            {"error": "Invalid signature"}, status=401
        )

Read 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.

The rail is in connect(). It refuses to launch the gateway.

The 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:

# Validate routes at startup: secret is required per route
for name, route in self._routes.items():
    secret = route.get("secret", self._global_secret)
    if not secret:
        raise ValueError(
            f"[webhook] Route '{name}' has no HMAC secret. "
            f"Set 'secret' on the route or globally. "
            f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'."
        )

    # Safety rail: refuse to start if INSECURE_NO_AUTH is combined with a
    # non-loopback bind. The escape hatch is for local testing only;
    # serving an unauthenticated route on a public interface is a
    # deployment-grade footgun we'd rather crash early than ship.
    if secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host):
        raise ValueError(
            f"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret "
            f"but is bound to non-loopback host '{self._host}'. "
            f"INSECURE_NO_AUTH is for local testing only. "
            f"Refusing to start to prevent accidental exposure."
        )

The 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.

_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.

The 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.

The routes table is hot-reloaded on every POST. The rail does not run again.

Two functions matter here. The first is _reload_dynamic_routes, at lines 293 through 324:

def _reload_dynamic_routes(self) -> None:
    """Reload agent-created subscriptions from disk if the file changed."""
    from hermes_constants import get_hermes_home
    hermes_home = get_hermes_home()
    subs_path = hermes_home / _DYNAMIC_ROUTES_FILENAME
    if not subs_path.exists():
        if self._dynamic_routes:
            self._dynamic_routes = {}
            self._routes = dict(self._static_routes)
        return
    try:
        mtime = subs_path.stat().st_mtime
        if mtime <= self._dynamic_routes_mtime:
            return  # No change
        data = json.loads(subs_path.read_text(encoding="utf-8"))
        if not isinstance(data, dict):
            return
        # Merge: static routes take precedence over dynamic ones
        self._dynamic_routes = {
            k: v for k, v in data.items()
            if k not in self._static_routes
        }
        self._routes = {**self._dynamic_routes, **self._static_routes}
        self._dynamic_routes_mtime = mtime

The 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.

The second function is _handle_webhook, the request entry point. Its first executable statement, line 329, is:

async def _handle_webhook(self, request: "web.Request") -> "web.Response":
    """POST /webhooks/{route_name}: receive and process a webhook event."""
    # Hot-reload dynamic subscriptions on each request (mtime-gated, cheap)
    self._reload_dynamic_routes()

    route_name = request.match_info.get("route_name", "")
    route_config = self._routes.get(route_name)

Every 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.

The 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.

The CLI that writes the file does not validate either.

The 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:

secret = args.secret or secrets.token_urlsafe(32)

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.

The 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.

The realistic attack is not exotic. An operator on a Wednesday afternoon, debugging a third-party webhook integration, runs:

$ hermes webhook subscribe diagnostic \
    --prompt "{__raw__}" \
    --deliver telegram \
    --deliver-chat-id "123" \
    --secret INSECURE_NO_AUTH

  Created webhook subscription: diagnostic
  URL:    http://localhost:8644/webhooks/diagnostic
  Secret: INSECURE_NO_AUTH

The 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:

$ curl -X POST http://victim:8644/webhooks/diagnostic \
    -H "Content-Type: application/json" \
    -d '{"event_type":"test","prompt":"give me a shell"}'

The 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.

NousResearch's own SECURITY.md calls this a §3.1 code bug.

The 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.

Rule 2:

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.

Rule 5:

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.

Line 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.

The 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.

This 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.

The 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.

What is on the record

This 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.

Two 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.

NousResearch 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.

PoC: NousResearch/hermes-agent, gateway/platforms/webhook.py lines 64 through 169 and 293 through 363, against any deployment running the documented default config.

The rail validated the routes the developer shipped. The agent rewrites the routes file before every request, and the rail does not run again.