Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
111e2a8
spec: control-plane hardening design (sparring draft)
DanielSoCra Jul 7, 2026
f291870
spec: address codex spec-review iteration 1 findings
DanielSoCra Jul 7, 2026
0704e56
spec: address codex spec-review iteration 2 findings
DanielSoCra Jul 7, 2026
66725c7
spec: address codex spec-review iteration 3 findings
DanielSoCra Jul 7, 2026
b8921a3
spec: address codex spec-review iteration 4 findings
DanielSoCra Jul 7, 2026
4505f11
plan: control-plane hardening implementation plan
DanielSoCra Jul 7, 2026
b68398a
plan: address codex plan-review iteration 1 findings
DanielSoCra Jul 7, 2026
dbe640b
plan: address codex plan-review iteration 2 findings
DanielSoCra Jul 7, 2026
b746f85
spec+plan: address codex plan-review iteration 3 findings
DanielSoCra Jul 7, 2026
a2fed45
test(gate): immovable acceptance tests for control-plane auth
DanielSoCra Jul 7, 2026
8cc38cd
handoff: control-plane hardening work-order
DanielSoCra Jul 7, 2026
0679101
test(gate): nodenext import extensions in acceptance tests
DanielSoCra Jul 7, 2026
8081707
security: control-plane bearer auth, dependency audit gate, secrets h…
DanielSoCra Jul 7, 2026
92cdbe4
docs: control-plane hardening result file
DanielSoCra Jul 7, 2026
0296727
ops: complete RUNFORGE_CONTROL_TOKEN plumbing across launchd, env exa…
DanielSoCra Jul 7, 2026
c1006e2
review: implementation deep-review findings
DanielSoCra Jul 7, 2026
b649e8a
fix(daemon): resolve .env.mac from repo root in CLI
DanielSoCra Jul 7, 2026
61cd031
fix(daemon): accept Bearer scheme case-insensitively (RFC 7235)
DanielSoCra Jul 7, 2026
de46665
fix(dashboard): return 500 on DaemonAuthError in GET proxies
DanielSoCra Jul 7, 2026
6b6fe2d
ops(scripts): harden install-daemon provisioning and creds log creation
DanielSoCra Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.mac.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Daemon URL — daemon runs natively on the macOS host, containers reach it via host.docker.internal
DAEMON_URL=http://host.docker.internal:3847

# --- Control-plane token ---
# Shared secret between the daemon control plane and its callers (dashboard,
# briefing-summarizer, concierge, CLI). The native install script generates and
# appends this to .env.mac automatically if missing. Rotate by generating a new
# value, updating .env.mac, and reinstalling/reloading the daemon and dashboard.
RUNFORGE_CONTROL_TOKEN=

# --- Self-hosted Postgres ---
POSTGRES_DB=runforge
POSTGRES_USER=runforge
Expand Down
6 changes: 6 additions & 0 deletions .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# If using Claude Max subscription locally (not in Docker), this is not needed
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# --- Control-plane token ---
# Shared secret between the daemon control plane and its callers (dashboard,
# briefing-summarizer, concierge, CLI). Required in Docker because the daemon
# binds on 0.0.0.0. The compose file fails fast if this is unset.
RUNFORGE_CONTROL_TOKEN=

# Self-hosted Postgres used by app-owned stores
POSTGRES_DB=runforge
POSTGRES_USER=runforge
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ jobs:
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: pnpm/action-setup@v4

Expand All @@ -63,6 +65,33 @@ jobs:

- run: pnpm install --frozen-lockfile

- name: Audit (prod, high)
run: pnpm audit --prod --audit-level high

- name: Gitleaks (full history)
run: |
set -u
VERSION=8.30.1
ARCH="$(uname -m)"
case "$ARCH" in
arm64)
FILE="gitleaks_${VERSION}_darwin_arm64.tar.gz"
CHECKSUM="b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5"
;;
x86_64)
FILE="gitleaks_${VERSION}_darwin_x64.tar.gz"
CHECKSUM="dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709"
;;
*)
echo "::error::Unsupported architecture: $ARCH"
exit 1
;;
esac
curl -sL -o "/tmp/$FILE" "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/$FILE"
echo "$CHECKSUM /tmp/$FILE" | shasum -a 256 -c
tar -xzf "/tmp/$FILE" -C /tmp
/tmp/gitleaks detect --redact

