This file enumerates the security decisions the dashboard makes and the test invariants that block merge. Everything labeled V0-SHIP-REQUIRED is per the architect's consultation with security_researcher (td-wisp-eb0pn).
The product runs on Charlie's laptop, on 127.0.0.1, with no auth. That is not a free pass — multi-user POSIX hosts share 127.0.0.1 across all local users, prompt-injection in agent mail can drive XSS, and the dashboard executes whitelisted shell commands. Each section below names the defense and how to verify it.
- Bind 127.0.0.1 only. Not
0.0.0.0. Enforced inbackend/src/server.tsviaapp.listen(port, '127.0.0.1', …). The systemd unit further restricts viaRestrictAddressFamilies=AF_UNIX AF_INET. - Host header allowlist (DNS rebinding defense).
middleware/security.ts::hostHeaderAllowlist. Allowed:127.0.0.1,localhost(with optional port). Anything else → HTTP 421 Misdirected Request. - Origin header check on state-changing endpoints. Must be
http://127.0.0.1:<port>orhttp://localhost:<port>. Anything else → HTTP 403. - IPv6 posture: Node's
app.listen('127.0.0.1', …)binds IPv4 only, so::1is naturally refused. - CSP
connect-srcincludes the gc supervisor URL. Phase C wiresEventSourcefrom the browser directly tohttp://127.0.0.1:8372/v0/city/{name}/events/stream. Different port = different origin, so the supervisor URL must be explicitly enumerated. The middleware factory takes anextraConnectSrcarray; the server passes[config.gcSupervisorUrl]at boot. Anything else attempting to call out from the page fails the CSP —'self'covers the dashboard's own API, the extras list covers the supervisor.
curl -sH 'Host: evil.com' http://127.0.0.1:8081/api/health # → 421
curl -sX POST -H 'Origin: http://evil.com' http://127.0.0.1:8081/api/sessions/td-foo/peek # → 403
X-Frame-Options: DENYContent-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'X-Content-Type-Options: nosniffReferrer-Policy: no-referrer
Double-submit cookie pattern (middleware/csrf.ts). Token generated per boot, surfaced as a thriva_admin_csrf cookie (SameSite=Strict, non-HttpOnly), echoed by the frontend as X-CSRF-Token on every POST/PATCH/DELETE.
Why not csurf: the canonical package is deprecated; rolling a minimal double-submit pattern is reasonable here, and the Host + Origin checks do the heavy lifting. CSRF is the third belt.
curl -sX POST http://127.0.0.1:8081/api/sessions/td-foo/peek -H 'Host: 127.0.0.1' -H 'Origin: http://127.0.0.1:8081'
# → 403 {"error":"Missing CSRF token","kind":"csrf"}
Every privileged invocation routes through backend/src/exec.ts. No general-purpose exec helper exists.
-
Enum whitelist of allowed commands:
gc bd update <id> --status=... --assignee=...,gc bd close <id> [--reason=...],gc agents nudge <alias>.Peek is no longer in this list: architect addendum td-wisp-ijk7g (mechanic td-wisp-e1v14) confirmed peek is served by gc supervisor's
GET /v0/city/{name}/session/{id}/transcriptHTTP endpoint as structured turns. The dashboard fetches the transcript viaGcClient.fetchTranscriptand sanitises text fields server-side with the samesanitiseTerminalOutputit would have applied to shell output. Nosubprocess.spawninvolved — one less attack surface in the privileged-exec path. -
Param schemas enforced before any privileged call:
- Bead id:
^(td|th|jt)-[a-z0-9-]{3,32}$ - Session id:
^(td|th)-[a-z0-9]{3,12}$(validated inroutes/sessions.tsbefore the gc HTTP call) - Agent alias:
^[a-z][a-z0-9_./-]{1,63}$
- Bead id:
-
Spawn options:
shell: false— non-negotiable. Nosh -c, no command injection vectors.env: cleanEnv()— onlyPATH=/usr/local/bin:/usr/bin:/bin,HOME,LANG. Inherited env stripped.stdio: ['ignore', 'pipe', 'pipe']— child can't block on stdin prompts.
-
Resource limits: per-exec timeout 10–15 s; output cap 100 KB (truncates + kills child); concurrency cap of 4 parallel via in-process semaphore.
-
Audit log: every exec writes a
{type: 'dashboard.exec', endpoint, parsed_args, exit_code, duration_ms}row to.gc/events.jsonl(durable channel; survives dolt-hq corruption).
curl -sX POST http://127.0.0.1:8081/api/sessions/$(printf "'; rm -rf /")/peek …
# → 400 {"error":"invalid session id","kind":"validation"}
The literal arguments never reach a shell; even if they did, shell: false would refuse to interpret them.
Everything rendered in the UI that originated outside the dashboard (mail bodies, bead descriptions, peek output, agent state strings) is TEXT, NOT HTML.
- React's default escaping is the friend.
{content}notdangerouslySetInnerHTML. NoinnerHTML, nodocument.write, noeval, noFunction()anywhere in the frontend. - Peek output: server-side strips ANSI/OSC/control characters (
backend/src/exec.ts::sanitiseTerminalOutput) and passes only safe SGR. Client renders withansi_up(uses CSS classes, not inline styles, courtesy ofuse_classes = true). - Mail bodies + bead descriptions render in
<pre>with full text escaping.
Each transcript turn in routes/Agents.tsx::TurnBlock uses dangerouslySetInnerHTML to inject the HTML that ansi_up produces from the turn's text. This is the canonical pattern for ansi_up and is safe for two layered reasons:
- Server-side sanitisation runs first — every turn's text passes through
sanitiseTerminalOutputbefore it reaches the browser, which strips OSC sequences, non-SGR CSI sequences, and control characters. The string ansi_up sees contains only printable characters plus safe SGR colour escapes. ansi_upwithuse_classes = truedoes not pass through arbitrary HTML — it HTML-escapes<,>,&from the input and emits only<span class="ansi-...">wrappers (no inline styles, no event handlers, no<script>/<iframe>/<a>).
The eslint-disable react/no-danger comment at the call site cross-references this exception. Any other use of dangerouslySetInnerHTML in the codebase is a bug — flag at review.
The peek modal carries a banner: "Content is agent-generated and may contain misleading instructions." Mitigates prompt-injection-in-content for the human reading it.
# Mail body containing <script>alert(1)</script>
# Rendered in the UI → escaped to '<script>alert(1)</script>' as text. No script execution.
Physical separation of read vs send routers (security_researcher's strong preference over code-path discipline):
routes/mail.ts— read paths; takes aviewing-asquery param.routes/mail-send.ts— write path; the send function's signature has no as-identity parameter. Server is structurally unable to send-as-other.
Frontend renders a visible "Viewing as " banner with colour; the compose-from field is greyed when viewing-as ≠ the configured owner so the constraint is visible before the user tries.
Audit log (audit.ts): every fetch records actor=<owner>, viewing_as=<alias>. Every send records actor=<owner>, viewing_as_context=<alias> so the trail is intact regardless of UI state. <owner> resolves to the GC_CITY_OWNER_ALIAS env value (default 'human'); the mail-send wire identity is structurally locked to 'human' regardless of that knob (td-4k317p).
No client-side caching of mail under as-identity (Cache-Control: no-store, no localStorage retention).
THRIVA_ADMIN_DASHBOARD_DISABLED=1
server.ts checks this env at boot and refuses to bind the listener. process.exit(0). Also enforceable via systemd Environment=THRIVA_ADMIN_DASHBOARD_DISABLED=1.
- Per-user POSIX permission gate:
127.0.0.1is shared across all local users on the machine. v1 may switch to a Unix-domain socket with0600+ os-owner ACL. v0 limitation: trust the host. - No rate-limiting beyond the in-process semaphore. v1 may add per-IP throttle on the audit log path.
- No TLS — same-machine loopback only.
- No request signing beyond CSRF.
Anything beyond v0 lands as a separate bead. v0 deliberately ships the security floor that the architect + security_researcher named as merge-blocking — not the full enterprise stack.