agenthropic

Security model

This page enumerates every security control agenthropic commits to, as a rule → why → how enforced table for each one: bind loopback-only (never 0.0.0.0), gate every endpoint behind a mandatory timingSafeEqual token (never a no-op-when-unset one), build no browser-driven subprocess/claude spawner ever, enforce same-origin on the realtime channel, allow no unauthenticated write endpoint, dial no URL out of an event payload (no SSRF), reach the dashboard only through an SSH or Tailscale tunnel, keep ANTHROPIC_API_KEY out of the dashboard’s own environment, and run SQLite in WAL mode with a backup routine that is actually exercised, not assumed. Key takeaway: none of this is aspirational hardening bolted on later — it is the corrective response to a source-level audit that found every real-world rival binds 0.0.0.0 and/or ships auth that is a no-op in practice, one of them with a live remote-code-execution spawner, and the design basis (ai/DESIGN.md §8, digesting that audit) calls this “non-negotiable.” The build plan backs every rule with a CI gate that is meant to turn the build red on violation, not a review-time reminder — see CI gates enforcing this below.

Update — 2026-07 (as built). This page was written pre-Phase-0, when every gate was designed but unimplemented. Implementation began 2026-07-11, and the code-level controls above now exist and are test-proven: the server binds 127.0.0.1 only and refuses to start without DASHBOARD_TOKEN (apps/server/src/config.ts / src/index.ts); a single global onRequest hook in apps/server/src/server.ts gates every /api/* route with a timing-safe token compare (the built SPA shell and its static assets, which hold no secret, are the only routes outside the gate); /api/stream rejects foreign Origin headers with 403 before auth; SQLite opens in WAL mode with foreign_keys=ON asserted on every connection (apps/server/src/db/connection.ts); the no-spawner static gate (scripts/check-no-spawner.mjs) and the license gate run as CI steps in .github/workflows/ci.yml; and the WP-F7 security-contract suite (apps/server/test/security-contract.test.ts) boots the real composition root and asserts loopback bind, 401-without-token, same-origin-only SSE, and no-token-no-start. Two boxes in the picture below — the webhook sink and the Telegram relay — remain planned, post-1.0 (entered only via KC-5); they are kept in the diagram as the target contract, marked as such. Per-rule as-built notes follow each rule.

Why a flagship page: the field failed at exactly this

The independent due-diligence audit behind the design basis checked six real, already-running dashboards against the same bar agenthropic sets for itself, and found every one of them short on at least one axis:

Project Bind Auth Worst finding
hoangsonww configurable DASHBOARD_TOKEN — no-op when unset RCE: /api/run spawns claude --permission-mode bypassPermissions from browser-supplied input
simple10 0.0.0.0 none LAN-exposed dashboard, wildcard CORS, stores full tool payloads
cast 0.0.0.0 write-gate good, GET reads unauth Unauth GETs dump every table despite a solid write-gate pattern
disler — none SSRF: dials an arbitrary responseWebSocketUrl taken from the request body
nirdiamant — none Command injection via execSync in the snapshot name, plus an ANTHROPIC_API_KEY-gated feature that ships local files to Anthropic
claude-code-templates 0.0.0.0 none LAN-exposed analytics, no auth at all

Source: ai/DESIGN.md §8 (“Every audited option binds 0.0.0.0 and/or ships no-op auth in practice”); the full posture matrix with file/line citations is ../../due-diligence/security.md. This table is condensed context for why each rule below exists — the systematic anti-pattern catalogue and how agenthropic structurally avoids each one is the dedicated subject of the threat model.

The trust boundary, in one picture

                         Mac Mini M4 — bind 127.0.0.1 only, never 0.0.0.0
        ┌───────────────────────────────────────────────────────────────────┐
        │                                                                    │
remote  │   SSH port-forward / Tailscale tunnel                              │
client ─┼──────────────┐          (never a reverse proxy to the open port)   │
        │              ▼                                                    │
        │   ┌────────────────────────────────────────────────────────┐      │
        │   │ timingSafeEqual(DASHBOARD_TOKEN) — every endpoint       │      │
        │   │ mandatory: fails startup if unset, not a no-op          │      │
        │   └───────────────────────┬────────────────────────────────┘      │
        │                           ▼                                       │
        │            Fastify server (apps/server)                           │
        │   ┌─────────────┬──────────────────┬───────────────────────┐      │
        │   │ read API    │ hook-ingest       │ /api/stream (SSE)     │      │
        │   │ auth-gated  │ receiver          │ auth + same-origin    │      │
        │   │             │ auth-gated        │ (no wildcard CORS)    │      │
        │   └─────────────┴──────────────────┴───────────────────────┘      │
        │                           │                                       │
        │                           ▼                                       │
        │            SQLite (WAL mode) ── backup ── tested restore          │
        │                           │                                       │
        │                           ▼                                       │
        │            webhook sink → operator-configured targets only        │
        │            (never a URL read from an event payload)               │
        │                           │                                       │
        │                           ▼                                       │
        │            Telegram relay (@baev_bot_bot), secret via token_ref   │
        │            (launchd env / chmod-600 — never in SQLite, never      │
        │             sent to the browser)                                  │
        └───────────────────────────────────────────────────────────────────┘

NEVER, anywhere inside this box: a 0.0.0.0 bind · a /api/run-shaped
`claude`/subprocess spawner driven by request input · a dial-out to a
payload-supplied URL.

Everything inside the box is untrusted-by-default until it crosses the token gate; nothing inside the box is reachable from outside the box except through the tunnel. This is the same ingest-loop diagram as the architecture overview with the trust boundary drawn explicitly around it.

As built: everything from the tunnel down through “SQLite (WAL mode) — backup — tested restore” exists and runs. The two bottom boxes — the webhook sink and the Telegram relay — are not built: alerting is post-1.0 and is entered only via KC-5 (earned by real daily use). Today the server makes no outbound network request of any kind, so the “operator-configured targets only” constraint is currently satisfied in the strongest possible way — the dial-out surface does not exist. One refinement the diagram’s token box undersells: the same-origin check on /api/stream runs before the token check (a cross-origin request is 403 even with a valid token), and the JSONL ingest path enters the box directly from the local filesystem (~/.claude/projects), never through an HTTP endpoint.

The control catalogue

Nine rules, each stated as rule → why → how enforced. Sources: ai/DESIGN.md §8 (the non-negotiable list itself) and docs/analysis/development-plan.md (the work packages and Definition-of-Done that turn each rule into CI-blocking code).

1. Bind loopback only (127.0.0.1), never 0.0.0.0

2. Auth token is mandatory — timingSafeEqual, not a no-op-when-unset

3. Never a browser-driven subprocess / claude spawner

4. Same-origin check on the realtime channel

5. No unauthenticated endpoints — read or write

6. No SSRF — never dial a URL taken from an event payload

7. Remote access via tunnel only

8. ANTHROPIC_API_KEY stays out of the dashboard’s own environment

9. SQLite in WAL mode, with a backup routine that is actually exercised

CI gates enforcing this

None of the nine rules above rely on a reviewer remembering to check for them. Each has a named work package, in docs/analysis/development-plan.md, whose Done-when is a CI-observable condition:

Rule Work package(s) Gate mechanism Phase
1. Loopback-only bind WP-U0, WP-F7 Contract test: loopback-or-fail; red until WP-U0 wires it 1
2. Mandatory timingSafeEqual token WP-F7, WP-U0, WP-U2 Contract test: fails startup when token unset; every route auth-guarded 1/4
3. No subprocess spawner WP-F5 Static grep/AST gate: any child_process import in apps/server → CI red 1
4. Same-origin realtime channel WP-F7, WP-U0, WP-U1 Contract test + shared/security origin helper; cross-origin Origin on /api/stream rejected 1/4
5. No unauthenticated endpoints WP-U2 Every read/write route wrapped by the auth guard; asserted per-route (WP-A8 was cut per best-path §6.2) 1/4
6. No SSRF WP-F5, WP-A4, WP-A10 Static gate + dedicated negative test proving no payload-URL dial-out 1/5/6
7. Tunnel-only remote access (operational — no code gate) Never build a reverse proxy / public-bind path; rule 1’s gate is the backstop —
8. ANTHROPIC_API_KEY isolation (none needed — WP-X11 deleted per best-path §6.3) No experimental stub exists; the key never enters the dashboard env (CD-10) —
9. WAL + tested restore WP-D2, WP-F8, WP-X9 Pragma assertion on connect; restore actually exercised; release-checklist line item 1/6
License/provenance for any borrowed pattern WP-F6 Non-allowlisted dependency license → CI red 1
Coverage floor for all of the above WP-F3, WP-X5 Merge-blocking >90% coverage gate, live from Phase 1 — as built: the thresholds are 100, and “merge-blocking” holds for anyone who is not the repository owner; see the note below 1

As built (what is verifiably wired today). .github/workflows/ci.yml runs, in this order: the gate:spawner security gate first, then typecheck, lint, format check, the web production build (pnpm --filter @agenthropic/web build), the test suite, and finally the license gate (gate:licenses). The ordering is deliberate and stated in the workflow’s own comment: the security gate is the cheapest check in the file and the only one guarding an invariant the project cannot walk back, so a change that breaks it is told so in seconds rather than after a full test run. The web build is its own step because unit tests exercise modules under the Vitest transform — a change that only breaks vite build would otherwise merge green and first fail at release time.

The coverage floor is 100, not 90. All five Vitest configs (apps/server, apps/web, packages/core, packages/shared, packages/test-fixtures) set thresholds: { lines, branches, functions, statements: 100 }, so the test step itself fails below that. The configs carry their own justification, and it is the reason the number moved rather than a boast: “a 90% bar on a package sitting at 100% licenses a ten-point regression to pass in silence, which is the opposite of a gate.” Nor is the figure bought with suppressions — src/** contains zero v8 ignore directives, and apps/server/test/coverage-honesty.test.ts fails the build if one appears. When server.ts carried a genuinely unreachable ?? arm that held branches at 99, the arm was deleted, not suppressed: an ignore pragma would have removed both arms of the operator from the denominator and bought a cosmetic

  1. CD-7’s “>90%” remains the ratified floor; 100 is where the code actually sits and therefore where the gate is pinned.

The rule 1/2/4/5 contract tests exist (security-contract.test.ts) and run inside the test step. Row 6’s negative test is moot until the dispatcher exists (see rule 6), and row 9’s “restore exercised” is test-level (see rule 9).

This paragraph used to end on an honest caveat — that making CI merge-blocking requires a GitHub branch-protection rule, an owner action on github.com that the repository itself could not attest — and that was true until 2026-08-25. It is no longer. main is branch-protected: the required status check is the context ci (lowercase — the job id in .github/workflows/ci.yml; the workflow’s display name CI is not the context), and force-pushes to main and deletion of main are refused for everyone. So a red gate now does withhold the merge button — from a contributor. It does not withhold it from the repository owner, because enforce_admins is deliberately off: agenthropic has exactly one maintainer whose normal working mode is a direct push to main, and turning admin enforcement on would lock the sole maintainer out of their own repository. That is a stated design choice, not an oversight and not an item still to be done. Attest it from a shell:

gh api repos/IvanBBaev/agenthropic/branches/main/protection \
  --jq '{contexts: .required_status_checks.contexts, enforce_admins: .enforce_admins.enabled}'
→ {"contexts":["ci"],"enforce_admins":false}

The canonical write-up of the exemption and of every claim it re-reads is the standing correction.

The canonical decision tying all of this together is CD-7 in docs/analysis/concept-analysis-v2.md: “Security + the coverage gate are boundary conditions from commit one, CI-blocking… >90% coverage blocks merges,” explicitly rejecting any plan that would defer security to a later phase or treat backup/restore as end-of-project polish. The global Definition-of-Done that closes docs/analysis/development-plan.md restates the same list as a condition every one of the 75 work packages must satisfy, not just the security-owned ones: “No security invariant is weakened: loopback-only bind; mandatory-token-or-fail-startup; SSE same-origin; no subprocess spawner; no SSRF; secrets never in SQLite/SSE/logs.”

One sequencing detail worth stating plainly because it looks alarming out of context: WP-F7’s security-contract tests are written to fail from the wave they land in (wave 8) until WP-U0’s server bootstrap wires the real primitives in (wave 9). That is by design — docs/analysis/development-plan.md §7 flags explicitly “do not merge WP-F7 as ‘passing’; its DoD is jointly owned with WP-U0.” A red security test at that specific, short-lived point in the build is the gate working as intended, not a regression. (That window has since closed: the contract tests now boot the real composition root and pass green.)

Current state

This page was written pre-Phase-0, when every control above was a binding design commitment and nothing more. That is no longer the situation. The Phase-0 feasibility spike returned CONDITIONAL GO, and implementation began 2026-07-11 by explicit owner override of CD-8 — an override that changed the schedule, not the security bar: none of the nine rules was relaxed, and the KC kill-checkpoint calendar in docs/analysis/roadmap-v1-v2-2026-07-06.md still governs.

As built today:

The controls on this page are therefore no longer only something to hold Phase 1’s pull requests to — most of them are a description of code that already runs, with the exceptions named honestly above.

See also