# Real Postgres for the decision-index integration suite. The runner is
# self-hosted (macOS), where GitHub `services:` containers are Linux-only,
# so start Postgres via `docker run` on a random host port (avoids clashing
Expand Down
2 changes: 1 addition & 1 deletion .specify/architecture/operator-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ These records physically reside in the shared operational data store, but their

- The **Auth Service** owns identity, sessions, role assignment, invitations, bootstrap state, and every authorization decision.
- The **Dashboard** enforces the session-and-role gate on the server side before any privileged view or change; it never trusts a role asserted by the client.
- **Agent Service and daemon control operations** are protected by the same administrator-only gate.
- The **Agent Service and daemon control operations** are protected by the same administrator-only gate; the daemon independently enforces a bearer-token boundary on its control-plane routes.
- The **Data Service** provides only the shared store instance and the Migration Runner that physically create authorization records. No authorization logic lives in the data store; the data store's own policy engine is not used for access control.
- **Coexistence during the staged transition:** the existing Dashboard architecture remains authoritative for current sign-in behavior until the project-owned replacement lands. This architecture defines the target. Governed paths and the deprecation of superseded stack specifications transfer only in later implementation work, recorded via metadata, never by deletion.

Expand Down
1 change: 1 addition & 0 deletions .specify/functional/operator-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Runforge's dashboard relies on an external hosted provider to sign operators in
- The administrator-versus-viewer capability distinction is enforced before any privileged view or change, is decided by the application's own rules rather than by where data is stored, and defaults to refusal.
- The all-or-nothing sign-in bypass is replaced by an explicit, named, local-only convenience mode that cannot activate in a production environment.
- Every operator and role present before the move has documented, verified equivalent access afterward, and no operator capability is weakened relative to the prior system.
- The daemon's control-plane routes require `Authorization: Bearer <RUNFORGE_CONTROL_TOKEN>` for every route except `GET /health`, and refuse to start on a non-loopback bind without a token.

## Constraints

