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.1only and refuses to start withoutDASHBOARD_TOKEN(apps/server/src/config.ts/src/index.ts); a single globalonRequesthook inapps/server/src/server.tsgates 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/streamrejects foreignOriginheaders with 403 before auth; SQLite opens in WAL mode withforeign_keys=ONasserted 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.
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.
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/streamruns 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.
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).
127.0.0.1), never 0.0.0.0127.0.0.1 exclusively. 0.0.0.0 is never an option,
not even behind a flag.0.0.0.0 by default
(simple10, cast, claude-code-templates) and are LAN- or network-reachable the
moment the process starts, regardless of what their auth layer does or doesn’t do.
ai/DESIGN.md §8 calls out 0.0.0.0 as the first thing every audited project got
wrong and states the corrective directly: “Bind loopback only — 127.0.0.1. Never
widen to 0.0.0.0.”How enforced. The Fastify server bootstrap (WP-U0, owner backend) implements
a loopback-or-fail listen call — the process refuses to start bound to anything
else. WP-F7’s security-contract tests assert this and are written to fail red
until WP-U0 wires the real bootstrap; the Phase 1 exit gate in
docs/analysis/development-plan.md requires these contract tests green. The
canonical decision recording “loopback-or-fail bind” as a CI-blocking condition from
commit one is CD-7 in docs/analysis/concept-analysis-v2.md.
As built: shipped exactly as specified. The composition root (
apps/server/src/index.ts) listens on127.0.0.1with no configuration path to any other host, andsecurity-contract.test.tsboots the real server and asserts that every bound address is127.0.0.1. The static gate (rule 3’s scanner) additionally rejects the0.0.0.0/::bind patterns anywhere in the tree.(Amended 2026-09-23: the runtime check now also fails closed on an empty address list. “No address is non-loopback” is vacuously true of no addresses at all, so a server that reported nothing bound used to pass the guard; it now logs
FATAL: the server reported no bound address...and exits non-zero, because an invariant that could not be verified is treated as violated.)
timingSafeEqual, not a no-op-when-unsetDASHBOARD_TOKEN compared with Node’s crypto.timingSafeEqual. If the token
environment variable is unset, the server refuses to start — it does not fall
back to “no auth needed.”hoangsonww’s DASHBOARD_TOKEN is opt-in and becomes a silent no-op when
unset — on 0.0.0.0 without ever setting it, that dashboard has no auth at all
despite shipping an auth feature. ai/DESIGN.md §8 names this precisely:
“Auth token is mandatory, not opt-in — a DASHBOARD_TOKEN that is a no-op when
unset (hoangsonww’s mistake) is not auth. Use timingSafeEqual.” A naive ===
string compare is also explicitly ruled out — it leaks timing information about how
many leading bytes matched, which is why the rule names the constant-time primitive,
not just “check a token.”How enforced. WP-F7 builds the shared/security primitives — loopback check,
token compare, SSE-origin check — as unit-tested (>90% coverage) building blocks and
as initially-failing contract tests; WP-U0’s Fastify bootstrap wires the
timing-safe middleware in and is done-when those contract tests turn green,
including “fails startup when token unset.” WP-U2 (Read API foundation) then
requires every read route to carry the auth guard, not only the ones an author
remembers to gate.
As built: the rule holds; the implementation shape is stronger than the sketches below in three ways. (1) There is no per-route guard to remember — a single global
onRequesthook inapps/server/src/server.tsgates every registered/api/*route (the built SPA shell and its assets are served outside the gate by design, and the static handler refuses any path whose first segment isapi), and it matches the exemptions (none today except the same-origin-then-token SSE ordering) on Fastify’s decodedrouteOptions.url, not the raw request URL, so a percent-encoded path like/%61pi/streamcannot slip past the gate (the contract suite tests exactly this). (2) The timing-safe compare hashes both sides to a fixed length beforetimingSafeEqual, so the length-mismatch early-return in the sketch — a small length oracle — does not exist in the real code. (3) Startup fails withoutDASHBOARD_TOKEN(apps/server/src/config.ts), exactly as sketched — and it also fails on a token that is present but too short:requireDashboardTokenrejects anything underMIN_TOKEN_LENGTH = 16characters with a message naming the minimum. That second check exists because “mandatory, not opt-in” is only half the property worth having;DASHBOARD_TOKEN=xsatisfies “set” while offering no more real protection than the no-op the rule was written against. Sixteen characters is a chosen floor, not a measured one. One deliberate accommodation:/api/streamalso accepts the token as?token=because the browserEventSourceAPI cannot set headers — and the server redacts that query value from its own logs (redactTokenInUrl). The two blocks below are kept as the design-basis record.
// apps/server/src/security/token-guard.ts — design-basis sketch (WP-F7/WP-U0);
// the real implementation differs, see the as-built note above
import { timingSafeEqual } from 'node:crypto';
export function verifyToken(provided: string | undefined, expected: string): boolean {
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
// guard the length mismatch *before* timingSafeEqual — it throws on unequal
// buffer lengths rather than returning false, and constructing a throw path
// from attacker-controlled input is itself worth avoiding.
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// apps/server/src/bootstrap.ts — design-basis sketch (WP-U0); the real
// composition root is apps/server/src/index.ts and behaves exactly like this
const token = process.env.DASHBOARD_TOKEN;
if (!token) {
throw new Error('DASHBOARD_TOKEN is required — refusing to start without auth.');
}
await app.listen({ host: '127.0.0.1', port }); // loopback-or-fail — never '0.0.0.0'
Sample env file for local operation always uses a placeholder, never a real value:
DASHBOARD_TOKEN=<token>
The pattern this generalizes — read-only-by-default, non-safe verbs gated,
constant-time compare, mounted before the router — is cast’s ~73-line
controlGate.ts, named as the auth-gate shape worth stealing in ai/DESIGN.md §7
and docs/analysis/development-plan.md’s track notes. It is clean-room
reimplemented, not copied: cast carries no OSS license (package.json sets
private: true, no LICENSE file), so it is all-rights-reserved by Berne default
per CD-9 — see licensing & provenance. Note also
that stealing the pattern is not enough on its own: cast itself still binds
0.0.0.0 and leaves GET reads unauthenticated despite that same gate existing —
rule 1 and rule 5 close exactly the gap that example leaves open.
claude spawnerclaude (or any other
subprocess) driven by request input. There is no /api/run-shaped route anywhere in
the design, now or on the roadmap.Why — the precise mechanism, not just “a rival had an RCE.”
hoangsonww’s /api/run accepts a permission-mode field from the browser
request body, and its server-side allow-list (ALLOWED_PERMISSION_MODES)
includes bypassPermissions. bypassPermissions is a Claude Code permission mode
that skips the tool-use confirmation prompts a normal session would show — so a
request that reaches this endpoint runs claude --permission-mode bypassPermissions
in an attacker-chosen working directory, and every tool Claude Code can invoke
(shell execution among them) then executes unconfirmed, as the host user. This
is not a theoretical weakness in “how much request data an endpoint trusts” — it is
a direct, one-hop path from an HTTP body field to host-level code execution.
ai/DESIGN.md §8 states the conclusion plainly: “/api/run accepts
permission-mode from the request body and its allow-list includes
bypassPermissions → arbitrary host-user code exec. We never build this surface.”
The due-diligence report also notes the concurrency-cap mitigation hoangsonww’s
own vendor review credited was a red herring — the permission mode is the actual
lever, not request volume (../../due-diligence/projects/hoangsonww.md).
// ANTI-PATTERN — illustrative reconstruction of the documented flaw in
// hoangsonww's server/routes/run.js (per due-diligence, run.js:96 /
// security.js:133), not a copy of the source. agenthropic never builds this.
app.post('/api/run', (req, res) => {
const { cwd, permissionMode } = req.body; // attacker-controlled
// ALLOWED_PERMISSION_MODES includes 'bypassPermissions'
spawn('claude', ['--permission-mode', permissionMode], { cwd }); // RCE
});
How enforced. WP-F5 (owner security, Phase 1) is a static grep/AST gate:
planting a child_process import anywhere in apps/server makes CI red. This is a
build-failing check, not a code-review convention — CD-7 in
docs/analysis/concept-analysis-v2.md names the “no-spawner grep/static gate” as
one of the boundary conditions live from commit one, and the global
Definition-of-Done in docs/analysis/development-plan.md §8 restates it for every
single work package: “No security invariant is weakened: … no subprocess spawner.”
Because the spawner in hoangsonww is architecturally isolated (~6 files, one mount
line, one table per the due-diligence), the corrective for anyone ever tempted to
graft hoangsonww patterns is equally simple: that surface is never mounted here in
the first place.
As built:
WP-F5shipped asscripts/check-no-spawner.mjs, wired as thegate:spawnerstep in.github/workflows/ci.yml, and it is broader than the design here promised: it scans all ofapps/*,packages/*,scripts/,hooks/, and the repo-root config files — source and tests — for the whole subprocess API family, wide binds (0.0.0.0/::), WebSocket-server patterns, and dynamic code evaluation (indirect eval,data:/concatenated dynamic import). Since 2026-09-07 it also reads the direct dependencies of the root and every workspacepackage.jsonby name, so a subprocess or WebSocket package is refused atpnpm add; transitive dependencies stay with lockfile review. Its two escape hatches are explicit and auditable (a logged whole-file allowlist containing only the policy file itself, and a per-linespawner-gate-allowmarker — on three sites as of 2026-09-09: the license gate’s fixed-argvpnpm licenses list, a migration-checksum test that runstsxonce at test time, and a test asserting the loopback guard rejects0.0.0.0), and its header is honest about scope: a regex gate stops the idiomatic reintroduction paths, not a deliberately obfuscating insider — the runtime loopback backstop and code review remain the real controls.AMENDED 2026-09-23 (J-11). “Three sites” is the count of sanctioned exceptions, and the enumeration above is still exact - but it is not a line count, and the gate prints a line count. The three exceptions are carried on five marked lines, because two of them need the marker on the
importas well as on the call:apps/server/test/migrations-checksum-stability.test.ts:83and:195,packages/shared/test/security.test.ts:81,scripts/check-licenses.mjs:36and:167. A reader auditing the gate’s5 line(s) inline-exemptclause against the sentence above would otherwise find a discrepancy that is not there.
Origin header
is not the dashboard’s own origin, and it never sends a wildcard CORS header.ai/DESIGN.md §8 states this as its own line item (“Same-origin check on
the WebSocket”) because a same-origin-only realtime channel is what keeps an
arbitrary web page — one you merely have open in another tab — from silently
attaching to your dashboard’s live feed if it ever guessed or leaked the token.
disler’s unauth POST /events plus wildcard * CORS is the negative example: no
origin check at all (../../due-diligence/security.md).How enforced. The transport itself is a canonical, already-resolved decision:
CD-5 in docs/analysis/concept-analysis-v2.md settles it as SSE, not
WebSocket — “server→browser-only feed; revisit WebSocket only if bidirectional
control is ever needed (it is not).” ai/DESIGN.md §8’s original wording predates
that resolution and still says “WebSocket”; this page follows the later, canonical
decision. WP-U1 (RealtimeHub SSE endpoint) is done-when “a cross-origin Origin
on /api/stream is rejected; no wildcard CORS,” and the same-origin helper used
to implement that check is one of the three shared/security primitives WP-F7
builds and unit-tests before WP-U0 wires it into the bootstrap.
As built: shipped, with one strengthening detail: the same-origin check on
/api/streamruns before the token check — a request with a foreignOriginis 403 even when it carries a valid token (security-contract.test.tsasserts “403, token or not”). Loopback origins (127.0.0.1andlocalhoston the bound port) are accepted; nothing sends a wildcard CORS header. The transport is SSE per CD-5, exactly as this section says.A request with no
Originheader at all is allowed, and that is a decision rather than an oversight, so it is worth stating both halves. A missingOriginmeans a non-browser client —curl, a Node EventSource polyfill, a health probe — and those cannot be driven by a hostile web page, which is the entire attack this rule closes. They are still gated: the token check runs immediately afterwards and rejects them without a credential. The half that matters is the other one: a presentOriginmust match the server’s own loopback origin exactly. Browsers always send it on a cross-origin request, so a page in another tab can never reach the stream, token or not. Treating a missing header as hostile would buy no security and break every command-line client.
ai/DESIGN.md §8’s literal wording is “no unauthenticated write
endpoints” — echoing the minimum bar the field itself failed: cast’s
controlGate.ts gates writes well (404s non-safe verbs unless
CAST_DASHBOARD_CONTROL=1 and the token are set) but its GET routes are left
open, and combined with its 0.0.0.0 bind, “unauthenticated GET reads that dump
every table” is the exact finding the due-diligence records
(../../due-diligence/security.md; ../../due-diligence/projects/cast.md).
agenthropic closes that gap rather than reproduce it.How enforced. WP-U2 (Read API foundation) states its Done-when as “every read
route auth-guarded (timing-safe)” — stricter than the DESIGN wording’s write-only
framing, precisely because cast’s read-side gap is documented and known. WP-A8
(operator alerts API) carries the same requirement forward for the CRUD surface:
“all write endpoints token-guarded, cross-origin rejected.”
As built: enforced structurally, which is stronger than “every route carries the guard”: there are no per-route guards to forget, because one global
onRequesthook inapps/server/src/server.tsgates every route whose routed pattern starts with/api/— read routes, the hook receiver,/api/stream, even/api/health. A new/api/*route added tomorrow is token-gated by construction. The only surface outside the gate is the built SPA shell and its static assets (apps/server/src/http/static-site.ts), which hold no secret and cannot present a Bearer header for the page still loading; that handler refuses any path whose first segment isapi, so an unregistered/api/...is never answered off the filesystem. The contract suite asserts 401 without a token and 401 for wrong tokens of different lengths (the compare hashes to fixed length first, so short probes behave identically).
webhook_targets, set up through the authenticated operator UI/API). It
never constructs an outbound request URL from data that arrived inside an ingested
event.disler’s server dials an arbitrary responseWebSocketUrl taken straight
from the incoming request body — a textbook server-side request forgery, letting
whatever sent the event make the server connect anywhere the attacker chooses
(ai/DESIGN.md §8: “no SSRF (never dial a URL taken from an event payload —
disler’s bug)”; ../../due-diligence/security.md, index.ts:198-201).How enforced. WP-F5 covers this alongside the no-spawner check as a
build-failing static gate (CD-7). At the feature level, WP-A4 (webhook dispatcher)
states its Done-when as “no code path reads a URL from a payload (test-proven)” —
a dedicated negative test, not only a lint rule, and Phase 5’s exit gate in
docs/analysis/development-plan.md restates it: “SSRF test proves no payload-URL
dial-out.” WP-A10’s alerts negative-test corpus keeps this proven on every future
change to the alerting surface, not just at first ship.
As built: the webhook dispatcher (
WP-A4) is not built — alerting is post-1.0, entered only via KC-5. Today the server process makes no outbound network request of any kind: nothing underapps/server/srcorpackages/*/srccallsfetch, importsnode:http/node:https, or reaches an HTTP client, and the server’s runtime dependencies are Fastify, TypeBox andbetter-sqlite3— none of which dials out on its own. (fetchdoes appear inapps/web/src/api.ts, which is the browser bundle calling this server’s own relative/apipaths, and inscripts/time-to-understand.mjs, a local measurement script; neither is the server process and neither takes a URL from ingested data.) So the SSRF surface this rule guards does not exist yet, and the rule is satisfied by absence rather than by enforcement.Amended 2026-09-26: an automated check now defends it.
WP-F5(scripts/check-no-spawner.mjs) gained the outbound-network family its work package always named: inapps/server/src/andpackages/*/src/it fails onfetch(, a node network module (http/https/http2/net/tls/dgram/dns), an HTTP client import, aWebSocket/EventSourceclient orXMLHttpRequest, and it refuses an HTTP client package in those packages’ manifests. The rest of this note is the record of the gap it closed. Until that date no automated check defended it. The gate scanned for the subprocess family, wide binds, WebSocket-server patterns and dynamic evaluation — it had no pattern for outbound HTTP, so an addedfetch()ornode:httpsimport would have passed it. An earlier version of this note claimed the gate would catch such a dial; that was wrong, and it contradicted §3’s own accurate enumeration of what the gate covers. TheWP-A4/WP-A10negative tests remain the Done-when for the future dispatcher; until then an outbound client is caught by the gate, with review as the second line.
ai/DESIGN.md §8: “Remote access via tunnel
only (SSH port-forward / Tailscale) — never a reverse proxy to the open port.” The
root CLAUDE.md non-negotiable constraints restate the same rule for anyone
touching this codebase.0.0.0.0 bind (rule
1 catches this), or any TLS/ingress feature that would imply public exposure. The
operator-facing procedure for setting this up (SSH vs Tailscale, port choices) is
the dedicated subject of remote access.ANTHROPIC_API_KEY stays out of the dashboard’s own environmentANTHROPIC_API_KEY unless a
specific, explicitly-scoped feature genuinely requires it — and today, none does.ai/DESIGN.md §8: “Don’t hold ANTHROPIC_API_KEY in the dashboard’s env
unless a feature truly requires it.” The concrete cautionary example is nirdiamant:
its only “AI” feature requires ANTHROPIC_API_KEY and ships local files to Anthropic
to classify them (../../due-diligence/projects/nirdiamant.md) — exactly the shape of
feature this rule keeps opt-in and isolated rather than baked into the core process’s
environment. The one place on the roadmap this key would ever have mattered — the
experimental vector-DB “observability becomes memory” feed (ai/DESIGN.md §9, Phase
3) — was deleted from the plan outright (WP-X11 removed per best-path §6.3,
applied 2026-07-06), so the core dashboard process never needs the key at all.
docs/analysis/concept-analysis-v2.md’s CD-10 states this more strongly:
the key “stays out of the dashboard env entirely.”How enforced. By construction since 2026-07-06: with WP-X11 deleted there is no
experimental stub for a core package to import and no code path that reads the key.
The rule stands as a standing constraint on any future experimental work — if such a
track ever returns, it must re-satisfy “no core package imports it” as an assertable,
tested condition with its coverage explicitly scoped.
As built: holds. The string
ANTHROPIC_API_KEYappears nowhere inapps/,packages/,hooks/, orscripts/— no code path reads it (verified by search against the implemented tree, 2026-07).
ai/DESIGN.md §8: “SQLite in WAL mode with backups.” A backup nobody has
ever restored from is not a real backup — this is why the plan phrases the gate as
“tested restore,” not “backup exists.”How enforced. WP-D2 (SQLite driver adapter) asserts journal_mode == wal and
foreign_keys == ON on every connection open. WP-F8 (Backup + tested-restore)
states WAL as asserted and a restore as exercised — this is the Phase 1 exit gate
in docs/analysis/development-plan.md (“WAL mode is on and a restore has been
exercised for real”) and it recurs at release: WP-X9’s RELEASE.md enumerates
“an exercised backup restore” as one of the checklist items closing out the release.
Retention and payload redaction (never storing raw tool payloads unredacted, unlike
simple10) are the adjacent hardening step, owned by WP-D10 and covered in depth
in backup & restore.
As built:
WP-D2shipped as specified —apps/server/src/db/connection.tssetsjournal_mode = WALandforeign_keys = ONand then reads the pragmas back and throws if either did not take, on every connection open.WP-F8shipped asapps/server/src/db/backup.ts: backup uses better-sqlite3’s online backup API (safe under WAL, no lock of the live database), and the restore path copies the backup into place, reopens it through the same pragma-assertingopenDatabase, and refuses to return a database that failsPRAGMA integrity_check. The restore path is exercised byapps/server/test/backup.test.tson every test run — though note the honest distinction: that is a test-exercised restore; an operator-level restore drill against real data is a release-checklist item (WP-X9), not something CI can prove. The backup itself is no longer merely a capability: an in-process daily timer runs it (see Scheduling, as built), because a backup routine nothing ever calls is not a backup either. Its cadence is a constant (daily); the expiry window (30 days) and keep-minimum (7 files) are the signed v1.0 retention numbers (D3, 2026-09-08), overridable viaDASHBOARD_RETENTION_BACKUP_DAYSandDASHBOARD_RETENTION_BACKUP_KEEP_MIN.Of the adjacent hardening: hook-payload redaction is built (
WP-IN14, applied before the idempotency key is computed, so raw secrets never reach the stored envelope or its hash). Retention is signed and running (D3, 2026-09-08): after each successful daily backup the composition root runs the retention pass —eventsrows older thanDASHBOARD_RETENTION_EVENTS_DAYS(default 90) are pruned in bounded runs, backup files older thanDASHBOARD_RETENTION_BACKUP_DAYS(default 30) expire behind a keep-minimum ofDASHBOARD_RETENTION_BACKUP_KEEP_MIN(default 7),token_usageis never pruned (settingDASHBOARD_RETENTION_TOKEN_USAGE_DAYSrefuses startup), andevents_rawstays append-only (OPEN-1’sarchive-segmentsbranch is declared but unbuilt and refused loudly). A cycle whose backup failed deletes nothing.
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.ymlruns, in this order: thegate:spawnersecurity 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 breaksvite buildwould 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) setthresholds: { 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 zerov8 ignoredirectives, andapps/server/test/coverage-honesty.test.tsfails the build if one appears. Whenserver.tscarried 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
- 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.
mainis branch-protected: the required status check is the contextci(lowercase — the job id in.github/workflows/ci.yml; the workflow’s display nameCIis not the context), and force-pushes tomainand deletion ofmainare 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, becauseenforce_adminsis deliberately off: agenthropic has exactly one maintainer whose normal working mode is a direct push tomain, 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.)
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:
/api/* route, same-origin-before-auth on /api/stream (SSE per CD-5), WAL +
foreign_keys asserted on every connection, online backup with an
integrity-checked restore path and a daily in-process schedule that fires it,
the signed retention pass (D3, 2026-09-08) chained after each successful backup,
hook-payload redaction before the idempotency key, and the
no-spawner/no-wide-bind/no-eval and license static gates running in CI.ANTHROPIC_API_KEY anywhere in the tree.
The first two absences are also gate-enforced: the subprocess family since Phase 1, the
outbound-network family in server-process code since 2026-09-26.WP-D10 retention sweeper, chained after
each successful daily backup — events rows older than 90 days are pruned in
bounded runs with a journal receipt, backup files older than 30 days expire behind
a floor of the 7 newest, and token_usage and events_raw are never pruned. The
numbers are overridable via DASHBOARD_RETENTION_* (0 switches a window off);
OPEN-1’s archive-segments branch stays declared-but-unbuilt and refused loudly.WP-X9, release checklist). Whether CI is
merge-blocking was, until 2026-08-25, a GitHub branch-protection setting this
repository could not attest; it can now — main requires the ci check, so a red run
withholds the merge button from a contributor and not from the repository owner
(enforce_admins is deliberately off, because the sole maintainer works by direct push
to main and would otherwise be locked out of his own repository). See
the standing correction.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.
RealtimeHub, HookSource) the security
primitives attach to./api/health
(itself behind the rule 2/5 token gate, so a probe needs the token) and the corpus
containment violation that shuts the server down rather than skipping past it.cast pattern note in rule 2.main became branch-protected on 2026-08-25, the owner being deliberately
exempt) and the negative-test catalogue that backs rules 3, 5, and 6.