agenthropic

Threat model

This page walks through four verified, source-level vulnerabilities found in the comparable self-hosted Claude Code dashboards audited before agenthropic’s build decision — simple10, cast, hoangsonww, and disler — states the attacker model each one hands a local network or a malicious client, and maps each to the exact design invariant (DESIGN.md §8) that forecloses it structurally in agenthropic. The key takeaway: the due-diligence’s cross-cutting verdict is blunt, not diplomatic — “every viable candidate binds 0.0.0.0 and/or ships auth that is a no-op in practice” (docs/due-diligence/security.md) — so agenthropic’s four non-negotiable security constraints (loopback-only bind, mandatory timingSafeEqual token, no request-driven spawner, no SSRF) are not generic hardening boilerplate borrowed from a checklist. They are named, structural responses to four real bugs this project read at the source and decided never to repeat.

Status (updated 2026-07, as built). This page was written in the bootstrap phase, when every mitigation below was a locked design invariant and nothing more. Implementation began 2026-07-11, and the mitigations are now shipped and test-proven: loopback-or-fail bind, mandatory-token-or-fail-startup with a timing-safe compare on every /api/* route, same-origin-before-auth SSE, the no-spawner/no-wide-bind/no-eval static gate running in CI, and hook-payload redaction at the ingest boundary. The SSRF mitigation is currently satisfied by absence — no outbound-dialing code exists at all (the webhook sink is post-1.0) — and, since 2026-09-26, that absence is gate-enforced in server-process code (WP-F5). None of the invariants was relaxed. The rival findings and attacker models below are the historical record and remain accurate; per-section as-built notes mark what changed — see status at the end.

Scope of this page

This is the “what the field got wrong, and why we can’t repeat it” reference. It covers four concrete, source-verified failure classes and the attacker model each enables. Adjacent security topics live elsewhere and are only cross-referenced here:

Amended 2026-08. A fifth section has since been added — the corpus read surface — and it is not a rival finding. It is agenthropic’s own untrusted-input boundary, documented here because it is the one surface on this page the project cannot foreclose by declining to build it: reading the corpus is the product.

The honest read: the field is worse than advertised

docs/due-diligence/security.md opens by correcting the premise most of these projects’ own READMEs invite: “loopback by default” is not what the source shows. The verified posture, at source, for the four projects in scope here:

Project Bind Auth CORS Worst finding Location
simple10 0.0.0.0 none wildcard LAN-exposed dashboard, no token; stores full tool payloads server bind + CORS config
cast 0.0.0.0 write-gate good, GET reads unauth — Read-gate 404s writes without CAST_DASHBOARD_CONTROL=1 + token, but unauth GETs dump every table index.ts:101, controlGate.ts
hoangsonww configurable DASHBOARD_TOKEN — no-op when unset — RCE: /api/run accepts permission-mode from the browser; ALLOWED_PERMISSION_MODES includes bypassPermissions → spawns claude --permission-mode bypassPermissions in any cwd run.js:96, security.js:133
disler — none * SSRF: server dials an arbitrary responseWebSocketUrl from the request body; unauth POST /events; .env guard commented out index.ts:198-201, pre_tool_use.py:324-327

(Reproduced from docs/due-diligence/security.md, restricted to the four projects in this page’s scope. The full matrix also covers nirdiamant and claude-code-templates — see that file directly.)

Two additional per-project facts sharpen the read:

Attacker models used in this page

Three attacker models recur across the four findings below. Naming them once, up front, avoids re-litigating “who could actually exploit this” in every section. A fourth — the local corpus writer — is defined in section 5 rather than here, because none of the four rival findings involve it:

Model Definition Defeated by
LAN peer Any device on the same network segment as the Mac Mini — another laptop on the same Wi-Fi, a compromised IoT device, a guest on the same router — reaching the dashboard’s port directly, no credential required beyond network access. Loopback-only bind (127.0.0.1): there is nothing at a routable address to connect to.
Local multi-user Another OS account on the same host connecting to 127.0.0.1:<port>. This attacker model survives a perfect loopback bind — bind alone only stops the network; it does not stop a second account on the same Mac Mini. Mandatory auth token, timingSafeEqual-compared, checked independently of bind.
Malicious event payload A client that can submit — or, worse, does not even need authorization to submit — an event/webhook-registration payload whose contents the server later acts on, e.g. dialing a URL the payload names. Never deriving an outbound-dial target from event-payload data; outbound targets are operator-configured only.

As built — an honest amendment to the local-multi-user row (M-11, fixed 2026-08). “Defeated by the mandatory token” is only as true as the token’s own custody, and agenthropic’s first shipped hook command undermined it: the command written into Claude Code’s settings let the shell expand ${DASHBOARD_TOKEN} into curl’s argv, so for the up-to-3-second life of every hook POST the token value sat in the process table — harvestable via ps//proc/<pid>/cmdline by exactly this attacker, who could then pass the gate legitimately. The fix (2026-08): the generated command now has curl import the env var itself (--variable '%DASHBOARD_TOKEN' + a single-quoted --expand-header template, curl ≥ 8.3.0), so the token appears in no process’s argv; a test simulates the shell expansion and asserts a canary value is absent from every argv word. On an older curl the command fails closed — nothing sent, session never blocked — rather than falling back to the leaking shape. Residual exposure: a process of the same account, or root, can always read the token (process environment, the profile/launchd plist that exports it); the token defends the cross-account boundary, nothing stronger. Details: hooks installer.

A fourth pattern shows up only in hoangsonww and deserves its own framing: an attacker model that collapses the other three. Because its bind is “configurable” and its token is “a no-op when unset,” whichever of LAN peer or local multi-user the deployer’s bind setting happens to expose gets the same outcome — full RCE — because auth was supposed to be the backstop and provided none. Section “hoangsonww” below covers this in detail.

1. simple10 — 0.0.0.0 + zero auth

Threat. Ships bound to 0.0.0.0, wildcard CORS, and no authentication of any kind — “LAN-exposed dashboard, no token” (docs/due-diligence/security.md posture matrix). It additionally stores full tool payloads rather than redacted ones (docs/due-diligence/projects/simple10.md, “Must-fix before exposure”), so anything an agent’s tool calls touched — file contents, command output, credentials that happened to pass through a tool argument — is retained in full and reachable by whoever can reach the dashboard.

Attacker model. LAN peer. No credential is needed; reaching the port is sufficient. On a home network this is anyone sharing the router; on the Mac Mini’s network specifically, anything else on that segment.

Why it matters for agenthropic specifically. simple10 is the project whose application-layer patterns (ports/adapters storage, strategy-pattern agent classes, buildAgentTree()/layoutTree()) agenthropic explicitly studies and reuses (DESIGN §0, §7). Studying a project’s architecture and inheriting its network posture are two different acts — agenthropic does the first, never the second.

Our mitigation.

2. cast — 0.0.0.0 + unauthenticated GET reads

Threat. cast’s controlGate.ts is, on its own terms, good security engineering: non-safe HTTP verbs 404 unless CAST_DASHBOARD_CONTROL=1 and DASHBOARD_TOKEN are both set, compared with timingSafeEqual, mounted before the router (docs/due-diligence/projects/cast.md). But the server binds 0.0.0.0 (index.ts:101), and the gate protects writes only — every GET route is unauthenticated and, per the due-diligence, “unauth GETs dump every table” (docs/due-diligence/security.md). The write-side is exemplary; the read side is wide open on a routable address.

Attacker model. LAN peer, performing purely passive reconnaissance — no write attempt is needed to exfiltrate session contents, tool payloads, or cost data; a GET is enough.

Our mitigation.

3. hoangsonww — no-op token + /api/run spawner is RCE

Threat — two independent failures that compound.

  1. The token is a no-op when unset. DASHBOARD_TOKEN auth is “opt-in and a no-op when unset” (security.js:133, per docs/due-diligence/projects/hoangsonww.md). Bind is “configurable” (docs/due-diligence/security.md). If an operator never sets the token — the default, unconfigured state — there is no authentication at all, regardless of bind.
  2. /api/run accepts a permission-mode from the browser request body, and ALLOWED_PERMISSION_MODES includes bypassPermissions (run.js:96). The result: a browser request spawns claude --permission-mode bypassPermissions in an attacker-chosen absolute cwd — arbitrary code execution as the host user (docs/due-diligence/projects/hoangsonww.md). The due-diligence is explicit that the report’s originally-flagged “concurrency cap of 10,000” is a red herring; the permission mode is the actual lever.

Put together: on an unconfigured install, this is — in docs/due-diligence/projects/hoangsonww.md’s own words — “a self-hosted RCE box.” It is the single worst finding across every project audited (docs/due-diligence/security.md, “The two standouts”).

Attacker model. This is the finding that collapses the attacker-model distinction drawn earlier. Because the token is a no-op when unset, it does not matter whether the exposure ends up being a LAN peer (if bind is left wide) or a local multi-user (if bind is tightened but the token is still unset) — either one reaches /api/run with zero authentication and gets host-user code execution. Auth was supposed to be the backstop regardless of bind, and for an unconfigured install it provides none.

Our mitigation — two structurally separate answers, because these are two separate bugs:

4. disler — SSRF via an event-payload URL

Threat. The server dials an arbitrary responseWebSocketUrl taken directly from the incoming request body (index.ts:198-201, per docs/due-diligence/projects/disler.md and docs/due-diligence/security.md). Combined with an unauthenticated POST /events and wildcard CORS (*), any client that can reach the endpoint can make the server originate an outbound connection to a URL of the attacker’s choosing — the textbook SSRF pattern: the observability server becomes a stepping stone for internal-network scanning or exfiltration, dialing out on the attacker’s behalf using the server’s own network position. A second finding compounds the trust problem: the .env/key guard the original report credited it for is commented out in the shipped source (pre_tool_use.py:324-327).

Attacker model. Malicious event payload. No LAN position and no local account are needed — anything able to reach the unauthenticated POST /events endpoint controls a field the server will act on. This is the attacker model named directly in DESIGN.md §8’s SSRF clause: “no SSRF (never dial a URL taken from an event payload — disler’s bug).”

Why this one is easy to reintroduce by accident. disler is also the project agenthropic explicitly studies as “the clearest teaching example of the whole ingest pattern… hook → HTTP → SQLite → WebSocket → browser” (docs/due-diligence/projects/disler.md; DESIGN §3, §7 — its ~180-line send_event.py is named as the reference implementation to learn the loop from, not build on). The webhook-sink leg of agenthropic’s own architecture (event → outbound HTTP → Telegram) is structurally the same shape as the code that has the bug. The mitigation therefore has to be a rule about where a dial target is allowed to come from, not merely “don’t copy disler’s file.” (As built, that leg does not exist yet — the webhook sink is post-1.0, entered only via KC-5, and today the server makes no outbound request of any kind. The rule below is what any future dispatcher will be held to.)

Our mitigation.

5. The corpus read surface — agenthropic’s own untrusted input

(Added 2026-08, written from the shipped ingest adapter.)

The four findings above are other people’s bugs, and every mitigation against them is a surface agenthropic simply never built. This section is different in kind. It describes a surface the project cannot decline, because reading the corpus is the product: ~/.claude/projects is a directory tree written by another program, on a schedule agenthropic does not control, containing filenames and file contents that anything Claude Code ran can have influenced. Treating that tree as trusted input on the grounds that it sits inside the operator’s own home directory would be the same category of error as simple10’s “loopback by default” — a posture asserted rather than enforced.

Attacker model. Local corpus writer — any process able to create, rename, or swap an entry under the corpus root. On a single-user Mac Mini that is usually Claude Code itself, but it is equally every tool Claude Code runs and every path a prompt can talk one of those tools into writing. This model needs no network, no dashboard token, and is entirely untouched by the loopback bind: it is the one attacker on this page that all four mitigations above leave in place. The reader is therefore built to be structurally incapable of the things that would matter, rather than merely careful.

What the reader refuses, and why.

What this does not defend against, stated plainly. None of the above protects against corpus contents that are hostile but well-formed — a transcript that misreports token counts, or a filename crafted to mislead a human reader rather than to traverse. The reader’s contract is narrow and deliberately so: stay inside the root, stay bounded, never write. Judging whether the JSONL is telling the truth is not a security control and is not claimed as one. Equally, nothing here defends the corpus itself: an attacker who can already write into ~/.claude/projects can shape what the dashboard displays, and the only thing these guards secure is that they cannot use that position to escape the tree, exhaust the process, or make agenthropic write anything back.

Traceability — finding → invariant → source

Rival finding Attacker model agenthropic invariant Source
simple10: 0.0.0.0, zero auth, wildcard CORS LAN peer Bind 127.0.0.1 only, never 0.0.0.0 CLAUDE.md; DESIGN.md §8
simple10: stores full tool payloads (data-at-rest exposure, any reader) Store redacted payloads — requirement decided, exact field list/thresholds open docs/due-diligence/security.md; backup & restore
cast: 0.0.0.0 + unauth GET reads LAN peer Bind 127.0.0.1 only (removes the reach); reads token-gated too, not only writes docs/due-diligence/security.md; security model
hoangsonww: DASHBOARD_TOKEN no-op when unset Local multi-user or LAN peer, whichever bind exposes Auth token mandatory, not opt-in; timingSafeEqual DESIGN.md §8
hoangsonww: /api/run + bypassPermissions = RCE Any client reaching the endpoint Never build a request-driven claude/subprocess spawner CLAUDE.md; DESIGN.md §8
disler: SSRF via responseWebSocketUrl Malicious event payload Never dial a URL taken from an event payload; outbound targets are operator-configured DESIGN.md §8
disler: unauth POST /events, CORS * Malicious event payload / any client No unauthenticated write endpoints; same-origin check on the realtime channel CLAUDE.md; DESIGN.md §8

The table has no row for section 5 because there is no rival finding to trace: the corpus read surface is agenthropic’s own, and its mitigations answer to the shipped adapter rather than to DESIGN.md §8.

What agenthropic structurally forecloses

Annotated against the canonical pipeline diagram (DESIGN.md §3):

                         LAN / other local accounts
                                    │
                                    X   no 0.0.0.0 bind — nothing routable to reach
                                    │      (forecloses simple10 & cast's LAN-peer path)
                                    ▼
127.0.0.1 only ──►  hook-ingest  ──►  SQLite (WAL)  ──►  SSE  ──►  browser SPA
                        │  ▲                                 (same-origin only —
                        │  └── mandatory token,               forecloses disler's
                        │      timingSafeEqual                wildcard-CORS path)
                        │      (forecloses hoangsonww's
                        │       no-op-when-unset path)
                        │
                        X   no /api/run, no claude spawn, no subprocess
                        │      driven by request input
                        │      (forecloses hoangsonww's RCE — structurally,
                        │       by never building the surface)
                        ▼
                  webhook sink ──►  Telegram relay (@baev_bot_bot)
                        │
                        X   target is operator-configured (webhook_targets),
                            never a URL taken from an event payload
                            (forecloses disler's SSRF)

Every X above is a surface that is not built, not a surface that is built and then locked down. This is the deliberate consequence of DESIGN §0’s decision to build greenfield rather than fork: the RCE spawner, the wildcard CORS, the no-op token are not bugs to patch in agenthropic’s own code because that code does not exist in the first place — they only had to be studied, named, and excluded at the design stage.

As built: the top of the diagram is now running code and each X is now enforced, not only designed: the static gate (scripts/check-no-spawner.mjs, a CI step) turns the build red on any subprocess import, wide bind, WebSocket server, or dynamic eval anywhere in the tree, and the security-contract tests boot the real server and prove the loopback bind, mandatory token, and same-origin SSE. Two as-built refinements to the picture: the realtime leg is SSE (CD-5), as drawn; and the bottom leg — webhook sink → Telegram relay — is not built (post-1.0, KC-5), so its X currently holds in the strongest form: no outbound dial exists at all. There is also a second ingest path the diagram predates: JSONL transcripts read directly from the local filesystem (~/.claude/projects), which never crosses an HTTP surface in the first place — and which, precisely because none of the X marks above apply to it, gets its own treatment in section 5.

Status and what’s not yet built

Consistent with docs/site/STYLE-GUIDE.md’s rule to say plainly when something is undecided rather than gloss over it:

See also