From a3630a230f94ed6a3df0800c6e02be2c3a0288e4 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Fri, 14 Aug 2026 17:03:28 +0530 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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,