This file enumerates the security decisions the dashboard makes and the test invariants that block merge.
The product runs on the operator's host, 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.
The target architecture has two browser-visible API classes:
- GC-owned resources come from the GC supervisor API through a generated browser client.
- Dashboard-local resources come from the dashboard service under
/api/*.
Standalone development may route supervisor /health and /v0/* through the
dashboard service under /gc-supervisor/* as a transport-only proxy so one
SSH-forwarded port is enough. That proxy is not a security or DTO boundary: it
forwards bytes and headers and does not inspect, validate, strip, cache, or
rename supervisor payloads.
- Bind 127.0.0.1 only. Not
0.0.0.0. Enforced bybackend/src/config.ts:HOSTis ignored unless it is already127.0.0.1, andbackend/src/server.tsbindsconfig.bindHost. 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 dashboard-service 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-srcnames the chosen transport. If the browser calls the supervisor directly, include the supervisor origin explicitly. If standalone mode uses the transport-only proxy,connect-src 'self'remains sufficient.
curl -sH 'Host: evil.com' http://127.0.0.1:8082/api/health # → 421
curl -sX POST -H 'Origin: http://evil.com' http://127.0.0.1:8082/api/client-errors # → 403
Opt-in via DASHBOARD_READONLY=1 (default off). Hardens the /gc-supervisor
transport proxy for an instance fronted by an external auth proxy, so a request
that reaches the dashboard cannot mutate the city. Enforced server-side in
backend/src/routes/supervisor-transport-proxy.ts against the explicit
allowlist in backend/src/routes/supervisor-read-allowlist.ts. When enabled the
proxy:
- Rejects any non-
GET/HEADwith405— the method gate is checked before the path gate, so a write likePOST .../slingis405, never forwarded. - Default-denies reads to the explicit read allowlist (the minimal set of
supervisor reads the SPA performs, plus both SSE streams). Anything else —
including the side-effecting agent
primeGET flagged by the exposure premortem — is404. A new read view fails closed until it is added to the allowlist. - Strips the write-authorizing
x-gc-requestheader (andcontent-type) unconditionally before forwarding — it never depends on the header's absence. - Gates the resolved upstream path, not the raw
req.path. The allowlist runs onnew URL(req.url, base).pathname— the exact string forwarded — so a traversal that would resolve to a different (e.g. cross-city GLOBAL) upstream cannot pass the per-city[^/]+template, whether spelled literally (..), percent-encoded (%2e%2e,%2E%2E, mixed), or via a backslash. An authority in the request target (//host/…) is pinned to the supervisor origin. - Logs every gate rejection (
logWarnwith method + path) so an externally-fronted instance leaves an audit trail of refused method/traversal probes.
This is the single load-bearing exposure control: it sits upstream of the
unauth supervisor and survives a blown-open network layer. The default
(read/write) mode is unchanged, preserving the zero-friction local operator
experience. The deployment side of this — when and how to expose at all — is
the operator runbook in exposure.md.
DASHBOARD_READONLY=1
curl -sX POST -H 'x-gc-request: dashboard' http://127.0.0.1:8082/gc-supervisor/v0/city/$CITY/sling # → 405, never forwarded
curl -s http://127.0.0.1:8082/gc-supervisor/v0/city/$CITY/agent/$AGENT/prime # → 404 (side-effecting GET, not allowlisted)
curl -s http://127.0.0.1:8082/gc-supervisor/v0/city/$CITY/beads # → 200, x-gc-request stripped before forwarding
curl -s --path-as-is http://127.0.0.1:8082/gc-supervisor/v0/city/%2e%2e/events/stream # → 404 (encoded `..` resolves to GLOBAL stream, gated on the resolved path)
X-Frame-Options: DENYContent-Security-Policy: default-src 'self'; script-src 'self' 'sha256-...'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; 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 gascity_admin_csrf cookie (SameSite=Strict, non-HttpOnly),
echoed by the frontend as X-CSRF-Token on every dashboard-service
POST/PATCH/DELETE.
The target dashboard-service write surface is local-only: client-error
telemetry, maintainer gh actions, and any local audit/control endpoints. GC
mutations should move to the supervisor API and use the supervisor's own
browser-safe mutation/auth/header model. Do not keep a dashboard-server GC
write route merely to reuse the dashboard CSRF middleware.
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:8082/api/client-errors -H 'Host: 127.0.0.1' -H 'Origin: http://127.0.0.1:8082'
# → 403 {"error":"Missing CSRF token","kind":"csrf"}
Every privileged invocation routes through backend/src/exec.ts. No general-purpose exec helper exists.
-
Target enum whitelist of allowed commands:
gitevidence commands andghmaintainer reads/actions only.gcsubprocesses are not part of the dashboard service exec surface.Bead close with operator reason moved to the generated supervisor client after
GC-10; agent nudge moved afterGC-11; agent prime/composed-prompt reads moved afterGC-12. Those subprocess paths are not part of the exec surface.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 current implementation fetches the transcript through the generated browser supervisor client and renders it as escaped text; no dashboard server route orsubprocess.spawnis involved. -
Param schemas enforced before any privileged call:
- Bead id:
^(td|th|jt)-[a-z0-9-]{3,32}$ - Session id:
^[a-z]{2,4}-[a-z0-9-]{1,32}$(case-sensitive, no/i; validated via the sharedSESSION_ID_RE, re-exported bylib/sessionId.ts, for run/session linking). A session id is the session bead's id, so the 2-4-letter prefix is the city store's bead prefix — it admits thegc/td/thcores and per-deployment city codes that can't be enumerated here, both 2-letter (e.g.mc-*) and 4-letter (e.g.fddc-*); the lowercase-only, hyphen-and-alphanumeric body keeps the gate strict. - 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()— inherited env stripped. The child receivesPATHfromADMIN_PATHwhen configured, otherwise a fixed local-safe search path;HOME;LANG=C.UTF-8;NO_COLOR=1; andGITHUB_TOKENonly when the dashboard process explicitly has one forghreads.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 -s http://127.0.0.1:8082/gc-supervisor/v0/city/test-city/session/$(printf "'; rm -rf /")/stream …
# → 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. - Transcript output: fetched from the supervisor transcript endpoint and rendered as text. Client converts
ansi_upoutput into React nodes before rendering, so transcript colour spans are still escaped component output rather than injected HTML. - Mail bodies + bead descriptions render in
<pre>with full text escaping.
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. Banner copy and presentation defer to DESIGN.md for status-presentation voice and the "States have words" rule.
# Mail body containing <script>alert(1)</script>
# Rendered in the UI → escaped to '<script>alert(1)</script>' as text. No script execution.
Target state: mail read/send identity is enforced by the supervisor API contract and generated client types. The dashboard frontend must still render a visible "Viewing as " banner and must not create a client-side "send as other" path.
Current posture keeps mail reads and sends on the generated supervisor client:
- Mail list/thread reads use the generated supervisor client directly in the
browser. The browser's "viewing as" state filters generated
Messageobjects in memory; there is no dashboard read router or dashboard mail-read DTO. - Compose sends use the generated supervisor
POST /v0/city/{cityName}/mailclient directly in the browser with the operator wire identityfrom: "human"and the supervisor mutation header. There is no dashboard mail-send router or dashboard mail-send DTO.
Frontend renders a visible "Viewing as " banner with colour; the compose-from field is greyed when viewing-as ≠ the operator so the constraint is visible before the user tries.
Audit log (audit.ts): mail no longer passes through the dashboard
service. Mail-send audit/event semantics are owned by the supervisor API; the
dashboard service only records dashboard-local writes.
No persistent client-side caching of mail under as-identity (localStorage,
IndexedDB, or durable caches). Generated-client query caches must key by
identity and stay in memory.
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=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.