Expand Down
5 changes: 3 additions & 2 deletions .specify/stack/operator-auth-ts.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ Chosen over a hand-rolled session system (security risk, the issue forbids weake
## Key Decisions

- **Better Auth + Drizzle adapter** — auth tables defined in the shared `packages/db` schema but **created by the Data Platform Migration Runner** (STACK-AC-DATA-PLATFORM owns ordering; this spec owns their definition and semantics).
- **Server-side enforcement only** — a `requireSession()` in route handlers and server components; the client-asserted role is never trusted. Daemon control routes sit behind an admin-only variant.
- **Pure `gateDecision` predicate** — `(session, { localBypass }) → '/login' | null | 'deny'`; no I/O, fully unit-tested, mirrors the regulated pilot deployment's `gate-decision.ts` split.
- **Server-side enforcement only** — a `requireSession()` in route handlers and server components; the client-asserted role is never trusted. Dashboard enforces roles; the daemon enforces the bearer boundary on the control plane with `RUNFORGE_CONTROL_TOKEN`.
- **Daemon control-plane auth model** — the daemon guards every control-plane route except `GET /health` with `Authorization: Bearer <RUNFORGE_CONTROL_TOKEN>`; the bind host must be IPv4 loopback (`127.0.0.0/8`) when no token is configured, otherwise non-loopback binds are refused at startup. A legacy loopback mode allows tokenless operation on loopback with loud deprecation warnings. `X-Requested-By` is kept as CSRF/provenance defense on mutating methods, running after the bearer check. The built-in HTML dashboard is legacy/loopback-only.
- **Pure `gateDecision` predicate** — `(session, { localBypass }) → '/login' | null | 'deny'`; no I/O, fully unit-tested, mirrors the regulated pilot deployment's `gate-decision.ts` split.
- **App-owned `role`** — `administrator | viewer` on the membership/user record, enforced in application code; Supabase `is_admin()`/`is_member()` SQL and all RLS policies are removed.
- **`AUTH_DISABLED` → `LOCAL_AUTH_BYPASS`** — the blunt switch is replaced by a named local-only bypass that activates only when an explicit local flag is set **and** no production indicator (`NODE_ENV=production`, deploy markers) is present; it refuses in production and logs the refusal.
- **Continuity** — preserve first-user-is-admin bootstrap and the invitation flow; `team_members` / `invitations` semantics carry over with documented operator migration.
Expand Down
14 changes: 14 additions & 0 deletions .specify/traceability.yml
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ STACK-AC-OPERATOR-SURFACE-CLIENT:
- packages/dashboard/app/api/decisions/answer/route.test.ts
- packages/dashboard/app/api/daemon/daemon-routes.test.ts
- packages/dashboard/app/api/daemon/p3-halt-proxy.gate.test.ts
- packages/dashboard/app/api/metrics/escalation/route.test.ts
- packages/dashboard/components/steering/p3-live-inbox.gate.test.tsx
- packages/dashboard/components/steering/p3-stale-stub-cleanup.gate.test.ts
- packages/dashboard/app/api/decisions/[id]/route.test.ts
Expand Down Expand Up @@ -1709,10 +1710,23 @@ STACK-AC-OPERATOR-AUTH:
- packages/dashboard/lib/auth/**
- packages/dashboard/app/api/auth/**
- packages/dashboard/app/(auth)/**
- packages/daemon/src/control-plane/server.ts
- packages/daemon/src/control-plane/degraded-server.ts
- packages/daemon/src/control-plane/control-auth.ts
- packages/daemon/src/control-plane/resolve-control-token.ts
- packages/daemon/src/control-plane/cli.ts
- packages/daemon/src/main.ts
- packages/dashboard/lib/daemon-fetch.ts
- docker-compose.yml
- scripts/install-daemon.sh
- scripts/com.runforge.daemon.plist
test_paths:
- packages/auth/**/*.test.ts
- packages/dashboard/lib/auth/**/*.test.ts
- packages/dashboard/app/api/auth/**/*.test.ts
- packages/daemon/src/control-plane/server.test.ts
- packages/daemon/src/control-plane/control-auth.test.ts
- packages/daemon/src/control-plane/resolve-control-token.test.ts
status: draft

# Operator-approved production release (D5). FUNC status: draft pending Operator approval
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ services:
NODE_ENV: production
PLUGINS_DIR: /app/plugins
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY in the selected env file}
RUNFORGE_CONTROL_TOKEN: ${RUNFORGE_CONTROL_TOKEN:?set RUNFORGE_CONTROL_TOKEN in the selected env file}
ports:
- "${DASHBOARD_PORT:-127.0.0.1:3000:3000}"
depends_on:
Expand All @@ -93,6 +94,7 @@ services:
DAEMON_HOST: "0.0.0.0"
DAEMON_DATA_BACKEND: ${DAEMON_DATA_BACKEND:-postgres}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY in the selected env file}
RUNFORGE_CONTROL_TOKEN: ${RUNFORGE_CONTROL_TOKEN:?set RUNFORGE_CONTROL_TOKEN in the selected env file}
# The container IS the externally-managed sandbox, so workers run with
# --dangerously-skip-permissions to clear the CLI "Workspace not trusted"
# gate on dynamic worktree cwds. The daemon's PreToolUse containment hooks
Expand Down Expand Up @@ -142,6 +144,7 @@ services:
BRIEFING_DATA_BACKEND: ${BRIEFING_DATA_BACKEND:-postgres}
NODE_ENV: production
GIT_REPO_PATH: /repo
RUNFORGE_CONTROL_TOKEN: ${RUNFORGE_CONTROL_TOKEN:?set RUNFORGE_CONTROL_TOKEN in the selected env file}
depends_on:
migrate:
condition: service_completed_successfully
Expand Down
22 changes: 17 additions & 5 deletions docs/running.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ cp .env.prod.example .env.prod
|----------|----------|-------------|
| `GITHUB_TOKEN` | Yes | GitHub PAT with `repo` scope |
| `ANTHROPIC_API_KEY` | Yes | Anthropic API key |
| `RUNFORGE_CONTROL_TOKEN` | Yes | Shared secret for daemon control-plane bearer auth |
| `POSTGRES_DB` | Yes | Compose-managed Postgres database name |
| `POSTGRES_USER` | Yes | Compose-managed Postgres user |
| `POSTGRES_PASSWORD` | Yes | Compose-managed Postgres password |
Expand Down Expand Up @@ -119,11 +120,18 @@ The macOS host uses a **hybrid deployment**: dashboard and briefing-summarizer r
# 1. Start the daemon natively (if not already running via launchd)
cd packages/daemon && pnpm start &

