From 41e92709f750a54e3d2c0e7761c2a0f6dc6ef534 Mon Sep 17 00:00:00 2001 From: Sage Hart Date: Wed, 12 Aug 2026 21:27:40 -0500 Subject: [PATCH] fix(security): require dashboard bearer by default CORPOS_MODE=local no longer skips approve/kill auth. Ungated simulation requires explicit CORPOS_ALLOW_UNAUTHENTICATED=true (FO-017 / REV-010). --- .env.example | 8 +++--- README.md | 2 +- SECURITY.md | 6 +++-- apps/api/src/app.ts | 3 ++- scripts/harness/adversarial-run.mjs | 39 ++++++++++++++++++++++++++--- 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index b498fca..ad58fbb 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,11 @@ # logs, and agent output. # ── Runtime mode ───────────────────────────────────────────────────── -# local (default): approvals/kill do not require a shared token. -# shared: require DASHBOARD_API_TOKEN for approval and kill mutations. -# CORPOS_MODE=local +# Dashboard mutations require DASHBOARD_API_TOKEN by default (FO-017). +# Ungated local simulation only with an explicit opt-in: +# CORPOS_ALLOW_UNAUTHENTICATED=true +# CORPOS_MODE=local does not skip the bearer gate. +# CORPOS_MODE=shared # DASHBOARD_API_TOKEN= # ── Data ───────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index 817dee8..f3d72e5 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Architecture decisions live in [`docs/adr/README.md`](docs/adr/README.md). ## Security -Reference architecture — not production-hardened. `CORPOS_MODE=shared` plus `DASHBOARD_API_TOKEN` enables a **Bearer token gate on the API** for approve/kill mutations. The ops console does not yet attach that token (shared-mode approvals return 401 until a Bearer-capable client is used). See [SECURITY.md](SECURITY.md). +Reference architecture — not production-hardened. `DASHBOARD_API_TOKEN` is required for approve/kill mutations by default; `CORPOS_MODE=local` does not skip the bearer gate. Set `CORPOS_ALLOW_UNAUTHENTICATED=true` only for local simulation. See [SECURITY.md](SECURITY.md). ## Community diff --git a/SECURITY.md b/SECURITY.md index e25a7bd..4f5c3ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,6 +11,7 @@ Treat it as a design artifact you can run locally. - Unknown tools fail closed. - Exception HITL with scheduled TTL fail-closed; kill switch; department capital caps. - Shared-mode console sends Bearer (`VITE_DASHBOARD_API_TOKEN` / `DASHBOARD_API_TOKEN`). +- Dashboard mutations require `DASHBOARD_API_TOKEN` by default; `CORPOS_MODE !== "shared"` does not ungated them. - G3 quorum, G6 appeal, and enforcement `strict`/`audit` modes. - Hash-chained audit receipts (`npm run audit:verify`). - Company-day demos do not auto-approve exceptions unless a caller passes @@ -26,8 +27,9 @@ Treat it as a design artifact you can run locally. ## Shared demo -When `CORPOS_MODE=shared`, `DASHBOARD_API_TOKEN` is required as a **Bearer** -token on API approve and kill mutations. The ops console sends +When `CORPOS_ALLOW_UNAUTHENTICATED` is unset, `DASHBOARD_API_TOKEN` is required as a **Bearer** +token on API approve and kill mutations. `CORPOS_MODE=local` (or any value other than +an explicit opt-in) does not skip the gate. The ops console sends `VITE_DASHBOARD_API_TOKEN` when configured. This is a static token compare for demos, not an OAuth flow. diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 22adb03..0dbb086 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -30,7 +30,8 @@ import { fileURLToPath } from "node:url"; /** Dashboard bearer gate — exported for adversarial behavioral probes. */ export function requireAuth(c: { req: { header: (n: string) => string | undefined } }): boolean { - if (process.env.CORPOS_MODE !== "shared") return true; + // Ungated simulation only with an explicit opt-in (FO-017). CORPOS_MODE !== "shared" must not imply allow. + if (process.env.CORPOS_ALLOW_UNAUTHENTICATED === "true") return true; const expected = process.env.DASHBOARD_API_TOKEN?.trim(); if (!expected) return false; const header = c.req.header("authorization") ?? ""; diff --git a/scripts/harness/adversarial-run.mjs b/scripts/harness/adversarial-run.mjs index 752e929..e4c5a8b 100644 --- a/scripts/harness/adversarial-run.mjs +++ b/scripts/harness/adversarial-run.mjs @@ -162,12 +162,34 @@ const email = await company.gateway.invoke( ); cell("ASI09", email.decision.effect === "approve" && Boolean(email.decision.approvalId), "HITL"); -// AUTH — exercise real requireAuth via HTTP on /api/kill (shared mode) +// AUTH — requireAuth is closed by default; CORPOS_MODE=local must not ungated (FO-017) const prevMode = process.env.CORPOS_MODE; const prevToken = process.env.DASHBOARD_API_TOKEN; -process.env.CORPOS_MODE = "shared"; +const prevAllow = process.env.CORPOS_ALLOW_UNAUTHENTICATED; +delete process.env.CORPOS_ALLOW_UNAUTHENTICATED; +delete process.env.CORPOS_MODE; process.env.DASHBOARD_API_TOKEN = "secret"; const app = buildApp(company, "simulation"); +const defaultUnauth = await app.request("/api/kill", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ killed: true }), +}); +process.env.CORPOS_MODE = "local"; +const localUnauth = await app.request("/api/kill", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ killed: true }), +}); +process.env.CORPOS_ALLOW_UNAUTHENTICATED = "true"; +const optIn = await app.request("/api/kill", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ killed: false }), +}); +delete process.env.CORPOS_ALLOW_UNAUTHENTICATED; +process.env.CORPOS_MODE = "shared"; +process.env.DASHBOARD_API_TOKEN = "secret"; const unauth = await app.request("/api/kill", { method: "POST", headers: { "content-type": "application/json" }, @@ -185,11 +207,20 @@ if (prevMode === undefined) delete process.env.CORPOS_MODE; else process.env.CORPOS_MODE = prevMode; if (prevToken === undefined) delete process.env.DASHBOARD_API_TOKEN; else process.env.DASHBOARD_API_TOKEN = prevToken; +if (prevAllow === undefined) delete process.env.CORPOS_ALLOW_UNAUTHENTICATED; +else process.env.CORPOS_ALLOW_UNAUTHENTICATED = prevAllow; const authedBody = await authed.json(); +const optInBody = await optIn.json(); cell( "AUTH", - unauth.status === 401 && authed.status === 200 && authedBody.killed === false, - `unauth=${unauth.status} authed=${authed.status}`, + defaultUnauth.status === 401 && + localUnauth.status === 401 && + unauth.status === 401 && + authed.status === 200 && + authedBody.killed === false && + optIn.status === 200 && + optInBody.killed === false, + `default=${defaultUnauth.status} local=${localUnauth.status} unauth=${unauth.status} authed=${authed.status} optin=${optIn.status}`, ); // Audit forge