fix(security): require auth on /mcp — closes an unauthenticated RCE (ADR-054) - #281
Merged
Conversation
nox-0x
approved these changes
Jul 17, 2026
nox-0x
left a comment
Collaborator
There was a problem hiding this comment.
Approving — clean security fix that closes an unauthenticated RCE via /mcp + all-interfaces bind, verified live per the PR body.
Reviewed the diff carefully. The important things check out:
- The auth middleware is mounted before the /mcp route handlers (
app.use("/mcp", requireAuth)at run.ts:286, handlers at 310/317/323), and the integration test explicitly verifies GET/POST/DELETE all 401 without a token, plus that nomcp-session-idis issued on the reject path. That is the correct place to gate — an unauthenticated caller cannot obtain the session id later tools/call depends on. resolveBindHostdefaults to127.0.0.1andserve()now passeshostname: bindHost. Test in bind-host.test.ts locks the default. The empty-string / whitespace cases in bothparseCliArgsandresolveBindHostcorrectly fall back to loopback rather than expanding to all interfaces (which is whatlisten("")would silently do).--hostthreads throughinstall-serviceinto the written service file; install-service-host.test.ts asserts both presence and absence, and the Makefile forwardsBIND_HOSTonly when set locally so a deploy with no override doesn't blank the remote's own value.- Empty
--hostis rejected at three layers (serverparseHost, CLIparseFlags, and asserted in tests), so a service file carrying--host=can't silently expose the port on every boot. - The startup warning names the two still-exempt routes (
POST /api/hooks/*,GET /api/host) rather than claiming blanket coverage — matches the deferred-residual documented in ADR-054 and the changeset, and won't lie to an operator at the moment they decide to expose the port.
Known residual (/api/hooks/* + /api/host unauthenticated on every bind) is deferred with clear rationale and follow-up scope. Not blocking here.
Minor observations, none blocking:
isLoopbackBindonly recogniseslocalhost/127.0.0.1/::1, so a bind to another 127/8 address (127.0.0.2) would trigger the network-exposure warning — false-positive direction, safe.- The Makefile
.envparser doesn't handle quoted values (BIND_HOST="0.0.0.0"); a quoted value would reach Node'slisten()with quotes and fail loudly — not a silent-widen. Fine to defer. install-prod-service.sh's "[prod] Binding to HOST=$HOST (reachable from the network)" line would be inaccurate if HOST=127.0.0.1, but that combination has no legitimate use case.
Two defects composed into unauthenticated remote code execution, verified live against both the local server and forge rather than inferred: requireAuth was mounted on /api/* and /ws/* only, so /mcp — the transport exposing create_agent/kill_agent/set_manager — matched neither prefix and had no check of its own. Unauthenticated POST /mcp initialize returned 200 + a session id; tools/list then enumerated the full toolset. serve() was called with no hostname, so Node bound 0.0.0.0/::. Confirmed live: node ... TCP *:3100 (LISTEN). Together: any host on the same network could open an MCP session with one curl and create_agent with permissionMode "bypass", an arbitrary workingDirectory and an arbitrary prompt — arbitrary code execution as the server's user, no credential. The repo is public with tagged releases, so this shipped to users whose laptops sit on untrusted networks. /mcp now requires the same token as everything else, checked at the transport boundary so an unauthenticated caller cannot complete initialize and never obtains the session id later calls need. The server binds 127.0.0.1 unless --host / AUTONOMOS_HOST says otherwise, threaded through install-service, install-prod-service.sh and the Makefile so an exposed box keeps its bind. Auth is the boundary; the bind is defense-in-depth. That ordering (Terry's "always require auth" ruling) is what let this delete the localhost-trust category outright rather than gate it — no peer-address check, no exemption to get subtly wrong. It costs nothing: auth.ts has always generated and persisted a random 256-bit token on first start. Known residual, deliberately deferred: POST /api/hooks/* and GET /api/host stay unauthenticated on every bind. They can't spawn or control agents, but can forge agent status and inject dashboard notifications. Authenticating the relay changes how the token travels and risks a fleet-wide status blackout if it fails to arrive — that needs its own PR with a real-spawn QA, not an emergency patch. Every operator-facing string names the gap instead of claiming blanket coverage. BREAKING: a server reached over the network goes loopback-only on upgrade unless AUTONOMOS_HOST=0.0.0.0 is set in that box's .env (not rsynced, so it persists per box) or BIND_HOST=0.0.0.0 is passed to make deploy. Tests are attached to the bind logic, not to an endpoint's lifetime: #221 shipped isLoopbackBind with a locking test, #264 deleted the caller and the test together, and the helper sat inert with zero callers. A deleted test is how a security invariant dies quietly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SeVpLvCDMBZNVnrbkHzAgN
Review follow-up. `tsx --env-file` (the prod wrapper) and hand-quoted service-file args pass `AUTONOMOS_HOST="0.0.0.0"` through WITH the quote characters, and a hostname carrying quotes fails serve() with ENOTFOUND — a crash-loop on the exact deploy where someone is enabling network exposure, and AUTONOMOS_HOST-in-.env is the path the docs recommend. resolveBindHost now peels one matched surrounding quote pair before use. A hostname/IP never legitimately contains a surrounding quote pair, so this is safe; a mismatched or lone quote is left intact and still reaches serve() to fail loudly rather than being silently rewritten into something that binds. Verified: booting via `tsx --env-file` with AUTONOMOS_HOST="0.0.0.0" now binds *:PORT instead of crashing. The nox-0x review flagged the BIND_HOST Makefile path as fail-loud/defer; this is the same class on the recommended env path, fixed at the single chokepoint before serve(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SeVpLvCDMBZNVnrbkHzAgN
aterrylu
force-pushed
the
terry/security-mcp-auth-bind
branch
from
July 18, 2026 03:39
0a13c18 to
16cddb9
Compare
…opback Corrects the bind half of this PR after learning how the server is actually deployed. Users reach it on a remote box over the network — Tailscale `dev-server:3100`, GCP IAP port-forward, or SSH to the public IP. The first two need a real network interface; a loopback default (an earlier revision of this PR) would have silently broken them on upgrade. So the bind default is unchanged: the server still listens on all interfaces, exactly as before. resolveBindHost returns `undefined` when unset and passes that to serve() — Node's `::` dual-stack default, byte-identical to the old no-hostname behavior (deliberately not the string "0.0.0.0", which is IPv4-only and would drop an IPv6 client). `--host` / `AUTONOMOS_HOST` is now an opt-in to RESTRICT to loopback (`--host=127.0.0.1`) for a box reached only via SSH tunnel. The RCE is closed by auth on /mcp, not by the bind — that is unchanged and is the actual fix. This makes the whole PR non-breaking: nobody's network-reached dashboard goes dark on upgrade. The startup line is now informational (not an alarm) and fires only on a non-loopback bind, naming the still-unauthenticated /api/hooks + /api/host. isLoopbackBind no longer treats undefined as loopback (undefined = all interfaces = exposed). Docs, ADR-054, and the changeset updated: no longer a breaking change; Phase 2 (loopback-only internal listener) named as the follow-up that removes the network exposure of /mcp + /ws/gateway + /api/hooks. Verified live: default binds *:PORT (dual-stack) and unauth /mcp 401s; --host=127.0.0.1 binds loopback only and the info line stays silent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SeVpLvCDMBZNVnrbkHzAgN
This was referenced Jul 18, 2026
aterrylu
added a commit
that referenced
this pull request
Jul 24, 2026
…d queue warning Two policies established by this PR, recorded per CLAUDE.md's append-only decision-record convention: 1. The prompt-delivery receipt applies only to providers that emit hook events (`hooks.eventCount > 0`), never a provider-name check — so a provider that doesn't exist yet is classified by what it declares rather than by an allowlist someone has to remember to update. 2. Queued Codex inbound warns the operator once per stall episode at 5 minutes, replacing a ~45-minute silence. The 15-minute tolerance for long turns is unchanged; waiting was always correct, not saying so was the bug. Records the consequence explicitly rather than leaving it to be rediscovered: Codex spawn-with-prompt now has NO delivery detector, and a lost prompt is indistinguishable from a finished agent because the daemon reports the thread idle either way. A Codex-native detector via thread/status is a scoped follow-up, deliberately not this ADR. The ADR number appears in exactly two places (the entry and one code comment) and in no log strings, so a renumber stays a two-minute change — a past collision touched ~18 references across 10+ files. Number assigned by TeamLead@autonomOS (055 reserved for #284, 056 merged with #283). Rides with this PR per the #281/#283 precedent so the code and its rationale stay co-located in git log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP
aterrylu
added a commit
that referenced
this pull request
Jul 24, 2026
…d queue warning Two policies established by this PR, recorded per CLAUDE.md's append-only decision-record convention: 1. The prompt-delivery receipt applies only to providers that emit hook events (`hooks.eventCount > 0`), never a provider-name check — so a provider that doesn't exist yet is classified by what it declares rather than by an allowlist someone has to remember to update. 2. Queued Codex inbound warns the operator once per stall episode at 5 minutes, replacing a ~45-minute silence. The 15-minute tolerance for long turns is unchanged; waiting was always correct, not saying so was the bug. Records the consequence explicitly rather than leaving it to be rediscovered: Codex spawn-with-prompt now has NO delivery detector, and a lost prompt is indistinguishable from a finished agent because the daemon reports the thread idle either way. A Codex-native detector via thread/status is a scoped follow-up, deliberately not this ADR. The ADR number appears in exactly two places (the entry and one code comment) and in no log strings, so a renumber stays a two-minute change — a past collision touched ~18 references across 10+ files. Number assigned by TeamLead@autonomOS (055 reserved for #284, 056 merged with #283). Rides with this PR per the #281/#283 precedent so the code and its rationale stay co-located in git log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP
aterrylu
added a commit
that referenced
this pull request
Jul 26, 2026
…-delivery false alarm (#287) * fix(codex): stop losing inbound messages in silence + kill the prompt-delivery false alarm A Codex agent appeared to drop an inbound message: send() returned success, the gateway logged the connection, and no `[codex-inbound] injected` line ever appeared. Investigation found the message was never dropped — it was correctly QUEUED behind an unfinished turn, in complete silence. From outside, a working queue and a lost message produced byte-identical logs. Root causes fixed: 1. Provider-blind prompt-delivery receipt. `trackPromptDelivery` reads the hook relay, but was gated only on `if (params.prompt)`. Codex emits zero hook events, so its SessionStart never arrived and EVERY prompted Codex agent logged "may have failed to boot" and pushed a dashboard SystemWarning — on agents that had already run their prompt correctly. That false alarm actively misdirected the original diagnosis. Now gated on the CAPABILITY (`hooks.eventCount > 0`) so it stays correct if Codex ever ships hooks. Nothing is lost: the re-delivery fallback needs a SessionStart to become reachable, so for Codex it never was. Gemini (11 events) is unaffected. 2. The inbound queue said nothing. Delivery is idle-gated by design (a `turn/start` mid-turn corrupts the thread), but the wait emitted no output: no enqueue log, a 15-minute silent poll, and an operator notification only after 3 consecutive failures — ~45 minutes. Now logs enqueue, logs every expired attempt, distinguishes "thread still active" from "status unreadable" (naming the cause), and notifies the operator once per stall episode after 5 minutes. The 15-minute tolerance for long turns is kept — waiting is correct, not SAYING you're waiting was the bug. 3. Silent drops on the delivery paths: - `dispose()` cleared the queue with no log — the module's only true message drop, reachable on kill, delete, PTY exit and resume-failure respawn. - The broadcast fan-out skipped endpoint-less Codex agents bare, and broadcast has no per-recipient ack. - Unicast fell through to the channel-server WS for a non-running Codex agent, which "succeeds" into a socket whose reader ignores inbound. - `broadcastToAllAgents` had no `.catch()`; one throw took out every recipient after the sender was already ack'd. Also fixed: `statusLoop`'s escalation could never fire, because `queryIdle` swallows read failures so the catch never ran and `statusFailures` reset every cycle — a daemon that accepts the socket but never answers `thread/read` would freeze the dashboard silently. And RPC timeouts were never cleared on success, leaking a 30s timer per call (test suite exit: 31s -> 1.8s). NOT fixed, deliberately: the original report's other symptom — a Codex thread that goes active at spawn and never finishes — did not reproduce locally with an identical spawn and is Codex-side, past our submission path. This change makes that failure VISIBLE rather than claiming to fix it. Tests: 9 delivery-observability + capability-gate cases against a fake app-server daemon (swaps the global WebSocket, so production framing, id matching and timeout logic all stay under test). Verified live against a real `codex` agent on an isolated dev server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP * docs: add changeset for the Codex inbound observability fix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP * docs: record ADR-057 — capability-gated prompt receipt + 5-min inbound queue warning Two policies established by this PR, recorded per CLAUDE.md's append-only decision-record convention: 1. The prompt-delivery receipt applies only to providers that emit hook events (`hooks.eventCount > 0`), never a provider-name check — so a provider that doesn't exist yet is classified by what it declares rather than by an allowlist someone has to remember to update. 2. Queued Codex inbound warns the operator once per stall episode at 5 minutes, replacing a ~45-minute silence. The 15-minute tolerance for long turns is unchanged; waiting was always correct, not saying so was the bug. Records the consequence explicitly rather than leaving it to be rediscovered: Codex spawn-with-prompt now has NO delivery detector, and a lost prompt is indistinguishable from a finished agent because the daemon reports the thread idle either way. A Codex-native detector via thread/status is a scoped follow-up, deliberately not this ADR. The ADR number appears in exactly two places (the entry and one code comment) and in no log strings, so a renumber stays a two-minute change — a past collision touched ~18 references across 10+ files. Number assigned by TeamLead@autonomOS (055 reserved for #284, 056 merged with #283). Rides with this PR per the #281/#283 precedent so the code and its rationale stay co-located in git log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP * test(gateway): cover the non-running-Codex delivery guard The PR's one behavior change had zero test coverage — gateway-router.test.ts never exercised the Codex branches at all. Adds both directions against a real isolated agent store: - an EXITED Codex agent whose channel-server socket is still open now gets a visible error and NO socket write (previously: the write "succeeded", the sender was told null/success, and the recipient discarded the bytes) - a running Claude Code agent still delivers over that same socket — the guard must stay Codex-specific, since Claude Code genuinely reads inbound there Found by asking whether the change was actually verified rather than assuming the suite covered it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP * test(codex): cover the two escalation paths that were unreachable or one-shot Both are behavior, not logging — a notification that can never fire looks exactly like a healthy system, which is the failure mode this whole PR is about. - statusLoop: a daemon that accepts the WebSocket but never answers thread/read now warns. Previously queryIdle swallowed the failure, so the catch never ran, statusFailures reset every cycle, and the warning was unreachable for the likeliest daemon failure there is. - noteFailure: re-notifies on a doubling backoff instead of exactly once per controller lifetime. Moves ensureThread's hardcoded 1s poll into `timings` as threadPollMs — it was the last wait in the module a test couldn't shrink, which is what made the second case unreachable in under 6 seconds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP * docs(adr-057): state the idle-gate premise as an untested assumption ADR-057's Context asserted that injecting a turn/start mid-turn "interleaves and corrupts the thread" and called the resulting idle gate right. Subsequent testing disproved it: 8 injections across 5 thread states (blocking MCP call, apply_patch mid-write, wait_agent block, reasoning, backgrounded shell, plus a control) were all accepted with the original work completing intact. The claim is reworded, not removed. Deleting it would erase the "we believed X, then tested it" trail; leaving it would enter a known-false statement into an append-only record, where a future reader could believe it and never find the reversal — the exact misleading-signal failure this PR exists to fix. Stating it as an assumption untested AT THE TIME is simply accurate about that moment. Consistency is what decides it: the forthcoming reversal ADR is required to label its one surviving safeguard "untested conservatism, not a measured requirement." We don't get to hold an hours-old ADR of our own to a lower bar than the one we're writing today. Docs only — no behavior change. Nothing has entered the record yet; #287 is unmerged, so this is not a rewrite of history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP * fix(codex): address review — remove dead `warned` field, give statusLoop the same backoff Two findings from nox-0x's review of #287: 1. `QueuedInbound.warned` was dead state — set to false in enqueue() and never read again, a leftover from moving warn-once tracking to the controller- scoped `longWaitWarned`. Its doc comment described per-message semantics the code deliberately rejects. Removed the field and corrected the comment. 2. `statusLoop`'s escalation was still `=== FAILURES_BEFORE_WARN` — the exact one-shot-per-lifetime flaw this PR fixes in `noteFailure`, and its own comment claimed to "mirror the delivery-path warning" after that mirror had broken. Now escalates on the same doubling backoff (nextStatusWarnAt, re-armed on a successful reconcile), so a daemon still unreachable an hour later keeps surfacing instead of going quiet after the first warning. New test pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YkDUfhTJQDjyXetrZEpP --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two defects composed into unauthenticated remote code execution. Both verified live against the local server and forge — not inferred from reading code.
1.
/mcphad no auth at all.requireAuthwas mounted on/api/*and/ws/*only./mcp— the Streamable-HTTP transport exposingcreate_agent,kill_agent,set_manager— matched neither prefix and had no internal check.2. The server bound every interface.
serve()was called with nohostname, so Node bound0.0.0.0/::. Confirmed live:node … TCP *:3100 (LISTEN).Together: any host on the same network — café, office, dorm — could open an MCP session with a single
curlandcreate_agentwithpermissionMode: "bypass", an arbitraryworkingDirectory(runtime.tschecks existence only) and an arbitrary prompt. That is arbitrary code execution as the server's user, no credential, no skill. CVSS ≈ 9.8. The repo is public with tagged releases (latest v0.4.0), so this shipped.The chain was proven and stopped at
tools/list;create_agentwas never called.flowchart LR A[Any host on the network] -->|no credential| B{"bind *:3100"} B --> C["POST /api/agents"] --> D["401 ✅"] B --> E["POST /mcp"] --> F["no middleware<br/>no internal check"] F --> G["initialize → 200 + session"] G --> H["create_agent<br/>bypass + any cwd + any prompt"] H --> I["RCE as the server's user 💥"]Solution
Auth is the boundary; the bind is defense-in-depth. That ordering is Terry's "always require auth" ruling, and it's what let this delete the localhost-trust category instead of gating it — no peer-address check, no exemption to get subtly wrong. It costs nothing:
auth.tshas always generated and persisted a random 256-bit token on first start, so there is no tokenless state to design around.app.use("/mcp", requireAuth)— checked at the transport boundary, so an unauthenticated caller cannot completeinitializeand never obtains the session id later calls depend on. Asserting only thattools/call401s would have missed a regression leaving session creation open.127.0.0.1by default, with--host/AUTONOMOS_HOSTas explicit opt-in, threaded throughinstall-service→install-prod-service.sh→Makefile(BIND_HOST) so a deliberately-exposed box keeps its bind acrossmake deploy.Known residual — deliberately NOT closed here
POST /api/hooks/*andGET /api/hostremain unauthenticated on every bind. They cannot spawn or control agents, but an unauthenticated caller can forge agent status and inject dashboard notifications (including theproactive: truepush path).Deferred on purpose: authenticating the relay changes how the token travels (TokenSecurityAudit@autonomOS's scope) and risks a fleet-wide status blackout if the token fails to reach the hooks. That needs its own PR with a real-spawn
/qa, not an emergency patch. Until then every operator-facing string names the gap. See ADR-054.A server reached over the network goes loopback-only on upgrade. Set in that box's
.env(not rsynced, so it persists per box):AUTONOMOS_HOSTis recommended overBIND_HOST: the service wrapper runstsx --env-file=<repo>/.env, so it survivesinstall-service --force, whereas a service-file-baked--hostis dropped by a reinstall that omits it. One-off alternative:make deploy BIND_HOST=0.0.0.0.Testing
Live, against a real server on an isolated port + config dir (never touching the running deployment):
*:3100127.0.0.1:39131/mcp initialize200+ session id/mcpGET / DELETE--host=0.0.0.0*, warns, still 401s unauthmake checkgreen — 632 server/CLI + 234 dashboard testsAUTONOMOS_INTEGRATION=1mcp-auth suite green locally (4/4)mcp-auth.test.ts(real HTTP, real server),bind-host.test.ts,cli-args.test.ts,install-service-host.test.ts(asserts the actual written service file, including the absence case)Risks
--host=<specific IP>(not0.0.0.0) makes the post-install health check and the ADR-029 running-server guard — both of which probelocalhost— report a false failure. Documented in--helpand the README; not fixed here to keep the patch small./mcpchange would break any external HTTP MCP client (Claude Desktop, CI). No in-repo client is affected — agents use the stdio channel-server, not HTTP/mcp— and the "legit client still works" test covers the path.What
/polishcaught (worth reading)Three real bugs, all found before this PR existed:
.envhas noBIND_HOST(new + rsync-excluded), so$(if $(BIND_HOST),…)forwarded nothing andinstall-service --forcerewrote the unit without--host. Fixed by recommendingAUTONOMOS_HOSTin.env(survives reinstalls) and documenting the pre-upgrade step.BIND_HOST=0.0.0.0 # commentin.envmademake proda silent no-op — the#opened a shell comment that swallowed the installer, and the recipe is@-prefixed so there was no output. A green deploy that changed nothing. Fixed by stripping the comment + quoting; the strip itself then needed\#because make comments the line otherwise. Verified across four.envshapes./api/hooks/*and/api/hostwere exempt. That string is the last thing an operator reads before exposing a box. Now it names them.Correction to the record
The audit first reported
isLoopbackBind(zero callers) as "designed and never wired up." Wrong, andgit log -Ssays so: #221 added it with its caller and a locking test; #264 deleted the caller and the test together, orphaning the helper.The corrected story is worse. ADR-041 already recorded that unconditional auth-exemption is a HIGH-severity credential-injection vector on non-loopback binds — found by
/polish, fixed pre-ship for one endpoint./api/hooks/*is the "existing localhost-trust model" ADR-041 says it was mirroring but chose to be stricter than, and it was left exempt on every bind. The lesson was written down, applied once, and never applied to the neighbour it was copied from — then the only test encoding it was deleted as collateral.A deleted test is how a security invariant dies quietly. Removing the endpoint made removing its test look like hygiene. Nothing failed. The helper just sat there looking like a guard. The new tests attach to the bind logic itself, not to any endpoint's lifetime.
Two candidate findings were also retracted as false positives after empirical checks: a slash-normalisation auth bypass (
//api/agents→ 200 is the SPA static fallback; handler-vs-router 404 shape disproved it) and CSWSH from a third-party origin (SameSite=Lax withholds the cookie on a subresource WS handshake).Alternatives considered
isLoopbackBindto gate the exemptions — rejected. Preserves the "trusted context" idea that failed here in the first place./api/hooks/*(hook traffic is always loopback —shared.tshardcodesAUTONOMOS_SERVER=localhost) — designed, then dropped for Terry's always-auth rule. Kept as fallback. It carried three landmines, all empirically probed:::ffff:127.0.0.1must PASS (a::1-only check 401s every hook → fleet blackout);startsWith("::ffff:")is a total bypass (it prefixes any IPv4); and loopback is all of127/8—127.0.0.53is loopback on Linux but not macOS, so=== "127.0.0.1"passes QA on a Mac and is wrong on forge.EADDRNOTAVAILiftailscaledisn't up yet (forge is systemd), and with auth universal the bind isn't the boundary.🤖 Generated with Claude Code
https://claude.ai/code/session_01SeVpLvCDMBZNVnrbkHzAgN