# 2. Start Postgres, migrations, dashboard, and briefing-summarizer in Docker
# 2. Start Postgres, migrations, dashboard, and briefing-summarizer in Docker.
# Both ENV_FILE and --env-file are required: ENV_FILE feeds each service's
# env_file:, --env-file feeds the ${...:?} interpolation in the compose file.
ENV_FILE=.env.mac docker compose --env-file .env.mac up --build -d
```

Dashboard is available at `http://localhost:3000` on the local network. Auth is disabled. The dashboard connects to the native daemon via `host.docker.internal:3847`.
Dashboard is available at `http://localhost:3000` on the local network. Auth is disabled. The dashboard connects to the native daemon via `host.docker.internal:3847` and forwards `RUNFORGE_CONTROL_TOKEN` on every control-plane request.

> **Token rotation:** update `RUNFORGE_CONTROL_TOKEN` in `.env.mac`, run
> `ENV_FILE=.env.mac docker compose --env-file .env.mac up -d` to recreate the
> containers, and reinstall/reload the native daemon plist so the new token is
> in the daemon's environment.

## Database Migrations

Expand Down Expand Up @@ -284,11 +292,15 @@ Use `/pause` when you want the daemon to stop picking up work but let current ru
# Emergency halt (requires X-Requested-By; Bearer token if RUNFORGE_CONTROL_TOKEN is set)
curl -fsS -X POST localhost:3847/halt \
-H 'X-Requested-By: operator' \
-H 'Authorization: Bearer <token>'
-H "Authorization: Bearer ${RUNFORGE_CONTROL_TOKEN}"

# Pause / resume
curl -fsS -X POST localhost:3847/pause -H 'X-Requested-By: operator'
curl -fsS -X POST localhost:3847/resume -H 'X-Requested-By: operator'
curl -fsS -X POST localhost:3847/pause \
-H 'X-Requested-By: operator' \
-H "Authorization: Bearer ${RUNFORGE_CONTROL_TOKEN}"
curl -fsS -X POST localhost:3847/resume \
-H 'X-Requested-By: operator' \
-H "Authorization: Bearer ${RUNFORGE_CONTROL_TOKEN}"
```

A paused daemon also gates integrate entry: a run that reaches the `integrate` phase while paused is parked at `pausedAtPhase: 'integrate'` instead of merging, then resumes through the normal integrate arm after `/resume`.
Expand Down
20 changes: 20 additions & 0 deletions docs/security-overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Security Overrides

`pnpm.overrides` pins in root `package.json` for high-severity transitive vulnerabilities
that are not cleared by direct dependency upgrades alone.

| Package path | Pinned version | Advisory(s) | Date |
|---|---|---|---|
| `micromatch>picomatch` | 2.3.2 | GHSA-c2c7-rcm5-vvqj | 2026-07-07 |
| `path-to-regexp` | 8.4.2 | GHSA-j3q9-mxjg-w52f | 2026-07-07 |
| `fast-uri` | 3.1.3 | GHSA-q3j6-qgpj-74h6, GHSA-v39h-62p7-jpjc | 2026-07-07 |
| `vitest>picomatch` | 4.0.5 | GHSA-c2c7-rcm5-vvqj | 2026-07-07 |
| `vitest>vite` | 7.3.6 | GHSA-v2wj-q39q-566r, GHSA-p9ff-h696-f583, GHSA-fx2h-pf6j-xcff | 2026-07-07 |
| `jsdom>undici` | 7.28.0 | GHSA-vmh5-mc38-953g, GHSA-vxpw-j846-p89q, GHSA-hm92-r4w5-c3mj | 2026-07-07 |
| `@dotenvx/dotenvx>picomatch` | 4.0.5 | GHSA-c2c7-rcm5-vvqj | 2026-07-07 |

Direct upgrades performed alongside these overrides:

- `next` 16.2.0 → 16.2.10 (dashboard)
- `hono` ^4.12.8 → ^4.12.28 (concierge)
- `better-auth` 1.6.11 → 1.6.23 (dashboard; moves `vitest` from a regular dependency to a peer dependency, eliminating most dev-graph audit noise without overrides)
Loading
Loading