From f2712481f61a48b8eb3d912d27786dcdbd1fb2a3 Mon Sep 17 00:00:00 2001 From: Nivedit Jain Date: Mon, 27 Jul 2026 14:35:22 +0530 Subject: [PATCH 1/3] =?UTF-8?q?docs(design):=20failproofaid=20=E2=80=94=20?= =?UTF-8?q?Rust=20daemon,=20thin=20client,=20unified=20collector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design document for v1.0.0, for review. No implementation. Proposes turning failproofai from a stateless cold-start-per-event hook into a long-lived daemon that also absorbs agenteye-collector, so one process per machine both enforces policy and collects agent sessions. The shape: a Rust supervisor owning the socket, verdict, spool, shipper and tailers, driving unprivileged Node workers that host the existing TypeScript policy engine unchanged. The runtime decision (the collector is Rust) and the privilege-separation decision resolve to the same topology — the unprivileged worker simply happens to be a JS runtime. Three things it unlocks that are impossible today: policies that remember (require-tests-before-stop), one collector covering all 12 CLIs instead of 3, and a timeline joining hook verdicts to transcript content that neither product can compute alone. Includes eight findings in the current codebase that this architecture would amplify — chief among them that the builtins shell out to git/gh with a cwd taken verbatim from the client payload, which is harmless in a user process and root RCE in a root one — plus fifteen ranked silent-enforcement-loss risks, each with a mechanical guard rather than a review note, and a 16-stage delivery sequence that front-loads the value which needs no Rust. Three recommended deviations from earlier decisions are flagged for the review rather than folded in silently, and eight open questions are listed, including one load-bearing dependency on the AgentEye ingest endpoint being an upsert. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ex49L3LF4YmcWicwwZWZXg --- .../2026-07-27-failproofaid-daemon-design.md | 741 ++++++++++++++++++ 1 file changed, 741 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md diff --git a/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md b/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md new file mode 100644 index 00000000..0fc2ac62 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md @@ -0,0 +1,741 @@ +# failproofaid — a Rust daemon, a thin client, and one collector for every agent + +**Date:** 2026-07-27 +**Branch:** `1.0.0` +**Status:** design — awaiting review +**Ships as:** v1.0.0 (major) + +## Goal + +Turn failproofai from a stateless, cold-start-per-event hook into a long-lived daemon that +also absorbs `agenteye-collector`, so that one process per machine both **enforces** policy +and **collects** agent sessions for every supported CLI. + +Three things this unlocks that are impossible today: + +1. **Policies that remember.** Cross-event session state makes a whole class of policy + writable for the first time — starting with `require-tests-before-stop`. +2. **One collector instead of two daemons.** failproofai already parses **12** agent CLIs; + `agenteye-collector` ships **3**. Merging gives capture for all twelve using code that is + already written and already live-verified. +3. **A joined timeline.** Hook events carry the *policy verdict*; transcripts carry the + *model output and tool results*. Neither product can compute the join alone. + +This document is for review and iteration. **No implementation is proposed for this PR.** + +## Scope + +### In scope + +- The daemon/client split, its process topology, and the privilege boundary. +- The Rust ↔ TypeScript division of labour, and why the policy engine stays TypeScript. +- The wire contract, and how the exit-code and byte-exact-stdout guarantees survive it. +- Absorbing the collector: transcript tailing, cursors, the durable spool, cloud shipping. +- Correlating live hook events with tailed transcript events into one session timeline. +- npm distribution of per-platform Rust binaries, and the CI/publish changes it needs. +- A staged delivery sequence, with the point of no return named. +- Security findings in the *current* codebase that this architecture would amplify. + +### Out of scope + +- Windows. Linux and macOS first; Windows is sketched only where it changes a decision + (named-pipe ACLs, the Copilot dual-shell command strings). +- The dashboard's own re-architecture. It becomes separately installable; its internals do + not change here. +- The AgentEye server, its API surface, and its data model. One dependency on it is called + out as an open question. +- Evaluations, audits, alerts, incidents — the analysis half of Observability. +- Any change to what the twelve vendor CLIs themselves do. + +--- + +## Why now: what the current design costs + +Every hook event — one per agent tool call, across 12 CLIs — spawns a fresh process that +redoes all of its setup and exits. Measured from the code: + +- 1–3 process spawns (`npx -y failproofai` resolution on the default project-scope install) +- a full TypeScript/ESM module-graph parse, roughly 10k lines, including ~75 KB of session + libraries that `cli=claude` never touches +- construction of all 39 builtin policies and their regexes at module scope +- two `.failproofai` tree-walks, three config reads, four `readdir`s +- **six file writes and six unlinks**, purely so that `import ... from 'failproofai'` resolves + inside a user's policy file +- one to three **awaited** PostHog POSTs that block process exit + +The genuinely per-event work is: read stdin, `JSON.parse`, a few table lookups, about twenty +regex tests, one JSONL append. + +Latency is the least of it. The stateless design **structurally blocks** the product: + +**Policies cannot remember anything.** The five stop-gates fake state by re-deriving it from +external oracles on every fire. `require-ci-green-before-stop` spawns roughly four +subprocesses including a **15-second `gh` network call on every Stop**. +`warn-repeated-tool-calls` maintains a 64 KB-capped sidecar JSON file per transcript and +silently no-ops when `transcriptPath` is absent. `gitBranchCache` is a module-scope `Map` in a +process that exits immediately, so its production hit rate is approximately zero. +`pendingStopBlockBySession` had to be hand-rolled inside the Pi shim because the engine had +nowhere to put it. There is no `require-tests-before-stop` because *"did tests run earlier in +this session?"* has no answer. + +**There is no persistent process to build on.** Confirmed absent across `src/`, `lib/`, +`bin/`, and `scripts/`: `createServer`, `.listen(`, `net.createServer`, named pipes, IPC, +`fs.watch`, chokidar. The only long-lived process is the Next.js standalone server, and its +sole cross-request state is an in-memory singleton that dies on restart. + +**Meanwhile a second daemon already exists for the same machines.** `agenteye-collector` is +installed per agent machine, registers as a systemd or launchd service, tails local session +transcripts, and ships them to AgentEye. Running two long-lived failproofai-family daemons on +every developer box is not a design anyone would choose deliberately. + +### The asset that makes the merge cheap + +failproofai's audit pillar is **already a collector that ships nowhere**. The root-level +`lib/-projects.ts` and `lib/-sessions.ts` modules parse claude, codex, copilot, +cursor, opencode, pi, hermes, openclaw, factory, devin, antigravity, and goose into a +canonical `LogEntry`, and the twelve adapters under `src/audit/cli-adapters/` normalize that +into `NormalizedToolEvent`. The web dashboard and the audit pipeline already share this one +parsing layer. + +`agenteye-collector` covers three of those twelve. The merge is mostly a matter of pointing +existing parsers at an incremental reader and a shipper. + +--- + +## Architecture + +`failproofaid` is a Rust binary that owns the socket, the verdict, the durable spool, the +cloud shipper, and the transcript tailers. It does **not** own policy evaluation or transcript +parsing — those are roughly 18,000 lines of live-verified TypeScript. It spawns unprivileged +JavaScript workers and speaks a length-prefixed JSON protocol to them over pipes. + +That is not a compromise. It is exactly the privilege-separation model we want, in which the +unprivileged worker happens to be a JavaScript runtime. + +### Process topology and trust boundary + +```mermaid +flowchart TB + subgraph agent["Agent CLI process — the calling user"] + CLI["claude / codex / cursor / goose / ..."] + HOOK["failproofai-hook
Rust, static, ~2-3ms"] + CLI -->|"stdin payload"| HOOK + end + + subgraph root["failproofaid supervisor — root"] + IPC["ipc
socket + SO_PEERCRED"] + ROUTER["router
VERDICT AUTHORITY"] + POOL["worker_pool"] + SPOOL["spool
durable queue"] + STATE["state_store"] + IPC --> ROUTER + ROUTER --> POOL + ROUTER --> STATE + ROUTER --> SPOOL + end + + subgraph unpriv["Unprivileged workers"] + ENF["failproofaid-enforce
Node, uid _failproofai
pure builtins only"] + POL["failproofaid-policy
Node, calling user
per uid+project
CUSTOMER CODE"] + TAIL["failproofaid-tailer
calling user
reads own home only"] + SHIP["failproofaid-shipper
uid _failproofai-net
the only outbound socket"] + end + + HOOK -->|"AF_UNIX"| IPC + POOL --> ENF + POOL --> POL + POOL --> TAIL + SPOOL --> SHIP + SHIP -->|"HTTPS, one host"| CLOUD[("AgentEye")] + + style root fill:#2d2d3a,stroke:#e4587c,color:#fff + style unpriv fill:#1f2430,stroke:#5a6478,color:#fff + style agent fill:#1f2430,stroke:#5a6478,color:#fff +``` + +| Process | Language | Runs as | Network | Loads customer code | Reads user paths | +|---|---|---|---|---|---| +| `failproofaid` supervisor | Rust | root | **never** | no | **never** | +| `failproofaid-enforce` | Node | `_failproofai` (nologin, no home) | no | **no** | no | +| `failproofaid-policy` | Node | calling user, per `(uid, project)` | yes | **yes** | yes | +| `failproofaid-tailer` | Rust | calling user, per collected uid | no | no | own home only | +| `failproofaid-shipper` | Rust | `_failproofai-net` | one host | no | spool only | +| `failproofaid-updater` | Rust | root, short-lived | one host | no | staging only | + +Two design laws follow from that table: + +- **The root supervisor opens no network socket and parses no user-controlled file content.** + Network lives in the shipper and updater; user paths live in dropped workers. What remains + in root is orchestration, identity, the verdict meet, audit writes, and response shaping. +- **The verdict path and the collector path never share a process, a credential, or a failure + mode.** A hung shipper must never stall a tool call. + +### The three rules that keep this safe + +**Rule 1 — There is exactly one policy engine, and it is TypeScript.** + +Worth writing down now, while it is cheap. The dominant failure mode of "Rust daemon plus TS +engine" is someone later porting *just the hot builtins* to Rust for speed. That is how you +end up with the Rust one saying allow and the JavaScript one saying deny. If evaluation is +ever too slow, the answer is worker pooling and caching — never a second implementation. + +**Rule 2 — The worker computes; root decides.** + +The worker returns a **verdict** (`{decision, reason, policyNames}`). It never returns bytes +and never returns an exit code — handing it those would be handing it the verdict. +Composition is a **lattice meet** over `allow` < `instruct` < `deny`, never a join. + +```mermaid +flowchart LR + A["failproofaid-enforce
pure builtins
(cannot fork, no network)"] -->|"verdict A"| M{"MEET
min(A, B)"} + B["failproofaid-policy
impure builtins
+ customer JS"] -->|"verdict B"| M + M --> S["root: shape response
from generated table"] + S --> O["exitCode + stdout bytes + stderr bytes"] + + B -.->|"timeout / crash /
bad schema / no reply"| F["failure mode
(NOT allow)"] + F --> M +``` + +No message a worker can send turns a root DENY into an ALLOW. Worker death, timeout, or a +schema violation applies the configured failure mode — **absence is never allow**. + +**Rule 3 — Root never opens a file under any user's home.** + +Parser and tailer workers run as the owning uid. Root reading `/home/alice/.codex/sessions` +and shipping the bytes would be a one-line exfiltration primitive: any local user could +replace a transcript with a symlink to `/etc/shadow` or another user's private key. Enforce +this mechanically — a Rust architecture test asserting `router` and `ipc` have no path to +opening user paths, and an eslint `no-restricted-imports` fence around +`src/daemon/worker/**`. + +### Where response shaping happens + +Rule 2 says root emits the bytes, but the 25 per-CLI response branches in +`policy-evaluator.ts` are the crown jewels and must not be reimplemented in Rust. Both hold, +because **shaping is a pure total function of `(cli, event, decision)`** with `reason` and +`policyName` interpolated in. + +```mermaid +flowchart TB + SRC["policy-evaluator.ts
25 per-CLI branches
(the only authored source)"] + GEN["build step:
run evaluatePolicies across
cli x event x decision
(~12 x 26 x 3 = ~936 vectors)"] + TBL["generated response table
crates/failproofai-proto"] + CI{"CI parity check
regenerate + git diff --exit-code"} + + SRC --> GEN --> TBL + TBL --> CI + SRC --> CI + TBL --> USE1["root: shape a live verdict"] + TBL --> USE2["client: fail-closed when
there is no daemon to ask"] +``` + +The table is **generated, never authored**, so it cannot drift. This is the defense against +the repo's characteristic bug class — the same duplication that once let +`block-read-outside-cwd` silently no-op on every opencode read — applied mechanically rather +than by review. + +The same table serves the daemon-down path, where there is no worker to ask. One mechanism, +two uses. + +### Why the pure builtins get a process instead of a Rust port + +Root *should* evaluate the payload-pure builtins itself. But they are TypeScript, and several +use lookahead — `VERSION_FILE_MUNGE_RE`, `HOME_PREFIX_RE` — that Rust's linear-time `regex` +crate cannot express. You would need the backtracking `fancy-regex`, which means a hostile +pattern in user-supplied `policyParams` could **hang root**. That is a worse outcome than the +drift a port would avoid. + +Instead, `failproofaid-enforce` runs the existing pure set under a dedicated system uid with +`pids.max=1` and `RLIMIT_NPROC=1` — it physically cannot fork — Landlock read-only on the +packaged code, and no network. A *separate* uid rather than the calling user, because a +same-uid worker is `ptrace`-able and `LD_PRELOAD`-able **by the agent itself**. + +Of the 39 builtins, roughly 32 are pure functions of the payload: every `sanitize-*`, +`block-sudo`, `block-rm-rf`, `block-curl-pipe-sh`, `block-secrets-write`, `block-env-files`, +`protect-env-vars`, `block-read-outside-cwd` (path math only), `block-force-push`, +`block-push-master`, the cloud-CLI blocks, and the regex `warn-*` set. The roughly seven +impure ones — `block-work-on-main`, `warn-all-files-staged`, `warn-repeated-tool-calls`, and +the five stop-gates — are workflow nags, not containment controls. **The security backbone can +run privileged with zero IO; the nags run unprivileged.** + +### One request, end to end + +```mermaid +sequenceDiagram + participant CLI as Agent CLI + participant C as failproofai-hook (Rust) + participant R as failproofaid router (root) + participant E as enforce worker + participant P as policy worker (user) + + CLI->>C: spawn, write payload to stdin + C->>R: connect + length-prefixed request + R->>R: SO_PEERCRED -> uid (never from payload) + par pure and impure evaluate concurrently + R->>E: payload + E-->>R: verdict A + and + R->>P: payload + policy set + project root + P->>P: canonicalize, load config,
run builtins + customer JS + P-->>R: verdict B + end + R->>R: final = meet(A, B) + R->>R: shape via generated table
sanitize reason text + R->>R: append audit record, enqueue event + R-->>C: {exitCode, stdout bytes, stderr bytes} + C->>CLI: write bytes verbatim, exit(code) + Note over C,CLI: Invariant W: bytes are opaque.
No parse, no re-serialize, no trim. +``` + +The client is deliberately trivial: connect, pipe, write two buffers, exit with a number. +That gets spawn-to-exit to roughly 2–3 ms against Node's ~40 ms floor, on top of eliminating +all the per-event setup listed earlier. + +It also makes the stdout invariant **structural** rather than conventional. `dev-hook.mjs` +currently has to *document* "never write to stdout" as a rule a human must uphold. A Rust +client whose only stdout write is the response buffer cannot violate it. + +--- + +## Absorbing the collector + +`agenteye-collector` today: a Rust binary installed per machine, registered as a systemd or +launchd service, capturing Codex, OpenClaw, and Hermes by tailing local session transcripts. +It backfills once on first run then streams within seconds, delivers exactly once across +restarts, only ever reads the agent's files, keeps and retries a failed batch rather than +discarding it, and reports `health` as unhealthy while anything is outstanding — *"healthy +means your data arrived, not merely that the process is alive."* Capture is off until +enabled, per agent, because transcripts ship as-is and can contain secrets. + +Every one of those semantics is worth preserving verbatim. They are more mature than anything +on the enforcement side. + +### Two sources, one pipeline + +```mermaid +flowchart TB + subgraph sources["Two views of the same tool call"] + H["Hook event (live)
decision, policyName, reason
permissionMode, cwd, blocked"] + T["Transcript (tailed)
toolInput, toolResult, exit code
tokens, context fill, model"] + end + + H -->|"eventId"| J{"join buffer
5 min window"} + T -->|"same eventId"| J + J --> U["upsert on event_id"] + U --> SP["spool"] --> SH["shipper"] --> AE[("AgentEye
Event Stream")] + + H -.->|"no transcript half
after window"| BL["outcome: blocked
(a first-class event
no transcript can contain)"] + BL --> U + T -.->|"no hook half"| TO["source: transcript
(hooks not installed,
or model_response)"] + TO --> U +``` + +The correlation key: + +``` +eventId = blake3(sessionKey || correlationId || eventType) +sessionKey = blake3(machineId || uid || cli || sessionId) +correlationId = tool_use_id ?? "fp:" + fingerprint + ":" + nth +fingerprint = blake3(cli || sessionId || canonicalToolName + || stableStringify(canonicalToolInput)) +``` + +This works by construction, not by luck: **both sides already run the same +canonicalization.** `src/audit/cli-adapters/shared.ts` and `src/hooks/handler.ts` both call +`canonicalizeToolName` and `canonicalizeToolInput` from `src/hooks/tool-name-canonicalize.ts`. +One implementation, so the fingerprints agree and keep agreeing. + +Because the id is deterministic and content-derived: + +- **Exactly-once becomes an idempotency key** — at-least-once delivery plus an upsert. +- **Cursors become a performance optimization, not a correctness requirement.** Lose one, + re-read the file, the server dedupes. +- **The naive collector is safe.** Re-reading whole files each tick produces the same ids, + which is what lets all twelve CLIs ship on day one with zero adapter edits. +- **Double-shipping during cutover is safe**, so the old and new collectors can overlap for + a release. +- The hook / tool_use / tool_result three-way join is three order-independent upserts. + +### Field ownership + +The rule: **the hook owns what we decided and what the environment was; the transcript owns +what the model said and what came back.** + +| Field | Winner | Why | +|---|---|---| +| `decision`, `policyName`, `reason` | hook | only source — no transcript holds a verdict | +| `permissionMode` | hook | per-CLI resolution logic | +| `cwd` | hook | `resolve-cwd.ts` handles per-CLI quirks | +| `blocked` | hook | a deny means the tool never ran | +| `toolInput` | **transcript** | hook stdin is capped at 1 MB and discarded past it | +| `toolResultText`, exit code, duration | transcript | hook `PostToolUse` as fallback | +| tokens, context fill, reasoning, model | transcript | only source | +| `uid`, `machineId` | daemon | neither source has it | + +### Tailing + +**Watchers are a wakeup hint; the poll loop is the contract.** Never trust `fs.watch` or +inotify for correctness — they fail on NFS, overlayfs, containers, watch-limit exhaustion, +macOS FSEvents coalescing, and editor rename-over patterns. A watch event just makes the next +tick fire immediately. + +- **JSONL family** (claude, codex, copilot, cursor, pi, factory, openclaw, antigravity): + byte-offset tail, holding the partial trailing line in the cursor. +- **SQLite family** (hermes, goose, devin, plus antigravity's index): Rust `rusqlite` + read-only with real WAL visibility, cursor on `rowid`. **This is a genuine upgrade.** The + current `sql.js` fallback reads the *entire* database file into memory and sees only up to + the last WAL checkpoint — for a tailer that is fatal, since it would re-read a large + database every tick and still miss the newest rows. +- Truncation and rotation detected by size, inode, device, and a tail hash. Deterministic + event ids make re-backfill free. +- **Backfill defaults to none.** A machine with years of history is many gigabytes; shipping + all of it on install is both a cost event and a privacy event. + +### Reusing the twelve parsers + +Add one optional method to the adapter interface: + +```ts +interface CliAdapter { + cli: IntegrationType; + listTranscripts(opts?): Promise; // unchanged + streamEvents(meta): Promise; // unchanged + parseDelta?(input, cursor): Promise; // NEW, optional +} +``` + +Adapters without `parseDelta` get a generic shim that calls `streamEvents` and drops +everything at or below the high-water mark using the event id. Correct but O(file) per tick. +**That is how all twelve CLIs work on day one with zero adapter edits**, and how the hot three +get optimized later with no behaviour change. This is the single most important de-risking +decision in the plan. + +One high-leverage enrichment: `src/audit/cli-adapters/shared.ts` is 85 lines and currently +skips past everything that is not a `tool_use` block. Teaching it to also emit +`model_response` from assistant entries unlocks token counts and context-fill **for all twelve +CLIs at once**. + +### Durability + +Adopt the collector's proven semantics wholesale: segment files, `fsync` on close, a size and +age cap, drop-oldest on overflow **recorded as a first-class error event**, exponential +backoff with jitter, and quarantine to a dead-letter directory for permanently-rejected +batches so one bad payload cannot wedge the queue. + +**The spool may lose data; the verdict path may not.** Enforcement must never block, slow, or +fail because the network is down or the disk is full. + +PostHog telemetry folds into the same spool with a different sink. Today +`flushHookTelemetry()` blocks process exit on one to three POSTs; afterwards the hook path +makes **zero network calls**. That also gives a deliberate sequencing benefit: the durability +machinery gets proven against a low-stakes sink before any customer transcript touches it. + +--- + +## What cross-event state makes possible + +State lives in the daemon, keyed `session:`, `project:`, or `machine:`, with declared +durability per key. `PolicyContext` gains a `state` API — `get`, `set`, `incr` with a sliding +window, `at(scope)`, `timeline()`, and a bounded `settle()` that waits for an in-flight +transcript half. + +The policy that has been unwritable until now: + +```ts +match: { events: ["Stop"] }, +async fn(ctx) { + await ctx.state.settle({ timeoutMs: 300 }); + const t = ctx.state.timeline(); + + const edited = t.some(e => e.type === "tool_use" + && ["Edit", "Write", "MultiEdit"].includes(e.toolName) + && isSourceFile(String(e.toolInput.file_path ?? ""))); + if (!edited) return allow("no source edits this session"); + + const testPassed = t.some(e => e.type === "tool_use" + && e.toolName === "Bash" + && TEST_CMD_RE.test(String(e.toolInput.command ?? "")) + && e.exitCode === 0); // exit code comes from the TRANSCRIPT half + if (testPassed) return allow(); + + return instruct("You edited source but no test command succeeded this session."); +} +``` + +The two things that made this impossible are exactly the two things the daemon adds: a +**session timeline**, and the **transcript's tool result and exit code**. This policy is the +proof the architecture pays for itself. + +The five existing stop-gates also get materially cheaper. `require-ci-green-before-stop` +currently runs `gh --version`, a branch lookup, a HEAD lookup, `gh run list` with a 15-second +timeout, third-party check runs, and commit statuses — **on every Stop**. Moving the result to +`project:` scope keyed by HEAD sha, with a single-flight guard, means three concurrent agents +in one repo share one call. And `gitBranchCache`, today a `Map` with a near-zero hit rate, +becomes `project:`-scoped with `.git/HEAD` mtime invalidation and approaches 100%. + +--- + +## Findings in the current codebase + +These are present-tense issues that the daemon amplifies. They are worth fixing whether or not +the daemon ships, and several must be fixed *before* it does. + +| # | Finding | +|---|---| +| **F1** | **Running the builtins as root would be root RCE on demand.** `builtin-policies.ts` imports `execSync` and `execFileSync` and has roughly twenty call sites running `git` and `gh` with a `cwd` that `resolve-cwd.ts` takes **verbatim from the client-supplied payload**. A `.git/config` carrying `core.pager`, `core.fsmonitor`, an alias, or `include.path`, plus a chosen `cwd`, executes as root. This is why the pure/impure split is a prerequisite, not a nicety. | +| **F2** | **The activity store is world-readable.** `hook-activity-store.ts` calls `writeFileSync` and `appendFileSync` with no `mode`, yielding 0644 — while `lib/atomic-write.ts` defaults to 0600 and `src/audit/cache.ts` passes `mode: 0o600` explicitly. On a shared box every user can read every other user's project paths and session ids today. `hook-logger.ts` has the same problem. | +| **F3** | **RCE on `git clone`.** A cloned repo containing `.failproofai/policies/*policies.mjs` has that file imported with full privileges on the first hook event — no prompt, no diff, no marker. `isAgentSettingsFile` protects fourteen agent config paths but **not `.failproofai/` itself**, so a prompt-injected agent can also write its own policy file and persist across events. | +| **F4** | **The dashboard is a LAN-reachable, unauthenticated enforcement-disable endpoint.** `scripts/launch.ts` binds `0.0.0.0`, and `app/actions/update-hooks-config.ts` is an unauthenticated Server Action that flips `enabledPolicies`. | +| **F5** | **The config merge has downgrade channels.** `policyParams` resolves project-first-wins, and a project file can set `customPoliciesEnabled: false`. Both are reachable by `git clone`. Under the daemon the rule should invert: org beats user beats project for anything that *loosens*; project may only tighten. | +| **F6** | **The collector's documented install is `curl ... \| sh`, which failproofai's own `block-curl-pipe-sh` builtin denies.** Shipping a root daemon whose install method your own default policy set blocks will not survive a security review. Replace with signed packages and a tarball with a detached signature. | +| **F7** | **The API key is passed on the command line**, which puts it in shell history and `ps` output. Read it from a file or stdin, at mode 0600. | +| **F8** | **The self-hosting claim conflicts with a default vendor endpoint.** `docs/agenteye/security.mdx` states that nothing is sent to a third-party SaaS and data stays in your own cloud account. If the daemon defaults to a hosted endpoint, that is false for every customer who does not self-host — a controller/processor question, not a hygiene one. **Resolve before shipping.** | + +--- + +## Risks that would silently stop enforcement + +This project's characteristic bug is the one where everything looks green and nothing is being +enforced. Each risk below therefore gets a named mechanical guard rather than a review note. + +### Introduced by making the process long-lived + +| # | Risk | +|---|---| +| **R1** | **Custom policies vanish after event #1.** `custom-hooks-loader.ts` imports a *stable* temp path, so the second `import()` is a module-cache hit and the body — which is where `customPolicies.add()` lives — never re-runs. `clearCustomHooks()` has already emptied the registry. Every event still exits 0. **No existing test catches this, because no test fires two events in one process.** Fix: fingerprint-keyed import URLs plus a load cache. | +| **R2** | **Concurrent evaluations unlink each other's temp files.** The loader writes `.__failproofai_tmp__.mjs` next to the user's source and unlinks it. One evaluation unlinks while another imports, the import fails, the error is caught, and the policy **fails open**. Fix: content-addressed temp files outside the user's tree. | +| **R3** | **The policy registry is a `globalThis` singleton cleared per event.** Two concurrent projects with different `enabledPolicies` corrupt each other. Fix: serialize evaluation inside each worker and parallelize by sharding workers per project realm. | +| **R4** | **HOME and env are captured at module load.** This breaks e2e fixture isolation *and* the integration-suite oracle, which exports `FAILPROOFAI_HOOK_LOG_FILE` into the vendor CLI's environment and greps the result. A daemon never sees that export; the file stays empty and every probe flips to failure — or worse, a stale log yields a false pass. | +| **R5** | **Runtime `homedir()` inside policies** backs `block-read-outside-cwd` and `block-rm-rf`. Under a daemon these resolve the *daemon's* home. Identical on a dev box, which is exactly why it would survive review. | +| **R6** | **The `--cli` default is a silent-allow generator.** An unrecognized `--cli` is coerced to `claude`, so a newer client sending `goose` to an older daemon gets a Claude-shaped deny that goose ignores — exit 0, allow. Version skew must fail closed, never fall back. | +| **R7** | **Fail-closed cannot mean exit 2 everywhere.** Exit 2 on stop-class events means "don't finish, retry" and loops forever. Daemon-down must be per-CLI and per-event shaped: **block tool execution, never block turn completion.** | + +### Introduced by the Rust move + +| # | Risk | Guard | +|---|---|---| +| **R8** | **An unresolvable `optionalDependency` is a *successful* npm install.** Green install, working CLI, no hook client, hooks exit 127, silent allow. Highest-probability new failure in the plan. | Four independent closures: expose the client from the platform package's own `bin`; the installer execs the binary before writing any hook; production commands carry a guard with the stop-class exit split; `failproofai doctor`. | +| **R9** | **glibc versus musl, or an arch mismatch** — binary present, fails to exec, exit 126/127. Neither 2 nor 0, so the session runs unenforced and nobody is told. | Build static musl. Verify each artifact by exec'ing it on a matching runner **before** publish. | +| **R10** | **Using Rust `String` instead of `Vec` on the relay.** `String` is UTF-8-validated, so a policy reason containing a lone surrogate gets lossily replaced, corrupting the deny JSON — which is then parsed as garbage and **treated as allow** on five CLIs. | **Invariant W:** stdout and stderr are opaque bytes between worker and client. Typed as bytes end to end, linted, and fuzzed over invalid UTF-8, NUL, CRLF, 1 MB, and empty. | +| **R11** | **A stray `println!`, or a tracing subscriber defaulting to stdout** — one byte, same five CLIs. | `#![deny(clippy::print_stdout, clippy::print_stderr)]` crate-wide, with a single sanctioned output module. | +| **R12** | **Partial publish.** If three of five platform packages publish and the wrapper publishes anyway, two platforms get a green install with no hook client — silent allow, at scale, on the release everyone upgrades to. | A `verify-platform` hard gate between publishing the platform packages and publishing the wrapper. | +| **R13** | **The existing CI version check silently passes when `packages/wrapper/package.json` is absent**, because of a `2>/dev/null \|\| true`. It also never reads the *root* package's `optionalDependencies` and never checks the dependency *name set*, so dropping a triple from the build matrix would ship undetected. | Amend the check to read root's `optionalDependencies` and assert the expected triple names; add a single version-setting script used by both publish steps. | +| **R14** | **Deleting `ensureBundle` along with the rest of `dev-hook.mjs`.** `dist/index.js` is still required for user policies to resolve `import ... from 'failproofai'`. Deleting it fails open all three committed policies in this repo, with a log line nobody reads. | **Move** it into daemon startup and the release health check. Do not delete it. | +| **R15** | **Copilot's `bash` and `powershell` fields receive the same string today**, which works only because the current command has no shell-specific syntax. Adding a POSIX guard to both makes the guard itself the failure on Windows. | The command builder takes a shell parameter. The dogfood test already asserts the correct divergence; the production test must too. | + +### Assets to protect + +- **`EvaluationResult` is already the wire contract.** `policy-evaluator.ts` already returns + `{exitCode, stdout, stderr, decision, reason, policyName}`. **Design gate: if the design + forces edits to `__tests__/hooks/policy-evaluator.test.ts`, the design is wrong.** +- **The ~20 per-CLI assertion helpers in `__tests__/e2e/helpers/hook-runner.ts`** encode + live-verified vendor contracts. **They must change zero lines**; only `runHook()` moves. + Worth a CI gate that fails if they change without a re-verification trailer. +- **`__tests__/hooks/dogfood-configs.test.ts`** — five of its seven invariants survive nearly + verbatim, and two new ones get added. +- **CI is already wired for per-platform binaries.** The version-consistency check already + iterates `packages/*/package.json` and validates `packages/wrapper`'s + `optionalDependencies`. `packages/` does not exist yet — this is a pre-cut hole. + +--- + +## Recommended deviations from the stated decisions + +Three independent design passes converged on the same three points. Each changes a decision +already made, so they are raised explicitly rather than folded in silently. **These are open +for the review, not settled.** + +**D1 — Fail-closed should mean "could not reach the daemon *and* could not evaluate locally", +not "the socket was missing."** +As stated, the first stale socket after a reboot stops every agent on every machine. Keeping +the client thin is still possible: on the failure path it execs today's in-process JavaScript +path, so you pay current latency exactly when things are already broken. Without something +like this, users will set the mode to allow, which is strictly worse than a graceful degrade. + +**D2 — Ship `--user` as the default and the only npm-installable mode; make `--system` +installable only from a signed release artifact.** +`npx -y failproofai` runs as you today. Making a root daemon npm-installable means a +compromised npm release runs as **root on every developer machine in the org** — a far larger +prize, on a security product. This does not weaken the org-enforcement story; it changes the +install channel. + +**D3 — Do not give up `npx -y failproofai`. Under per-platform `optionalDependencies` you do +not have to.** +It is both the distribution mechanism and the zero-friction first run. Require the daemon for +*daemon features* — state, collection, sub-5 ms — never for *enforcement to work at all*. +This also keeps the early stages fully reversible. + +Two smaller notes in the same spirit. **One binary does not mean one address space**: a parser +OOM on untrusted JSON must never deny every tool call, so the parser stays a separate +memory-capped process and collector death maps to `degraded`. And the first-run banner must +not claim that nothing leaves the machine — PostHog fires by default today, so either state +it precisely or turn telemetry off by default. + +--- + +## Staged delivery + +Ordered so that value lands before the Rust risk. Each stage is one PR into `1.0.0`, keeping +all CI jobs green, with a CHANGELOG entry. + +```mermaid +flowchart TB + S0a["0a · hook-command-builder
no behaviour change"] --> S0b["0b · security-hardening
F1-F5"] + S0b --> S1["1 · hot-path-diet
PAYS FOR THE PROJECT"] + S1 --> S2["2 · request-scoped-env
R4, R5"] + S2 --> S3["3 · daemon-proto
types + generated table"] + S3 --> S4["4 · rust-workspace
client is a no-op"] + S4 --> S5["5 · daemon-core
differential test"] + S5 --> S6["6 · policy-realms
R1, R3"] + S6 --> S7["7 · daemon-default
LAST REVERSIBLE STAGE"] + S7 --> S8["8 · stateful-policies
require-tests-before-stop"] + S8 --> S9["9 · shims-to-socket"] + S9 --> S10["10 · spool
posthog sink only"] + S10 --> S11["11 · collector-mvp
local sink, 12 CLIs"] + S11 --> S12["12 · collector-incremental"] + S12 --> S13["13 · timeline-join"] + S13 --> S14["14 · agenteye-sink
POINT OF NO RETURN"] + S14 --> S15["15 · system-mode
root"] + S15 --> S16["16 · collector-cutover"] + + style S1 fill:#1d3b2a,stroke:#4ade80,color:#fff + style S7 fill:#3b3620,stroke:#facc15,color:#fff + style S14 fill:#3b1d1d,stroke:#e4587c,color:#fff + style S15 fill:#3b1d1d,stroke:#e4587c,color:#fff +``` + +| # | Branch | Ships | Why here | +|---|---|---|---| +| **0a** | `hook-command-builder` | Extract the command-string ternary — copy-pasted at nine sites in `integrations.ts`, plus a tenth hidden inside the OpenCode shim template — into one builder. Interpolate the tool maps from `types.ts` into the generated shims, killing three duplicate copies of one map and two of another. | Byte-identical output; the 24 existing literal assertions staying green *is* the proof. Turns a nine-site edit into a one-site edit and permanently closes the duplication class that caused the silent opencode no-op. | +| **0b** | `security-hardening` | F1–F5. Pure/impure builtin split with a mechanical guard — a test asserting the pure module graph never imports `node:child_process`, `node:fs`, `node:net`, or `fetch`. Activity store and hook log to 0600. Dashboard to loopback. `.failproofai/**` into `isAgentSettingsFile`. Project-policy trust-on-first-use. Invert the config merge. | Every one is a present-tense vulnerability that the daemon amplifies. | +| **1** | `hot-path-diet` | Content-addressed module temp files, killing six writes and six unlinks per event. Lazy-import the session libraries so `cli=claude` skips 75 KB. Detach the telemetry flush. **No Rust, no daemon.** | **The stage that pays for the project if everything after slips.** Every existing user gets a faster hook immediately, and it validates the module-identity fix in the short-lived process first. | +| **2** | `request-scoped-env` | Replace module-load `homedir()` and the env latch; make `handleHookEvent` return bytes rather than write them. | R4 and R5. **Prove it with the existing e2e suite while there is still no daemon** — otherwise it silently breaks fixture isolation and the integration-suite oracle three PRs later. | +| **3** | `daemon-proto` | Wire types, the `CollectedEvent` union, `eventId` derivation, schema parity between Rust and TS, and the generated response table. | Locks the contract. Pure types and pure functions; zero runtime change. | +| **4** | `rust-workspace` | Cargo workspace, path-filtered Rust CI job, `packages/` scaffolding, release matrix, launcher with fallback to the JS path. **The Rust client does nothing but exec the JS path.** | Proves cross-compilation, npm `optionalDependencies`, provenance, and launcher fallback while the binary is behaviourally a no-op. If distribution is going to hurt, find out here. | +| **5** | `daemon-core` | Supervisor, IPC, worker pool, and the policy worker wrapping the existing engine. User mode only, behind a flag, default off. | **The differential test lands here and is the most important test in the project.** | +| **6** | `policy-realms` | Fingerprint-keyed module cache, realm-scoped registry, hot reload, one worker per project realm. | R1 and R3. Required before the default flips. Worker-per-realm also fixes the module leak and the hung-policy problem — a Rust `SIGKILL` preempts a busy loop that an in-process `Promise.race` cannot. | +| **7** | `daemon-default` | Client tries the daemon, falls back in-process, and the configured mode governs only if both fail. Single-writer activity store. `health` v1. | **Last fully reversible stage.** | +| **8** | `stateful-policies` | The state API, the rewritten stop-gates, `require-tests-before-stop`, a velocity limit. | First capability only the daemon can deliver. It stops being an optimization and becomes a feature. | +| **9** | `shims-to-socket` | Pi, OpenClaw, and OpenCode move to a zero-dependency client module. | Removes a process spawn per event in three already-long-lived hosts. | +| **10** | `spool` | Durable queue and shipper, **telemetry sink only**. The hook path makes zero network calls. | **Proves the durability machinery against a low-stakes sink before any customer transcript touches it.** | +| **11** | `collector-mvp` | Tailer, parser worker, discovery, cursors, backfill-once, generic parsing with id-based dedupe. **Local sink only** — no network, no key. | All twelve CLIs collected with zero adapter edits and zero privacy exposure. Everything after is a sink change. | +| **12** | `collector-incremental` | Byte-offset JSONL tailing, real WAL SQLite reads, `parseDelta` for the hot three. | Seconds rather than minutes, and CPU from O(all transcripts) to O(delta). | +| **13** | `timeline-join` | Correlation buffer, blocked-outcome inference, `model_response` enrichment. | The unified timeline — the thing neither product could build alone. | +| **14** | `agenteye-sink` | Cloud sink, key handling, consent, redaction. Default off. | **POINT OF NO RETURN** | +| **15** | `system-mode` | Root supervisor, privilege drop with post-drop assertions, service units, org-locked policies, the architecture tests. | Highest blast radius; a bug here is a privilege-escalation bug. **Most of the value lands without it.** | +| **16** | `collector-cutover` | Deprecate the standalone collector for its three CLIs. Both run for one release; id dedupe absorbs the overlap. | | + +### The point of no return is stage 14 + +That is the first release in which a failproofai install can transmit session content. Before +it, local-first is a **property of the artifact**; after it, it is a **configuration**, plus a +wire-compatibility obligation to a server governed outside this repo, and every collector bug +becomes a data-leak bug rather than a crash. + +Two secondary irreversibility points: **stage 4**, where the npm `bin` change routes every +install through the new launcher, and **stage 15**, where mistakes become privilege-escalation +mistakes rather than availability ones. + +--- + +## Branch strategy, and a blocker + +This ships as v1.0.0 on a long-lived integration branch. `1.0.0` is cut from `origin/main`; +every stage above lands as its own PR **targeting `1.0.0`**, and `1.0.0` merges to `main` once +at release. The branch must be kept current with `main` continuously — over sixteen stages it +will otherwise rot, and a stale base makes every subsequent diff noisy. + +> **Blocker — must land before the first stage PR.** `.github/workflows/ci.yml` triggers only +> on `main`, for both `push` and `pull_request`. As written, **every PR into `1.0.0` would run +> zero CI** — no lint, no type check, no tests, no build, no e2e — and would appear green. +> Adding `1.0.0` to both trigger lists is a two-line change, but it must exist before any +> stage PR does, or the whole sequence runs unverified. `integration-suite.yml` and +> `osv-scanner.yml` need the same review. +> +> This design PR deliberately changes **no** workflow file — it is documentation only — so +> the fix belongs to the first implementation PR. + +--- + +## Testing + +The verification strategy is more important than usual here, because the failure mode being +guarded against is silence. + +**Split e2e by transport.** Run the *same* 19 e2e files twice: once embedded (pure bun, no +Rust, the default PR gate) and once through the Rust client and daemon. **The diff between +those two jobs is the contract test**, and it is worth more than any bespoke integration test. + +**Contributors must not need Rust.** The unit suite and the embedded e2e suite both run +Rust-free and together cover every vendor contract; the daemon suite is opt-in. A change under +`src/hooks/` never requires Rust, and a change under `crates/` never requires touching the +unit tests. This matters concretely: vendor-drift fixes are on a daily cron, so anything that +slows them down is a real cost. + +The tests that actually de-risk this, in priority order: + +1. **Differential** — every `(cli, event, verdict)` byte-identical between daemon and + in-process, including trailing bytes. Note the existing runner *trims* stdout, so this + needs a non-trimming variant. +2. **Custom-policy reload across events in one daemon** (R1) — fire the same event twice and + assert both denies; edit the file between events and assert the new rule applies. +3. **Version skew** (R6) — mismatched protocol, and an unknown CLI id. Assert fail-closed with + a named reason, and specifically that the daemon does *not* emit a Claude-shaped verdict + for an unknown CLI. +4. **Daemon-down fail-closed** across all twelve CLIs for both a tool event and a stop event, + asserting exact bytes and that stop-class never denies (R7). +5. **Concurrent multi-project** — two fixtures with disjoint enabled policies, interleaved + (R2, R3). +6. **Per-request env honoring** — two consecutive requests with different log-file settings + write to different files. **This is the test that keeps the integration-suite oracle + alive** (R4). +7. **Relay fuzz** over invalid UTF-8 and friends (R10), and **client stdout purity** under + verbose logging, backtraces, and panics (R11). +8. Worker crash mid-request, stale socket, timeout budget under every vendor timeout. + +**E2E isolation** uses a per-worker daemon with the endpoint derived from the fixture HOME. +That deliberately exercises the top compatibility trap — a daemon serving a project whose HOME +is not the daemon's own — rather than hiding it. Use hash-derived socket names from day one, +since unix socket paths cap at about 104 bytes on macOS and a silent bind failure looks like a +mass regression. + +**Add a daemon-down probe to the integration suite.** Stop the daemon, run one tool call, +assert the CLI reports a block. That converts fail-closed from a claim into a daily live check +across all twelve vendors. Also add an "oracle log exists and is non-empty" precondition: +today a broken oracle and broken enforcement are indistinguishable, since both produce "no +deny found" — the former must be an error, never a failure. + +--- + +## Open questions + +1. **Does the AgentEye ingest endpoint upsert on `event_id`?** A large part of this design's + simplicity — exactly-once, the three-way join, safe double-shipping during cutover, cursors + as an optimization — rests on it. If it is append-only, local dedupe needs an **exact + bounded set, never a bloom filter**: a false positive silently drops a real event, which is + the one failure that cannot be debugged. +2. **F8: the self-hosting claim versus a default vendor endpoint.** Needs a decision from + outside engineering. +3. **`node:sqlite` versus the declared Node floor.** WAL-aware reads need Node 22.5+, and the + current code silently degrades to a whole-file `sql.js` read otherwise. Rust fixes this for + the *collector*, but the dashboard and audit paths still use the TypeScript reader. Either + raise the floor or knowingly accept that four CLIs read stale data in the dashboard while + the collector reads them correctly. +4. **The dashboard leaving the main tarball** breaks the launcher's path resolution. Bare + `failproofai` must either spawn the dashboard package or print an install hint. +5. **Deleting Pi's inline tool maps needs its own test first** — verify the shared + canonicalization covers Pi's path key, or `block-env-files` and `block-secrets-write` + silently no-op on Pi. +6. **OpenClaw's fail-open behaviour is correct for its product** — a multi-channel gateway + that hard-blocks on infrastructure failure is a worse product. Keep it, make it + configurable, and **document the divergence prominently**: a global fail-closed claim that + OpenClaw silently does not honor would itself be a silent-enforcement surprise. +7. **Consent model for multi-user collection.** Can an org enable shipping a user's + transcripts without that user's acknowledgement? The proposed answer is that an org may + mandate capture but may not mandate that the person being captured is unaware — a notice + is unsuppressible, and `failproofai capture --explain` always shows what is read and where + it goes. Worth an explicit decision. +8. **Should a root daemon self-update from the network at all?** The proposed default is no — + defer to the OS package manager, with `update` as check-only unless an org opts in. A + network-triggered root-code-replacement channel on every customer box is strictly more + powerful than remote policy push. From e4af73fe479dba7f2b75774751b08d5cd7207b24 Mon Sep 17 00:00:00 2001 From: Nivedit Jain Date: Mon, 27 Jul 2026 14:59:41 +0530 Subject: [PATCH 2/3] docs(design): dashboard re-architecture in scope; add the update system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review changes to the failproofaid design doc. 1. The dashboard's re-architecture moves from out of scope to in scope. It is not just repackaged: today two page files import the twelve parser modules directly and re-parse transcripts per request, two Server Actions call writeHooksConfig with no gate, there is no middleware.ts in the repo at all, and app/api/auth is an identity cache for the audit feature rather than an access gate. That shape is redundant in single-user mode and a confused deputy under a root, multi-user daemon. Reads and writes both move to the daemon, the dashboard acquires a uid-bound identity, and it goes live instead of polled. Lands as stages 4b and 13b. 2. Adds "Staying current" — the update channel and vendor drift detection. There is no update path in the product today: no version check, no update notifier, no update command. Project scope drifts forward through npx; user scope bakes an absolute binary path into the vendor settings file at install and freezes. So a vendor schema change surfaces as "it quietly stopped catching things." The daemon auto-updates on the stable channel with a health-gated swap and automatic rollback, gains a failproofai update command, and detects drift three ways — unrecognized transcript shapes, vendor config that no longer matches what we wrote, and silence (a session file growing while zero hook events arrive). Lands as stages 7b and 11b. Also: F9 (nothing knows it is out of date), R16-R18, tests 9-11, and open question 8 rewritten — it previously proposed no self-update by default, which this reverses. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ex49L3LF4YmcWicwwZWZXg --- .../2026-07-27-failproofaid-daemon-design.md | 280 ++++++++++++++++-- 1 file changed, 261 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md b/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md index 0fc2ac62..d9137302 100644 --- a/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md +++ b/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md @@ -11,7 +11,7 @@ Turn failproofai from a stateless, cold-start-per-event hook into a long-lived d also absorbs `agenteye-collector`, so that one process per machine both **enforces** policy and **collects** agent sessions for every supported CLI. -Three things this unlocks that are impossible today: +Four things this unlocks that are impossible today: 1. **Policies that remember.** Cross-event session state makes a whole class of policy writable for the first time — starting with `require-tests-before-stop`. @@ -20,6 +20,11 @@ Three things this unlocks that are impossible today: already written and already live-verified. 3. **A joined timeline.** Hook events carry the *policy verdict*; transcripts carry the *model output and tool results*. Neither product can compute the join alone. +4. **A tool that keeps itself current.** Vendors change their transcript and hook schemas + without notice and things quietly stop working. Today nothing in this repo checks whether + the installed version is still the right one — there is no update command, no version + check, and no drift signal of any kind. A long-lived process can both update itself on the + stable channel and, more valuably, *notice* that a vendor has moved. This document is for review and iteration. **No implementation is proposed for this PR.** @@ -32,6 +37,10 @@ This document is for review and iteration. **No implementation is proposed for t - The wire contract, and how the exit-code and byte-exact-stdout guarantees survive it. - Absorbing the collector: transcript tailing, cursors, the durable spool, cloud shipping. - Correlating live hook events with tailed transcript events into one session timeline. +- **The dashboard's re-architecture** — where it gets its data, what authority it holds, and + how it is packaged once it is no longer part of the main tarball. +- **Staying current** — the update channel, `failproofai update`, and runtime detection of + vendor schema drift. - npm distribution of per-platform Rust binaries, and the CI/publish changes it needs. - A staged delivery sequence, with the point of no return named. - Security findings in the *current* codebase that this architecture would amplify. @@ -40,8 +49,8 @@ This document is for review and iteration. **No implementation is proposed for t - Windows. Linux and macOS first; Windows is sketched only where it changes a decision (named-pipe ACLs, the Copilot dual-shell command strings). -- The dashboard's own re-architecture. It becomes separately installable; its internals do - not change here. +- The dashboard's *visual* design and feature set. Its data path and its authority model + change here; what it chooses to render does not. - The AgentEye server, its API surface, and its data model. One dependency on it is called out as an open question. - Evaluations, audits, alerts, incidents — the analysis half of Observability. @@ -475,6 +484,190 @@ becomes `project:`-scoped with `.git/HEAD` mtime invalidation and approaches 100 --- +## The dashboard becomes a client of the daemon + +The dashboard cannot stay as it is. Today it is a second, independent reader of the same +on-disk state and a second, unauthenticated writer of the same config. That is merely +redundant in single-user mode. Under a root, multi-user daemon it is a confused deputy +sitting directly on top of the authority boundary the rest of this design is built around. + +What it is today, verified: + +- Two page files — `app/project/[name]/page.tsx` and its session page — import the twelve + `lib/-projects` and `lib/-sessions` modules directly and re-parse transcripts + from disk on every request. +- `app/actions/update-hooks-config.ts` and `app/actions/update-policy-params.ts` are Server + Actions calling `writeHooksConfig` directly. +- There is **no `middleware.ts` in the repo at all**, and `app/api/auth/` is an identity + *cache* for the audit feature — it reads `~/.failproofai/auth.json` to display who is signed + in. Nothing under `app/actions/` or `app/project/` consults it. It is not an access gate. +- `scripts/launch.ts` sets `HOSTNAME` to `0.0.0.0`. + +```mermaid +flowchart LR + subgraph now["Today — two readers, two writers"] + D1["dashboard
(Next server)"] -->|"re-parse whole files
per request"| FS1[("transcripts on disk")] + D1 -->|"writeHooksConfig
(no identity)"| CFG1[("hooks config")] + HK1["hook process"] -->|"reads"| CFG1 + end + + subgraph after["After — one reader, one writer"] + D2["dashboard
(daemon client)"] -->|"query + live push"| DA["failproofaid"] + D2 -->|"config RPC
(uid-bound)"| DA + DA -->|"incremental tail"| FS2[("transcripts on disk")] + DA --> CFG2[("hooks config
daemon-owned")] + end +``` + +Four changes, in dependency order. + +**1. Reads move to the daemon.** The daemon already tails and normalizes exactly these files, +incrementally and with real WAL visibility. Two independent readers of one format is the +duplication class this project keeps getting burned by. *This resolves open question 3 on its +own*: once the dashboard reads through the daemon, `node:sqlite` versus `sql.js` stops being a +dashboard problem, because `rusqlite` becomes the only SQLite reader in the product. + +**2. Writes move to the daemon, and acquire an identity.** Config mutation becomes an RPC, so +the precedence rules established in stage 0b — org beats user beats project, and project may +only tighten — have exactly one enforcement point. Today the Server Action is a second writer +that bypasses every rule the daemon would enforce, including org-locked policy under +`--system`. + +**3. The dashboard gets an identity, because without one it is a confused deputy.** The daemon +derives uid from `SO_PEERCRED` on the socket; the dashboard currently has no way to +participate in that model. A Next server that connects as whoever started it, and answers to +whoever can reach its port, holds strictly more authority than the person looking at the +screen. Minimum: loopback bind, connect over the viewing user's own socket, and treat "view my +sessions" and "change what is enforced" as separate capabilities rather than one page. + +**4. Live instead of polled.** Today freshness is bounded by a page load re-reading the +filesystem. A daemon can push. This is the live-UI motivation from the original brief, and it +is nearly free once change 1 lands. + +**Packaging.** `.next/standalone/` leaves the main tarball, so the artifact on the hot path +stays small and `failproofai dashboard` resolves `@failproofai/dashboard`, installing it on +first use and printing a hint when it is absent. The daemon must run perfectly with the +dashboard absent — that direction is already decided. The new constraint is the inverse: **the +dashboard requires the daemon.** It is no longer a standalone disk reader, and it should say +so plainly rather than silently degrading to a stale view. + +--- + +## Staying current: updates, and vendor schema drift + +Two problems that get conflated. **Updates** keep the installed version current with what we +have published. **Drift detection** is about the window between a vendor changing something +and anyone knowing. Auto-update only helps once a fix exists upstream; drift detection is what +shortens the part before that. + +### The present state: there is no update path at all + +Verified across the repo: no version check, no update notifier, no `update` command, nothing +that compares the installed version against the registry. The two install shapes rot +differently: + +- **Project scope** writes `npx -y failproofai --hook ...`, which re-resolves against the + registry, so it drifts forward on its own — at the price of a registry round trip on the + hot path. +- **User scope** bakes the result of `which failproofai` into the vendor's settings file at + install time. The path keeps working across an upgrade, but **nothing performs, prompts, or + even mentions the upgrade.** A machine set up in March is running March's parsers today. + The same baked path is why switching Node versions under nvm can leave the hook pointing at + a binary that no longer exists — exit 127, silent allow. + +So the slower install path is the one that stays fresh, and the recommended one is the one +that freezes. The daemon inverts that: **resolution happens once at daemon start, and +freshness becomes the daemon's job rather than the hot path's.** + +### Updating + +`failproofaid-updater` is short-lived, talks to one host, and only ever writes to a staging +directory. The supervisor itself never opens a network socket. + +```mermaid +flowchart TB + CH["check channel
stable / beta / pinned"] --> MAN{"signed manifest
exists for this version?"} + MAN -->|"no"| NOOP["stay put
record 'checked, current'"] + MAN -->|"yes"| SIG{"signature +
complete platform set?"} + SIG -->|"fail"| REJ["refuse, report,
do not retry blindly"] + SIG -->|"ok"| STG["stage into
staging dir"] + STG --> HC{"exec staged binary:
--version, then a canned
deny vector from the
generated response table"} + HC -->|"fail"| REJ + HC -->|"pass"| DR["drain: finish in-flight,
hand over listener"] + DR --> SWAP["atomic swap
keep N-1"] + SWAP --> PHC{"post-swap health"} + PHC -->|"fail"| RB["roll back to N-1
report as an event"] + PHC -->|"pass"| DONE["serving new version"] + + style REJ fill:#3b1d1d,stroke:#e4587c,color:#fff + style RB fill:#3b3620,stroke:#facc15,color:#fff + style DONE fill:#1d3b2a,stroke:#4ade80,color:#fff +``` + +- **Channels**: `stable` (default, auto-apply), `beta`, `pinned`. Org policy may pin or + disable entirely — "never move without us saying so" is a real enterprise requirement and + must be expressible. +- **A release becomes visible to updaters only after every platform artifact is verified.** + The manifest is published by the same job that passes the `verify-platform` gate from R12. + Without that coupling, auto-update converts a partial publish from *"some new installs are + broken"* into *"the fleet is broken within one check interval."* This is the single most + important constraint in this section. +- **Health-gated swap, with automatic rollback.** Verify the signature, stage, exec the staged + binary, run it against a canned deny vector from the generated response table, and only then + swap atomically. Keep N-1. If the post-swap health check fails, roll back and keep serving + from the old version. **An update must never be able to leave a machine unenforced** — that + is this project's characteristic bug, arriving by automation and at fleet scale. +- **Drain, do not drop.** In-flight requests complete and the listener is handed over. With + fail-closed as the default, a refused connection during the swap does not lose a metric — it + blocks the user's agent. The client should also retry once on a refused connection before + applying the configured failure mode. +- **Package-managed installs are notify-only.** If apt, brew, or npm owns the install root, + self-replacement breaks the next upgrade through that manager. Detect the install method and + self-replace only where we own the tree. +- **`failproofai update`** — bare form applies now; `--check` reports and sets an exit code; + `--channel` switches; `--rollback` returns to N-1. `failproofai health` reports current + version, channel, last successful check, and any pending version. **"Never checked + successfully" must be a visible state**, not an empty field — a machine that has silently + failed every check for two months looks identical to a current one otherwise. +- **Revocation, bounded.** A manifest may mark a version bad and move machines off it. Bound + what that can do: it may move a machine forward or to N-1, and it may never disable + enforcement or change what is enforced. It is a code channel, not a policy channel. + +### Noticing drift + +The complaint this is meant to fix — a vendor changes a schema and things quietly stop +working — is really about the lag before anyone knows. Today the only detector is a daily cron +running the integration suite against twelve vendors: our machines, our probes, our cadence. +A long-lived process on the affected machine can do better, in increasing order of value: + +- **Parse drift.** The tailer hits a record whose shape no adapter recognizes. Count it per + CLI and vendor version, degrade health, and name it in `failproofai health` rather than + skipping the record silently, which is what the parsers do today. +- **Config drift.** Re-read the twelve vendors' settings files periodically and compare them + against what we wrote. A renamed hook key, a moved settings path, or a file that no longer + contains our entry is detectable **without waiting for an event that never fires** — and + self-healing it is exactly the careless-human threat model already agreed. +- **Silence.** We know the CLI is installed, we can watch its session file growing, and + **zero hook events arrived for that session.** That is "enforcement is off and nobody + noticed", stated directly. It is structurally unobservable in a per-event process, because + the process that would notice is the one that never ran. This is the strongest argument for + the daemon that has nothing to do with latency or state. + +With opt-in telemetry these signals mean we learn a vendor moved within hours of the first +affected user rather than at the next cron. Without telemetry the user still sees a degraded +health line locally, which is more than they get today. + +**A fork worth deciding explicitly.** Much of what rots is data, not logic: hook config key +names, event name spellings, path layouts, which vendor versions map to which schema. Shipping +those as a signed *data* manifest on a faster cadence than the binary would cut fix latency +substantially. But a manifest that a root daemon fetches and acts on is one design slip away +from being a remote code channel. If we do it: strictly declarative, schema-validated before +use, no ability to add or disable a policy, and it may only affect **parsing and detection — +never a verdict**. Recommendation: yes for the collector and drift detection, never for the +enforcement path. + +--- + ## Findings in the current codebase These are present-tense issues that the daemon amplifies. They are worth fixing whether or not @@ -485,11 +678,12 @@ the daemon ships, and several must be fixed *before* it does. | **F1** | **Running the builtins as root would be root RCE on demand.** `builtin-policies.ts` imports `execSync` and `execFileSync` and has roughly twenty call sites running `git` and `gh` with a `cwd` that `resolve-cwd.ts` takes **verbatim from the client-supplied payload**. A `.git/config` carrying `core.pager`, `core.fsmonitor`, an alias, or `include.path`, plus a chosen `cwd`, executes as root. This is why the pure/impure split is a prerequisite, not a nicety. | | **F2** | **The activity store is world-readable.** `hook-activity-store.ts` calls `writeFileSync` and `appendFileSync` with no `mode`, yielding 0644 — while `lib/atomic-write.ts` defaults to 0600 and `src/audit/cache.ts` passes `mode: 0o600` explicitly. On a shared box every user can read every other user's project paths and session ids today. `hook-logger.ts` has the same problem. | | **F3** | **RCE on `git clone`.** A cloned repo containing `.failproofai/policies/*policies.mjs` has that file imported with full privileges on the first hook event — no prompt, no diff, no marker. `isAgentSettingsFile` protects fourteen agent config paths but **not `.failproofai/` itself**, so a prompt-injected agent can also write its own policy file and persist across events. | -| **F4** | **The dashboard is a LAN-reachable, unauthenticated enforcement-disable endpoint.** `scripts/launch.ts` binds `0.0.0.0`, and `app/actions/update-hooks-config.ts` is an unauthenticated Server Action that flips `enabledPolicies`. | +| **F4** | **The dashboard is a LAN-reachable, unauthenticated enforcement-disable endpoint.** `scripts/launch.ts` binds `0.0.0.0`, and `app/actions/update-hooks-config.ts` is an unauthenticated Server Action that flips `enabledPolicies`. There is no `middleware.ts` in the repo to gate it. The bind is a one-line fix in stage 0b; that the dashboard has **no identity to check in the first place** is the architectural half, and is why its re-architecture is in scope. | | **F5** | **The config merge has downgrade channels.** `policyParams` resolves project-first-wins, and a project file can set `customPoliciesEnabled: false`. Both are reachable by `git clone`. Under the daemon the rule should invert: org beats user beats project for anything that *loosens*; project may only tighten. | | **F6** | **The collector's documented install is `curl ... \| sh`, which failproofai's own `block-curl-pipe-sh` builtin denies.** Shipping a root daemon whose install method your own default policy set blocks will not survive a security review. Replace with signed packages and a tarball with a detached signature. | | **F7** | **The API key is passed on the command line**, which puts it in shell history and `ps` output. Read it from a file or stdin, at mode 0600. | | **F8** | **The self-hosting claim conflicts with a default vendor endpoint.** `docs/agenteye/security.mdx` states that nothing is sent to a third-party SaaS and data stays in your own cloud account. If the daemon defaults to a hosted endpoint, that is false for every customer who does not self-host — a controller/processor question, not a hygiene one. **Resolve before shipping.** | +| **F9** | **Nothing in the product knows whether it is out of date.** There is no version check, no update notifier, and no `update` command anywhere in the repo. Project scope drifts forward through `npx`; user scope bakes an absolute binary path into the vendor's settings file at install time and then stays where it is until a human upgrades it manually. A vendor schema change therefore surfaces as *"it quietly stopped catching things"*, on a machine nobody is watching. This is the finding the update channel and drift detection exist to close. | --- @@ -523,6 +717,14 @@ enforced. Each risk below therefore gets a named mechanical guard rather than a | **R14** | **Deleting `ensureBundle` along with the rest of `dev-hook.mjs`.** `dist/index.js` is still required for user policies to resolve `import ... from 'failproofai'`. Deleting it fails open all three committed policies in this repo, with a log line nobody reads. | **Move** it into daemon startup and the release health check. Do not delete it. | | **R15** | **Copilot's `bash` and `powershell` fields receive the same string today**, which works only because the current command has no shell-specific syntax. Adding a POSIX guard to both makes the guard itself the failure on Windows. | The command builder takes a shell parameter. The dogfood test already asserts the correct divergence; the production test must too. | +### Introduced by auto-update and the dashboard split + +| # | Risk | Guard | +|---|---|---| +| **R16** | **Auto-update turns a bad release into a fleet event.** R12's partial publish breaks *new installs* today. With auto-update it breaks *every machine on the channel* within one check interval, and the failure mode is silent allow. Automation does not create this bug, it multiplies its blast radius. | Publish the update manifest only from the job that passed `verify-platform`. Roll out by cohort rather than to everyone at once. Health-gate the swap with automatic rollback, and treat "rolled back" as a reportable event, not a silent retry. | +| **R17** | **Swapping the binary under in-flight requests.** With fail-closed as the default, a refused connection during the swap blocks the user's agent rather than losing a metric — an update that stutters is indistinguishable from an outage. | Drain and hand over the listener; the client retries once on a refused connection before applying the configured failure mode. A dedicated test asserts zero failed requests across a swap under load. | +| **R18** | **The dashboard stays a second writer.** If reads move to the daemon but a Server Action still calls `writeHooksConfig` directly, the daemon's precedence rules and org locks are bypassed by a web form — and under `--system`, by an unauthenticated one. Partial migration is the likely outcome here, because reads are the fun part. | Fence it mechanically: an eslint `no-restricted-imports` rule forbidding anything under `app/` from importing the config writer or the parser modules at all. The rule is the test, and it fails the moment someone reintroduces a direct import. | + ### Assets to protect - **`EvaluationResult` is already the wire contract.** `policy-evaluator.ts` already returns @@ -585,25 +787,34 @@ flowchart TB S1 --> S2["2 · request-scoped-env
R4, R5"] S2 --> S3["3 · daemon-proto
types + generated table"] S3 --> S4["4 · rust-workspace
client is a no-op"] - S4 --> S5["5 · daemon-core
differential test"] + S4 --> S4b["4b · dashboard-package
leaves the main tarball"] + S4b --> S5["5 · daemon-core
differential test"] S5 --> S6["6 · policy-realms
R1, R3"] S6 --> S7["7 · daemon-default
LAST REVERSIBLE STAGE"] - S7 --> S8["8 · stateful-policies
require-tests-before-stop"] + S7 --> S7b["7b · self-update
MAKES LATER STAGES
RECOVERABLE"] + S7b --> S8["8 · stateful-policies
require-tests-before-stop"] S8 --> S9["9 · shims-to-socket"] S9 --> S10["10 · spool
posthog sink only"] S10 --> S11["11 · collector-mvp
local sink, 12 CLIs"] - S11 --> S12["12 · collector-incremental"] + S11 --> S11b["11b · drift-detection
parse / config / silence"] + S11b --> S12["12 · collector-incremental"] S12 --> S13["13 · timeline-join"] - S13 --> S14["14 · agenteye-sink
POINT OF NO RETURN"] + S13 --> S13b["13b · dashboard-on-daemon
one reader, one writer"] + S13b --> S14["14 · agenteye-sink
POINT OF NO RETURN"] S14 --> S15["15 · system-mode
root"] S15 --> S16["16 · collector-cutover"] style S1 fill:#1d3b2a,stroke:#4ade80,color:#fff style S7 fill:#3b3620,stroke:#facc15,color:#fff + style S7b fill:#1d3b2a,stroke:#4ade80,color:#fff style S14 fill:#3b1d1d,stroke:#e4587c,color:#fff style S15 fill:#3b1d1d,stroke:#e4587c,color:#fff ``` +The four lettered sub-stages are new since the first draft: they carry the dashboard +re-architecture and the update system. They are lettered rather than renumbered so that the +"point of no return is stage 14" landmark keeps its name. + | # | Branch | Ships | Why here | |---|---|---|---| | **0a** | `hook-command-builder` | Extract the command-string ternary — copy-pasted at nine sites in `integrations.ts`, plus a tenth hidden inside the OpenCode shim template — into one builder. Interpolate the tool maps from `types.ts` into the generated shims, killing three duplicate copies of one map and two of another. | Byte-identical output; the 24 existing literal assertions staying green *is* the proof. Turns a nine-site edit into a one-site edit and permanently closes the duplication class that caused the silent opencode no-op. | @@ -612,15 +823,19 @@ flowchart TB | **2** | `request-scoped-env` | Replace module-load `homedir()` and the env latch; make `handleHookEvent` return bytes rather than write them. | R4 and R5. **Prove it with the existing e2e suite while there is still no daemon** — otherwise it silently breaks fixture isolation and the integration-suite oracle three PRs later. | | **3** | `daemon-proto` | Wire types, the `CollectedEvent` union, `eventId` derivation, schema parity between Rust and TS, and the generated response table. | Locks the contract. Pure types and pure functions; zero runtime change. | | **4** | `rust-workspace` | Cargo workspace, path-filtered Rust CI job, `packages/` scaffolding, release matrix, launcher with fallback to the JS path. **The Rust client does nothing but exec the JS path.** | Proves cross-compilation, npm `optionalDependencies`, provenance, and launcher fallback while the binary is behaviourally a no-op. If distribution is going to hurt, find out here. | +| **4b** | `dashboard-package` | Move `.next/standalone/` out of the main tarball into `@failproofai/dashboard`. `failproofai dashboard` resolves it, installs on first use, and prints a hint when it is absent. | Rides with the packaging work in stage 4 rather than repeating it a release later. Shrinks the artifact that project scope pulls through `npx` on the hot path, and closes the launcher path-resolution question. | | **5** | `daemon-core` | Supervisor, IPC, worker pool, and the policy worker wrapping the existing engine. User mode only, behind a flag, default off. | **The differential test lands here and is the most important test in the project.** | | **6** | `policy-realms` | Fingerprint-keyed module cache, realm-scoped registry, hot reload, one worker per project realm. | R1 and R3. Required before the default flips. Worker-per-realm also fixes the module leak and the hung-policy problem — a Rust `SIGKILL` preempts a busy loop that an in-process `Promise.race` cannot. | | **7** | `daemon-default` | Client tries the daemon, falls back in-process, and the configured mode governs only if both fail. Single-writer activity store. `health` v1. | **Last fully reversible stage.** | +| **7b** | `self-update` | The updater, channels, the signed manifest keyed to the `verify-platform` gate, health-gated swap with automatic rollback, drain-and-handover, `failproofai update`, and version/channel/last-check in `health`. | **A fleet you cannot move is a fleet you cannot fix.** This must exist before the installed base is large and well before stage 14, because after that a bad release is a data-leak release. It lands immediately after the last reversible stage on purpose: it is the mechanism that makes everything downstream recoverable. | | **8** | `stateful-policies` | The state API, the rewritten stop-gates, `require-tests-before-stop`, a velocity limit. | First capability only the daemon can deliver. It stops being an optimization and becomes a feature. | | **9** | `shims-to-socket` | Pi, OpenClaw, and OpenCode move to a zero-dependency client module. | Removes a process spawn per event in three already-long-lived hosts. | | **10** | `spool` | Durable queue and shipper, **telemetry sink only**. The hook path makes zero network calls. | **Proves the durability machinery against a low-stakes sink before any customer transcript touches it.** | | **11** | `collector-mvp` | Tailer, parser worker, discovery, cursors, backfill-once, generic parsing with id-based dedupe. **Local sink only** — no network, no key. | All twelve CLIs collected with zero adapter edits and zero privacy exposure. Everything after is a sink change. | +| **11b** | `drift-detection` | Parse drift counted per CLI and vendor version instead of skipped silently; periodic re-read of the twelve vendors' settings files with self-heal; the silence signal — a growing session file with zero hook events. All surfaced in `health`, and in telemetry when it is enabled. | Needs the tailer from 11 and nothing later. Closes the other half of F9: the update channel fixes *"we shipped a fix and you did not get it"*, this fixes *"nobody knew a fix was needed."* The silence signal in particular is the runtime version of this project's characteristic bug class. | | **12** | `collector-incremental` | Byte-offset JSONL tailing, real WAL SQLite reads, `parseDelta` for the hot three. | Seconds rather than minutes, and CPU from O(all transcripts) to O(delta). | | **13** | `timeline-join` | Correlation buffer, blocked-outcome inference, `model_response` enrichment. | The unified timeline — the thing neither product could build alone. | +| **13b** | `dashboard-on-daemon` | Dashboard reads move to a daemon timeline query; config mutation becomes a uid-bound RPC; live push replaces per-request filesystem reads; the eslint fence around `app/` lands. | Needs the timeline from 13 to have something to read. **Resolves open question 3** by making `rusqlite` the only SQLite reader in the product, and closes R18 mechanically rather than by review. | | **14** | `agenteye-sink` | Cloud sink, key handling, consent, redaction. Default off. | **POINT OF NO RETURN** | | **15** | `system-mode` | Root supervisor, privilege drop with post-drop assertions, service units, org-locked policies, the architecture tests. | Highest blast radius; a bug here is a privilege-escalation bug. **Most of the value lands without it.** | | **16** | `collector-cutover` | Deprecate the standalone collector for its three CLIs. Both run for one release; id dedupe absorbs the overlap. | | @@ -692,6 +907,17 @@ The tests that actually de-risk this, in priority order: 7. **Relay fuzz** over invalid UTF-8 and friends (R10), and **client stdout purity** under verbose logging, backtraces, and panics (R11). 8. Worker crash mid-request, stale socket, timeout budget under every vendor timeout. +9. **Update safety** (R16, R17) — a staged version that fails its health check is rolled back + and the machine keeps enforcing throughout; a swap under concurrent load loses zero + requests; a manifest missing any platform is refused; a package-managed install refuses to + self-replace. The rollback test matters most: it is the one that proves an update cannot + leave a machine unenforced. +10. **Drift detection** — a transcript record of unrecognized shape degrades health instead of + being skipped; removing our entry from a vendor settings file is reported and self-healed; + a session file that grows while producing zero hook events raises the silence signal. +11. **Dashboard fence** (R18) — the eslint rule fails on a reintroduced direct import of the + config writer or the parser modules, and a config change made through the dashboard is + visible to the daemon without a restart. **E2E isolation** uses a per-worker daemon with the endpoint derived from the fixture HOME. That deliberately exercises the top compatibility trap — a daemon serving a project whose HOME @@ -716,13 +942,19 @@ deny found" — the former must be an error, never a failure. the one failure that cannot be debugged. 2. **F8: the self-hosting claim versus a default vendor endpoint.** Needs a decision from outside engineering. -3. **`node:sqlite` versus the declared Node floor.** WAL-aware reads need Node 22.5+, and the - current code silently degrades to a whole-file `sql.js` read otherwise. Rust fixes this for - the *collector*, but the dashboard and audit paths still use the TypeScript reader. Either - raise the floor or knowingly accept that four CLIs read stale data in the dashboard while - the collector reads them correctly. -4. **The dashboard leaving the main tarball** breaks the launcher's path resolution. Bare - `failproofai` must either spawn the dashboard package or print an install hint. +3. **`node:sqlite` versus the declared Node floor** — now narrower than it was. WAL-aware + reads need Node 22.5+, and the current code silently degrades to a whole-file `sql.js` read + otherwise. Stage 13b removes the *dashboard* from this problem by making `rusqlite` the only + SQLite reader. What remains is whether the TypeScript reader survives at all afterwards, or + whether the audit path follows the dashboard onto the daemon. Deciding "it survives" means + knowingly keeping two SQLite readers with different visibility semantics, which is the + duplication class this design keeps closing elsewhere. +4. **What is the dashboard's multi-user model under `--system`?** One dashboard per user, on + that user's own socket, is the simple answer and the one this document assumes. The + alternative — one dashboard for the machine or the org, authenticating users itself — is a + real product request (a lead wanting a single view) and a much larger security surface, + because it means a service that legitimately reads several users' sessions. Decide before + 13b rather than during it. 5. **Deleting Pi's inline tool maps needs its own test first** — verify the shared canonicalization covers Pi's path key, or `block-env-files` and `block-secrets-write` silently no-op on Pi. @@ -735,7 +967,17 @@ deny found" — the former must be an error, never a failure. mandate capture but may not mandate that the person being captured is unaware — a notice is unsuppressible, and `failproofai capture --explain` always shows what is read and where it goes. Worth an explicit decision. -8. **Should a root daemon self-update from the network at all?** The proposed default is no — - defer to the OS package manager, with `update` as check-only unless an org opts in. A - network-triggered root-code-replacement channel on every customer box is strictly more - powerful than remote policy push. +8. **Auto-update is now a decision rather than a question, and it reverses this document's + first draft** — which proposed deferring to the OS package manager, with `update` as + check-only. The daemon updates itself on the stable channel by default. Under `--user` the + blast radius is the user's own account and this is uncontroversial. Under `--system` it is a + network-triggered root-code-replacement channel on every machine in the org, which is + strictly more powerful than remote policy push — so two things still need sign-off: + **(a)** the signing key and its custody, since that key now authorizes root code on every + customer machine; **(b)** whether an org can be *prevented* from pinning. The answer to (b) + should be no. Note that if D2 is accepted, the install channel and the update channel share + one trust root, which is the only coherent version of this. +9. **The signed data manifest for vendor schemas** — worth building, and where its line sits. + See the fork at the end of "Noticing drift". The recommendation is declarative only, + parsing and detection only, never the verdict path. This needs an explicit yes or no before + 11b, because adding it later means retrofitting a trust boundary rather than designing one. From c784704d4b445426fa2e595c8aa79f0d782ed65a Mon Sep 17 00:00:00 2001 From: Nivedit Jain Date: Tue, 28 Jul 2026 11:47:11 +0530 Subject: [PATCH 3/3] docs(design): full build, CI, and release migration in scope Adds "The build, CI, and release migration" as a first-class section: an inventory of all seven workflows and their triggers, six properties of the current release that do not survive the daemon move, the target publish DAG with its gates, a per-workflow migration table, branch protection and required-check changes, and tests for the pipeline. The six properties, all verified against the repo: - publish.yml has no build step; the tarball is a `prepare` side effect - publishing is not gated on CI at all - a failed alias publish exits 0, so it is a green release - the release tag is stamped in one place for a topology that will need it in seven - `prepare` on a git install would start requiring a Rust toolchain - the tarball inventory needs an explicit decision Also: - R19-R24, a fourth risk subsection for the release-pipeline migration - stage 0 `ci-foundation` and stage 4c `release-pipeline`, in both the flowchart and the stage table - the blocker section widened from ci.yml to the full trigger inventory (osv-scanner has the same problem; integration-suite, bump-platform-submodule and translate-docs each need a decision) - open question 9: does anything publish from 1.0.0 before the merge Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017716AAktWW6GeSrd5DoMWE --- .../2026-07-27-failproofaid-daemon-design.md | 256 ++++++++++++++++-- 1 file changed, 239 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md b/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md index d9137302..f4bc875c 100644 --- a/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md +++ b/docs/superpowers/specs/2026-07-27-failproofaid-daemon-design.md @@ -41,7 +41,10 @@ This document is for review and iteration. **No implementation is proposed for t how it is packaged once it is no longer part of the main tarball. - **Staying current** — the update channel, `failproofai update`, and runtime detection of vendor schema drift. -- npm distribution of per-platform Rust binaries, and the CI/publish changes it needs. +- **The full build, CI, and release migration** — all seven workflows, npm distribution of the + per-platform Rust binaries, the publish DAG and its gates, versioning across npm and Cargo, + signing and provenance, branch protection and required checks, and Dependabot coverage — + staged alongside the work rather than retrofitted at cutover. - A staged delivery sequence, with the point of no return named. - Security findings in the *current* codebase that this architecture would amplify. @@ -55,6 +58,9 @@ This document is for review and iteration. **No implementation is proposed for t out as an open question. - Evaluations, audits, alerts, incidents — the analysis half of Observability. - Any change to what the twelve vendor CLIs themselves do. +- The org's GitHub ruleset and required-check configuration *itself*. Those are repo settings, + not files this repo can commit. Every place one must change is named below, but landing it is + a human action outside the PR sequence. --- @@ -608,7 +614,9 @@ flowchart TB disable entirely — "never move without us saying so" is a real enterprise requirement and must be expressible. - **A release becomes visible to updaters only after every platform artifact is verified.** - The manifest is published by the same job that passes the `verify-platform` gate from R12. + The manifest is published by the same job that passes the `verify-platform` gate from R12 — + see the publish DAG in the next section, and note that the gate does not exist today, which is + why stage 4c must land before 7b. Without that coupling, auto-update converts a partial publish from *"some new installs are broken"* into *"the fleet is broken within one check interval."* This is the single most important constraint in this section. @@ -668,6 +676,174 @@ enforcement path. --- +## The build, CI, and release migration + +Today the repo publishes **one** pure-JavaScript npm package from **one** workflow, and the +artifact users receive is built as a lifecycle side effect that nothing audits. The target +publishes a wrapper, five platform binary packages, a dashboard package, fourteen alias +packages, and a signed update manifest — with a hard gate between the platform packages and the +wrapper, and a second between the wrapper and the manifest. + +That is a **different** pipeline, not a larger one. Stage 4 is too late to meet it for the first +time, and the update channel in stage 7b turns every property of it into a fleet-wide property. + +### What exists today, verified + +| Workflow | Trigger | What it does | Fires on `1.0.0` today | +|---|---|---|---| +| `ci.yml` | push + PR on **`main`** | 5 jobs: `quality` (version-consistency, lint, `tsc`), `test` (3 env-config matrix), `build`, `docs` (`mintlify validate` + MDX parse), `test-e2e` | **no** | +| `publish.yml` | `release: [published]`, dispatch | version from the tag, `npm publish --provenance`, 14 alias packages, then bumps the next dev version on `main` using a version-bot App token that bypasses the branch ruleset | n/a | +| `integration-suite.yml` | daily 06:17 UTC, dispatch | 12 real vendor CLIs in Docker against this repo's HEAD; `stable` leg gates, `beta` leg advisory | n/a (cron) | +| `osv-scanner.yml` | push + PR on **`main`**, weekly | dependency advisories | **no** | +| `build-image.yml` | push on `main` (path-filtered), daily 08:00 | hook-sync container to GHCR, refreshing `claude-code@latest` + `failproofai@latest` | n/a | +| `bump-platform-submodule.yml` | push on `main`, dispatch | submodule bump PRs | n/a | +| `translate-docs.yml` | daily 05:35 UTC | 14-language doc translation, content-hash cached | n/a | +| `.github/dependabot.yml` | weekly | `bun` and `github-actions` ecosystems **only** | — | + +CLAUDE.md documents *four* CI jobs; there are **five** — `docs` was added later and never made it +back into the doc. Minor on its own, but the CI job inventory is exactly the thing that has to be +right before it is forked into two transports and a Rust matrix. + +### Six properties of the current release that do not survive + +**P1 — The release build is an unaudited lifecycle side effect.** `publish.yml` has **no build +step**. `npm publish` fires `prepare`, which is `bun run build`: `dist/index.js`, `dist/cli.mjs`, +a full Next.js build, the static copy, and `prune-standalone.mjs`. The artifact that reaches +users is produced by a lifecycle hook on the publish runner, retained nowhere, and compared +against nothing. Cross-compiling five Rust targets inside `prepare` is not something anyone +should attempt. It has to become an explicit build job whose **retained output is what gets +published** — build once, `npm pack`, publish that tarball, and upload it as a workflow artifact +so a bad release can be examined rather than reconstructed. + +**P2 — Publishing is not gated on CI.** `release: [published]` invokes `publish.yml` directly. +Nothing re-runs quality, test, build, docs, or e2e against the tagged commit — the assumption is +that CI passed on `main` earlier and the tag points somewhere sane. Today the cost of that +assumption is a bad version sitting on npm until someone notices. After stage 7b it is a bad +version **auto-applied to the fleet within one check interval**. R16 is written as though this +gate exists; it does not. + +**P3 — A failed alias publish is a green release.** `publish-aliases.mjs` collects every failure +into a `warnings` array and prints `::warning::`. It never exits non-zero. Two of those cases are +genuinely benign — "already published at this version", and npm's similarity block, which is +permanent and outside our control. Everything else is a real publish failure reported as a +notice, on packages whose entire purpose is catching a user who typed the name wrong. + +**P4 — The version is derived from the git tag and written by `npm version` at publish time.** +Adequate for one package. The target carries root, `packages/wrapper`, N platform packages, +`@failproofai/dashboard`, 14 aliases, a Cargo workspace, and the update manifest — every one of +which must agree. This is R13 generalized: **one script sets the version everywhere, and the CI +consistency check asserts it**, including the dependency *name set*, not just the values. + +**P5 — `prepare` also runs on `npm install` from a git URL.** Installing the repo today builds +Next.js. Once `crates/` exists, that silently becomes "installing from git requires a Rust +toolchain" unless the build is split into a JS half and a native half. + +**P6 — The tarball's contents need an explicit inventory decision.** `files:` ships `bin/`, +`src/`, `scripts/`, `lib/`, `pi-extension/`, `openclaw-plugin/`, **`.next/standalone/`**, `dist/`, +and the README. Stage 4b removes the standalone tree. `src/` is the less obvious one: +`bin/failproofai.mjs` lazy-imports `../src/**` at roughly ten call sites, but the published `bin` +is the bundled `dist/cli.mjs`, which contains **zero** `../src/` references. Whether the raw +TypeScript is load-bearing or vestigial is a measurable question, and project scope pulls +whatever the answer is through `npx` on the hot path. + +### The target publish DAG + +```mermaid +flowchart TB + TAG["release published"] --> GATE{"re-run full CI
on the tagged sha"} + GATE -->|"fail"| STOP["ABORT — nothing published"] + GATE -->|"pass"| SETV["set-version
ONE script: root, packages/*,
crates/*, manifest"] + SETV --> BJS["build JS artifacts
npm pack, retained tarball"] + SETV --> BRS["build Rust matrix
5 targets, static musl"] + BRS --> VP{"verify-platform
exec each artifact on a
MATCHING runner"} + BJS --> VP + VP -->|"any target fails
or is missing"| STOP + VP -->|"all pass"| PP["publish N platform packages"] + PP --> VR{"verify-from-registry
install each platform pkg,
exec the hook client"} + VR -->|"fail"| STOP + VR -->|"pass"| PW["publish wrapper (failproofai)"] + PW --> PD["publish @failproofai/dashboard"] + PW --> PA["publish 14 alias packages
MUST fail the job on error"] + PD --> SMOKE{"post-publish smoke:
npx failproofai doctor
on every platform"} + PA --> SMOKE + SMOKE -->|"fail"| DEPRECATE["npm deprecate + report
manifest NEVER published"] + SMOKE -->|"pass"| MAN["sign + publish update manifest
ONLY reachable from here"] + MAN --> BUMP["bump next dev version"] + + style STOP fill:#3b1d1d,stroke:#e4587c,color:#fff + style DEPRECATE fill:#3b3620,stroke:#facc15,color:#fff + style MAN fill:#1d3b2a,stroke:#4ade80,color:#fff +``` + +Four invariants that this DAG exists to enforce: + +- **Nothing is published until every platform artifact is verified by execution.** Not built — + *executed*, on a runner matching its triple. R9's exit-126 failure is invisible to a build. +- **The wrapper publishes strictly after the platform packages it points at, and only after they + are confirmed installable from the registry.** Publishing a wrapper whose + `optionalDependencies` cannot resolve is R8 at fleet scale, and it is a *successful* install. +- **The update manifest is reachable from exactly one place in the graph** — after the smoke + test, in the same job. This is the coupling R16 turns on: if the manifest can be published by + any other path, auto-update converts a partial publish into a fleet outage. +- **Publish is idempotent and resumable.** A job that dies between the platform packages and the + wrapper must be re-runnable without hand-editing versions. Every publish step tolerates + "already published at this version" and nothing else. + +### Per-workflow migration + +| Workflow | Change | Lands in | +|---|---|---| +| `ci.yml` | Add `1.0.0` to both trigger lists — **the blocker below**. | **0** | +| `ci.yml` | Amend the version-consistency check per R13: read root's `optionalDependencies`, assert the expected triple *names*, and fail rather than pass when `packages/wrapper/package.json` is absent-but-expected. | 4c | +| `ci.yml` | Add a `rust` job — `cargo fmt --check`, `clippy -D warnings`, `cargo test` — **path-filtered** so a `src/hooks/` change never waits on it. | 4 | +| `ci.yml` | Split `test-e2e` into `test-e2e-embedded` (default gate, Rust-free) and `test-e2e-daemon`. **Both job names change**, see required checks below. | 5 | +| `ci.yml` | New guard tests as their own assertions: the pure-builtin import fence (0b), the `app/` eslint fence (13b). | 0b, 13b | +| `publish.yml` | The whole DAG above: CI gate, `set-version`, explicit build, `verify-platform`, registry verification, hard-fail aliases, post-publish smoke, manifest. | 4c | +| `publish.yml` | Sign the manifest in a **separate job** with a scoped, short-lived credential. The key authorizes root code on every customer machine — it must not sit in the same environment as `NPM_TOKEN`. See open question 8(a). | 7b | +| `publish.yml` | The next-dev-version bump currently pushes to `main`. While `1.0.0` is the integration branch it must bump **`1.0.0`**, or every release re-forks the version lineage. | 4c | +| `integration-suite.yml` | Add the "oracle log exists and is non-empty" precondition — today a broken oracle and broken enforcement both produce "no deny found". | 2 | +| `integration-suite.yml` | Daemon lifecycle inside the sandbox, plus the **daemon-down probe**: stop the daemon, run one tool call, assert the CLI reports a block. Converts fail-closed from a claim into a daily live check across twelve vendors. | 7 | +| `integration-suite.yml` | Becomes the natural oracle for drift detection — it already knows each vendor's version. Emit parse-drift and config-drift counts as suite output rather than only as local health. | 11b | +| `osv-scanner.yml` | Add `1.0.0` to both trigger lists. Add `Cargo.lock` scanning once `crates/` exists — osv-scanner reads it natively, so this is a path addition, not a new tool. | 0, 4 | +| `build-image.yml` | The hook-sync image installs `failproofai@latest` globally. Once the daemon is the default it must either run the daemon in-container or pin the no-daemon path explicitly — a container that silently falls back is a drift detector that has stopped detecting. | 7 | +| `bump-platform-submodule.yml` | Triggers on `main` only. Decide whether it also serves `1.0.0`; if not, say so, because sixteen stages is long enough for the submodule to rot on the integration branch. | 0 | +| `translate-docs.yml` | Exclude `docs/superpowers/specs/**` from translatable sources. This document is a working design doc; translating it 14 ways on a daily cron is pure token spend on text that changes every stage. | 0 | +| `.github/dependabot.yml` | Add the `cargo` ecosystem when `crates/` lands, and `npm` for `packages/*` if they carry lockfiles. A Rust workspace that Dependabot cannot see is a supply chain nobody is watching. | 4 | + +### Branch protection and required checks + +Three settings-level changes, none of which can be committed from a PR: + +- **`1.0.0` needs the same protection `main` has.** Sixteen stage PRs land there and it merges to + `main` once, at v1.0.0. An unprotected integration branch means the release is only as reviewed + as its weakest stage. +- **Required checks are matched by job name, and this migration renames jobs.** Splitting + `test-e2e` into two is the obvious case; adding `rust` as a path-filtered job is the subtle one, + because a required check that never runs blocks every PR that does not touch Rust. Use + `if: always()` skip-shims or GitHub's path-filter-aware required checks — decide which, once, + and write it down. +- **The version-bot App is a bypass actor on the org ruleset.** It currently pushes version bumps + to `main`. Once it also publishes the update manifest, the same identity signs code and bypasses + review. Split the identity before stage 7b, not after. + +### Testing the pipeline itself + +The pipeline gets the same treatment as the daemon: the failure being guarded against is a green +run that shipped nothing, or shipped something broken. + +- A **dry-run publish** into a local registry (Verdaccio) on every PR that touches + `publish.yml`, `set-version`, or `packages/`. Assert the full package set, the version + agreement, and the `optionalDependencies` name set. +- A **deliberately missing platform artifact** must abort before any publish. This is the R12 + test, and it is the one that matters most. +- A **failed alias publish** must fail the job (P3), while "already published" and npm's + similarity block must not. +- **Resume**: re-running publish after the platform packages succeeded must be a no-op on those + and proceed to the wrapper. +- **`prepare` without a Rust toolchain** must still produce a working JS install (P5). + +--- + ## Findings in the current codebase These are present-tense issues that the daemon amplifies. They are worth fixing whether or not @@ -725,6 +901,17 @@ enforced. Each risk below therefore gets a named mechanical guard rather than a | **R17** | **Swapping the binary under in-flight requests.** With fail-closed as the default, a refused connection during the swap blocks the user's agent rather than losing a metric — an update that stutters is indistinguishable from an outage. | Drain and hand over the listener; the client retries once on a refused connection before applying the configured failure mode. A dedicated test asserts zero failed requests across a swap under load. | | **R18** | **The dashboard stays a second writer.** If reads move to the daemon but a Server Action still calls `writeHooksConfig` directly, the daemon's precedence rules and org locks are bypassed by a web form — and under `--system`, by an unauthenticated one. Partial migration is the likely outcome here, because reads are the fun part. | Fence it mechanically: an eslint `no-restricted-imports` rule forbidding anything under `app/` from importing the config writer or the parser modules at all. The rule is the test, and it fails the moment someone reintroduces a direct import. | +### Introduced by the release-pipeline migration + +| # | Risk | Guard | +|---|---|---| +| **R19** | **Publishing is not gated on CI.** `release: [published]` runs `publish.yml` with no re-verification of the tagged commit. Today that ships a bad version to npm; after stage 7b it auto-applies one to the fleet. R16's mitigation assumes a gate that does not exist. | `publish.yml` invokes the CI workflow on the tagged sha via `workflow_call` and refuses to proceed on anything but success. No dispatch override. | +| **R20** | **A failed alias publish is a green release.** `publish-aliases.mjs` funnels every error into `::warning::` and always exits 0, so a broken typo-catch package looks identical to a healthy one. | Exit non-zero. Allowlist exactly two outcomes — "already published at this version" and npm's permanent similarity block — and assert the expected published count against the `ALIASES` array. | +| **R21** | **The release build is a `prepare` side effect** (P1). Nothing retains, audits, or compares the artifact users receive, and adding a Rust cross-compile matrix to that model is not viable. | Explicit build job → `npm pack` → publish that tarball → upload it as a workflow artifact. The published bytes and the archived bytes are the same bytes. | +| **R22** | **A renamed or path-filtered job silently stops being a required check.** This migration renames `test-e2e` and adds a path-filtered `rust` job. Required checks match by name; a required check that never fires blocks every PR, and a dropped one blocks nothing — and the second failure is invisible. | A committed manifest of required-check names asserted in CI against the workflow's job ids, plus a periodic `gh api` reconciliation against the live ruleset. Decide the path-filter convention once (skip-shim vs. filter-aware) and write it down. | +| **R23** | **The version is written in more than one place.** Root, `packages/wrapper`, N platform packages, `@failproofai/dashboard`, 14 aliases, the Cargo workspace, and the update manifest all carry it; `publish.yml` currently sets it with a bare `npm version`. A drift here is a wrapper pointing at a platform package that does not exist — R8, arriving by arithmetic. | One `set-version` script used by every publish step, and R13's amended consistency check asserting both the values and the dependency *name set*. | +| **R24** | **The signing identity is over-scoped.** The version-bot App already bypasses the branch ruleset to push version bumps. If the same job and the same environment also hold the manifest signing key, one compromised release workflow both authorizes root code on every customer machine and can push the commit that did it. | Sign in a separate job, in a separate GitHub Environment, with a short-lived credential; split the bump identity from the signing identity before 7b. Feeds open question 8(a). | + ### Assets to protect - **`EvaluationResult` is already the wire contract.** `policy-evaluator.ts` already returns @@ -782,13 +969,15 @@ all CI jobs green, with a CHANGELOG entry. ```mermaid flowchart TB - S0a["0a · hook-command-builder
no behaviour change"] --> S0b["0b · security-hardening
F1-F5"] + S0["0 · ci-foundation
MUST LAND FIRST"] --> S0a["0a · hook-command-builder
no behaviour change"] + S0a --> S0b["0b · security-hardening
F1-F5"] S0b --> S1["1 · hot-path-diet
PAYS FOR THE PROJECT"] S1 --> S2["2 · request-scoped-env
R4, R5"] S2 --> S3["3 · daemon-proto
types + generated table"] S3 --> S4["4 · rust-workspace
client is a no-op"] S4 --> S4b["4b · dashboard-package
leaves the main tarball"] - S4b --> S5["5 · daemon-core
differential test"] + S4b --> S4c["4c · release-pipeline
verify-platform gate
GATES 7b"] + S4c --> S5["5 · daemon-core
differential test"] S5 --> S6["6 · policy-realms
R1, R3"] S6 --> S7["7 · daemon-default
LAST REVERSIBLE STAGE"] S7 --> S7b["7b · self-update
MAKES LATER STAGES
RECOVERABLE"] @@ -804,19 +993,23 @@ flowchart TB S14 --> S15["15 · system-mode
root"] S15 --> S16["16 · collector-cutover"] + style S0 fill:#3b3620,stroke:#facc15,color:#fff style S1 fill:#1d3b2a,stroke:#4ade80,color:#fff + style S4c fill:#1d3b2a,stroke:#4ade80,color:#fff style S7 fill:#3b3620,stroke:#facc15,color:#fff style S7b fill:#1d3b2a,stroke:#4ade80,color:#fff style S14 fill:#3b1d1d,stroke:#e4587c,color:#fff style S15 fill:#3b1d1d,stroke:#e4587c,color:#fff ``` -The four lettered sub-stages are new since the first draft: they carry the dashboard -re-architecture and the update system. They are lettered rather than renumbered so that the -"point of no return is stage 14" landmark keeps its name. +Stage **0** and the five lettered sub-stages are new since the first draft: they carry the CI +foundation, the release pipeline, the dashboard re-architecture, and the update system. They are +lettered rather than renumbered so that the "point of no return is stage 14" landmark keeps its +name. | # | Branch | Ships | Why here | |---|---|---|---| +| **0** | `ci-foundation` | Add `1.0.0` to `ci.yml` and `osv-scanner.yml` triggers. Protect `1.0.0` and take an inventory of required checks (R22). Decide `bump-platform-submodule`'s relationship to the integration branch. Exclude `docs/superpowers/specs/**` from `translate-docs`. **No source change at all.** | **Must land first, and the whole sequence is unverified until it does.** Every stage below assumes CI runs on its PR; today none of them would. Kept separate from 0a so that the fix is reviewable as a two-line trigger change rather than buried in a refactor. | | **0a** | `hook-command-builder` | Extract the command-string ternary — copy-pasted at nine sites in `integrations.ts`, plus a tenth hidden inside the OpenCode shim template — into one builder. Interpolate the tool maps from `types.ts` into the generated shims, killing three duplicate copies of one map and two of another. | Byte-identical output; the 24 existing literal assertions staying green *is* the proof. Turns a nine-site edit into a one-site edit and permanently closes the duplication class that caused the silent opencode no-op. | | **0b** | `security-hardening` | F1–F5. Pure/impure builtin split with a mechanical guard — a test asserting the pure module graph never imports `node:child_process`, `node:fs`, `node:net`, or `fetch`. Activity store and hook log to 0600. Dashboard to loopback. `.failproofai/**` into `isAgentSettingsFile`. Project-policy trust-on-first-use. Invert the config merge. | Every one is a present-tense vulnerability that the daemon amplifies. | | **1** | `hot-path-diet` | Content-addressed module temp files, killing six writes and six unlinks per event. Lazy-import the session libraries so `cli=claude` skips 75 KB. Detach the telemetry flush. **No Rust, no daemon.** | **The stage that pays for the project if everything after slips.** Every existing user gets a faster hook immediately, and it validates the module-identity fix in the short-lived process first. | @@ -824,6 +1017,7 @@ re-architecture and the update system. They are lettered rather than renumbered | **3** | `daemon-proto` | Wire types, the `CollectedEvent` union, `eventId` derivation, schema parity between Rust and TS, and the generated response table. | Locks the contract. Pure types and pure functions; zero runtime change. | | **4** | `rust-workspace` | Cargo workspace, path-filtered Rust CI job, `packages/` scaffolding, release matrix, launcher with fallback to the JS path. **The Rust client does nothing but exec the JS path.** | Proves cross-compilation, npm `optionalDependencies`, provenance, and launcher fallback while the binary is behaviourally a no-op. If distribution is going to hurt, find out here. | | **4b** | `dashboard-package` | Move `.next/standalone/` out of the main tarball into `@failproofai/dashboard`. `failproofai dashboard` resolves it, installs on first use, and prints a hint when it is absent. | Rides with the packaging work in stage 4 rather than repeating it a release later. Shrinks the artifact that project scope pulls through `npx` on the hot path, and closes the launcher path-resolution question. | +| **4c** | `release-pipeline` | The publish DAG: a CI gate on the tagged sha, one `set-version` script, an explicit build job producing a retained tarball, `verify-platform` by execution on matching runners, registry verification before the wrapper, hard-failing alias publishes, a post-publish smoke, and the amended version-consistency check. The manifest step is scaffolded but publishes nothing yet. | R19–R23. **This gates 7b**: the update channel's central constraint is that the manifest is published only by the job that passed `verify-platform`, and that job does not exist until here. Landing it with the packaging work in 4/4b rather than a release later means the first multi-package publish is also the first *gated* one. | | **5** | `daemon-core` | Supervisor, IPC, worker pool, and the policy worker wrapping the existing engine. User mode only, behind a flag, default off. | **The differential test lands here and is the most important test in the project.** | | **6** | `policy-realms` | Fingerprint-keyed module cache, realm-scoped registry, hot reload, one worker per project realm. | R1 and R3. Required before the default flips. Worker-per-realm also fixes the module leak and the hung-policy problem — a Rust `SIGKILL` preempts a busy loop that an in-process `Promise.race` cannot. | | **7** | `daemon-default` | Client tries the daemon, falls back in-process, and the configured mode governs only if both fail. Single-writer activity store. `health` v1. | **Last fully reversible stage.** | @@ -860,15 +1054,26 @@ every stage above lands as its own PR **targeting `1.0.0`**, and `1.0.0` merges at release. The branch must be kept current with `main` continuously — over sixteen stages it will otherwise rot, and a stale base makes every subsequent diff noisy. -> **Blocker — must land before the first stage PR.** `.github/workflows/ci.yml` triggers only -> on `main`, for both `push` and `pull_request`. As written, **every PR into `1.0.0` would run -> zero CI** — no lint, no type check, no tests, no build, no e2e — and would appear green. -> Adding `1.0.0` to both trigger lists is a two-line change, but it must exist before any -> stage PR does, or the whole sequence runs unverified. `integration-suite.yml` and -> `osv-scanner.yml` need the same review. +> **Blocker — this is stage 0, and nothing else may land before it.** +> `.github/workflows/ci.yml` triggers only on `main`, for both `push` and `pull_request`. As +> written, **every PR into `1.0.0` would run zero CI** — no lint, no type check, no tests, no +> build, no docs validation, no e2e — and would appear green. Sixteen stages would land +> unverified and the breakage would surface at the merge to `main`, all at once. +> +> The full inventory, since `ci.yml` is not the only one: +> +> | File | Today | Needed | +> |---|---|---| +> | `ci.yml` | `push` + `pull_request` on `main` | add `1.0.0` to both — **the two lines that unblock everything** | +> | `osv-scanner.yml` | `push` + `pull_request` on `main` | add `1.0.0` to both; otherwise the integration branch accrues advisories unscanned for the length of the project | +> | `integration-suite.yml` | daily cron + dispatch, HEAD of the default branch | decide whether the daily vendor probe follows `1.0.0`. It is the only detector of vendor drift, and `1.0.0` is where the parsers will actually be changing | +> | `bump-platform-submodule.yml` | `push` on `main` | decide explicitly. Sixteen stages is long enough for the submodule to rot on the integration branch | +> | `translate-docs.yml` | daily cron | exclude `docs/superpowers/specs/**`; this document changes every stage and does not need fourteen translations of each draft | +> | Branch protection | `main` only | protect `1.0.0`, and inventory required-check names before any job is renamed (R22) | > -> This design PR deliberately changes **no** workflow file — it is documentation only — so -> the fix belongs to the first implementation PR. +> This design PR deliberately changes **no** workflow file — it is documentation only. Stage 0 +> exists to carry exactly this, and it is deliberately scoped to trigger and settings changes so +> it can be reviewed and merged in minutes rather than sitting behind a refactor. --- @@ -918,6 +1123,12 @@ The tests that actually de-risk this, in priority order: 11. **Dashboard fence** (R18) — the eslint rule fails on a reintroduced direct import of the config writer or the parser modules, and a config change made through the dashboard is visible to the daemon without a restart. +12. **Release pipeline** (R19–R23) — a dry-run publish into a local registry asserting the full + package set, version agreement, and the `optionalDependencies` name set; a deliberately + missing platform artifact aborting before *any* publish; an alias failure failing the job + while "already published" does not; a resumed publish being a no-op on what already + succeeded; and `prepare` producing a working JS install with no Rust toolchain present. The + missing-artifact test is the one that matters — it is R12 and R16 in a single assertion. **E2E isolation** uses a per-worker daemon with the endpoint derived from the fixture HOME. That deliberately exercises the top compatibility trap — a daemon serving a project whose HOME @@ -974,10 +1185,21 @@ deny found" — the former must be an error, never a failure. network-triggered root-code-replacement channel on every machine in the org, which is strictly more powerful than remote policy push — so two things still need sign-off: **(a)** the signing key and its custody, since that key now authorizes root code on every - customer machine; **(b)** whether an org can be *prevented* from pinning. The answer to (b) + customer machine — and note R24: it must not share a job or an environment with `NPM_TOKEN`, + nor an identity with the version-bot App that already bypasses the branch ruleset; + **(b)** whether an org can be *prevented* from pinning. The answer to (b) should be no. Note that if D2 is accepted, the install channel and the update channel share one trust root, which is the only coherent version of this. -9. **The signed data manifest for vendor schemas** — worth building, and where its line sits. +9. **Does anything publish from `1.0.0` before the merge to `main`?** `publish.yml` fires on + `release: [published]` and bumps the next dev version on `main`. Across sixteen stages the + options are: publish nothing until v1.0.0 (simple, but the Rust distribution work in stages + 4/4b/4c is then never exercised against the real registry until the day it matters); publish + `beta` dist-tag releases from `1.0.0` (exercises the pipeline, at the price of two live + version lineages); or cut `main` releases in parallel from cherry-picked stages. The middle + option is the only one that de-risks R8, R12, and R19 before the release that depends on + them, and it is the reason 4c bumps `1.0.0` rather than `main`. Needs a decision at stage 0, + because it determines whether the branch is a merge target or a release branch. +10. **The signed data manifest for vendor schemas** — worth building, and where its line sits. See the fork at the end of "Noticing drift". The recommendation is declarative only, parsing and detection only, never the verdict path. This needs an explicit yes or no before 11b, because adding it later means retrofitting a trust boundary rather than designing one.