failproofai 1.0.4-beta.2 → 1.0.4-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/run/route.js +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +5 -5
  33. package/.next/standalone/.next/server/app/audit/page.js +3 -3
  34. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/index.html +1 -1
  37. package/.next/standalone/.next/server/app/index.rsc +14 -14
  38. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  39. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  40. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  41. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  42. package/.next/standalone/.next/server/app/page.js +2 -2
  43. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  46. package/.next/standalone/.next/server/app/policies/page.js +4 -4
  47. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page.js +3 -3
  51. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +4 -4
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page.js +3 -3
  60. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  63. package/.next/standalone/.next/server/app/settings/page.js +2 -2
  64. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/chunks/{[externals]__1lh7m5d._.js → [externals]__0v1hz15._.js} +1 -1
  67. package/.next/standalone/.next/server/chunks/{[externals]__1rqkg_y._.js → [externals]__1nb206a._.js} +1 -1
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0rv7m0k._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1legmza._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p-qi2t._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/{_08w6xzm._.js → _0otft92._.js} +2 -2
  74. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
  77. package/.next/standalone/.next/server/chunks/_1vslkoe._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0p-5p8u._.js → [root-of-the-server]__0-4ja6q._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1p2otjt._.js → [root-of-the-server]__00cr3xu._.js} +3 -3
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__013jr2b._.js → [root-of-the-server]__04px77e._.js} +3 -3
  82. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__190won8._.js → [root-of-the-server]__0dxf928._.js} +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0s740oi._.js → [root-of-the-server]__0egr5s1._.js} +3 -3
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01wy8d-._.js → [root-of-the-server]__0i0l1di._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0n_lxhg._.js → [root-of-the-server]__0kokc8z._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ctpynv._.js → [root-of-the-server]__0n2txhk._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__02npjtd._.js → [root-of-the-server]__0r3c1_0._.js} +3 -3
  89. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ftmoxc._.js → [root-of-the-server]__17xnxlx._.js} +3 -3
  90. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0da85px._.js → [root-of-the-server]__1ckpmkk._.js} +3 -3
  91. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__00-s7h8._.js → [root-of-the-server]__1fvfw59._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ui5ur8._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/_02b3i98._.js +23 -0
  94. package/.next/standalone/.next/server/chunks/ssr/{_0oq1dh6._.js → _04ng_6x._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/{_1u8-lu2._.js → _07t3jol._.js} +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/{_0h_douw._.js → _0s-77v5._.js} +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/_1_ozgif._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +4 -1
  101. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  107. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  108. package/.next/standalone/.next/server/pages/404.html +1 -1
  109. package/.next/standalone/.next/server/pages/500.html +1 -1
  110. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  111. package/.next/standalone/.next/server/server-reference-manifest.json +22 -22
  112. package/.next/standalone/.next/static/chunks/{1pb1oztsbwcss.js → 07c6l1lq5_69b.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{0o-hh5_turzlz.css → 0jpd8dv2vk930.css} +1 -1
  114. package/.next/standalone/.next/static/chunks/{32spub4wqjem-.js → 12bsgz8czvcnn.js} +1 -1
  115. package/.next/standalone/.next/static/chunks/{3m4upvybtrexd.js → 1gn08w2pgqh09.js} +1 -1
  116. package/.next/standalone/.next/static/chunks/{010bv1w6j171t.js → 1xgq22tri86an.js} +1 -1
  117. package/.next/standalone/.next/static/chunks/{2aquitk72k2op.js → 20xkiqzvfkuvm.js} +1 -1
  118. package/.next/standalone/.next/static/chunks/{2bi_1y0a_smt7.js → 2a84ug32tzik2.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{2ej3b8gk5ittu.js → 32m2ryfankf_2.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/3ioub_lpen-sq.js +1 -0
  121. package/.next/standalone/.next/static/chunks/{0wz8yftk18ts2.js → 3jxd133cw2vm3.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/44ncu88eq6z71.js +1 -0
  123. package/.next/standalone/app/actions/get-leaks.ts +5 -1
  124. package/.next/standalone/app/actions/update-scheduled-audit.ts +4 -56
  125. package/.next/standalone/app/api/auth/status/route.ts +3 -2
  126. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +29 -2
  127. package/.next/standalone/app/audit/_components/leak-section.tsx +15 -2
  128. package/.next/standalone/app/audit/audit-styles.css +18 -0
  129. package/.next/standalone/app/settings/page.tsx +1 -1
  130. package/.next/standalone/app/settings/settings-client.tsx +13 -39
  131. package/.next/standalone/lib/auth/api-server-client.ts +6 -6
  132. package/.next/standalone/lib/codex-sessions.ts +20 -4
  133. package/.next/standalone/lib/copilot-sessions.ts +32 -1
  134. package/.next/standalone/lib/cursor-sessions.ts +62 -5
  135. package/.next/standalone/lib/sqlite-reader.ts +78 -1
  136. package/.next/standalone/package.json +10 -10
  137. package/.next/standalone/server.js +1 -1
  138. package/bin/failproofai.mjs +1 -1
  139. package/dist/cli.mjs +1191 -1130
  140. package/dist/worker.mjs +198 -359
  141. package/lib/auth/api-server-client.ts +6 -6
  142. package/lib/codex-sessions.ts +20 -4
  143. package/lib/copilot-sessions.ts +32 -1
  144. package/lib/cursor-sessions.ts +62 -5
  145. package/lib/sqlite-reader.ts +78 -1
  146. package/package.json +10 -10
  147. package/pi-extension/index.ts +11 -4
  148. package/src/audit/cache.ts +6 -0
  149. package/src/audit/cli-adapters/claude.ts +13 -0
  150. package/src/audit/cli-login.ts +4 -1
  151. package/src/audit/cli.ts +67 -51
  152. package/src/audit/harm-report.ts +12 -6
  153. package/src/audit/index.ts +151 -8
  154. package/src/audit/leak-fingerprint.ts +2 -4
  155. package/src/audit/leak-record.ts +13 -9
  156. package/src/audit/leak-scan.ts +20 -44
  157. package/src/audit/leak-store.ts +5 -2
  158. package/src/audit/machine-store.ts +8 -9
  159. package/src/audit/macos-notifier.ts +2 -2
  160. package/src/audit/redact-example.ts +62 -5
  161. package/src/audit/report-harm.ts +31 -46
  162. package/src/audit/schedule-cli.ts +17 -33
  163. package/src/audit/types.ts +11 -0
  164. package/src/hooks/configure-wizard.ts +28 -0
  165. package/src/hooks/fp-config.ts +29 -54
  166. package/src/hooks/handler.ts +1 -59
  167. package/src/hooks/integrations.ts +4 -1
  168. package/src/hooks/notice.ts +23 -154
  169. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +0 -3
  170. package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +0 -23
  171. package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +0 -1
  172. package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +0 -1
  173. package/src/audit/leak-notice.ts +0 -161
  174. /package/.next/standalone/.next/static/{aKNv4Kmv98Xpwns0Xa-C- → aeM365dNTtUZOZvNkX8ba}/_buildManifest.js +0 -0
  175. /package/.next/standalone/.next/static/{aKNv4Kmv98Xpwns0Xa-C- → aeM365dNTtUZOZvNkX8ba}/_clientMiddlewareManifest.js +0 -0
  176. /package/.next/standalone/.next/static/{aKNv4Kmv98Xpwns0Xa-C- → aeM365dNTtUZOZvNkX8ba}/_ssgManifest.js +0 -0
package/src/audit/cli.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * the dashboard to view it.
4
4
  *
5
5
  * failproofai audit Scan, then launch the dashboard at /audit.
6
- * failproofai audit --schedule Put scans on a timer and mail the findings.
6
+ * failproofai audit --schedule Put local scans and notifications on a timer.
7
7
  * failproofai audit --no-schedule Stop the timer.
8
8
  * failproofai audit --status What this machine is scheduled to do.
9
9
  * failproofai audit -h, --help Show usage.
@@ -37,8 +37,9 @@ import { openWhenReady } from "./open-browser";
37
37
  import { describeOutcome, reportHarm } from "./report-harm";
38
38
  import { notifyDesktop } from "./desktop-notify";
39
39
  import { macNotifierInstalled, pruneMacNotifyQueue, queueMacNotification } from "./macos-notifier";
40
- import { markLeakNoticeDelivered } from "./leak-notice";
40
+ import { desktopLeakNotice, type DesktopLeakEmailState } from "../hooks/notice";
41
41
  import { readConfig } from "../hooks/fp-config";
42
+ import { readAuth } from "../../lib/auth/auth-store";
42
43
  import { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM, helpScreen, helpOptsFor } from "../hooks/tui";
43
44
  import { version } from "../../package.json";
44
45
 
@@ -96,32 +97,36 @@ export function helpText(): string {
96
97
  {
97
98
  command: "audit",
98
99
  version,
99
- tagline: "review your agent CLIs for risky and wasteful patterns",
100
+ tagline: "find credentials your agents leaked into their own transcripts",
100
101
  sections: [
101
102
  {
102
103
  label: "usage",
103
104
  entries: [
104
- ["(bare)", `Scan your session history, then open http://localhost:${DASHBOARD_PORT}/audit`],
105
- ["--schedule [days]", "Scan on a timer and email the findings. Default 7 days, range 1-90. Signs you in the first time."],
105
+ ["(bare)", `Scan your session history for leaked keys, then open http://localhost:${DASHBOARD_PORT}/audit`],
106
+ ["--schedule [days]", "Scan on a timer and notify you on this machine. Default 7 days, range 1-90. No account required."],
106
107
  // Its own row now, rather than a clause inside --schedule's. It
107
108
  // does not stand alone, which is why it used to be a clause — but
108
109
  // a clause wraps, and `--email <address>` landing with the flag at
109
110
  // the end of one line and its placeholder at the start of the next
110
111
  // is not a flag anybody can read or copy.
111
- ["--email <address>", "With --schedule, skips the sign-in prompt."],
112
+ ["--email <address>", "With --schedule, add email alerts for the most recent masked credential leak. A sign-in code is emailed to you."],
112
113
  ["--no-schedule", "Stop the timer. Leaves you signed in."],
113
114
  // Its own pair of flags rather than a clause on --schedule: whether
114
115
  // this machine scans and whether it may interrupt you are separate
115
116
  // decisions, and nobody silencing a banner should have to re-state
116
117
  // their schedule to do it.
117
118
  ["--notify", "Show a desktop notification when a scan finds a credential. On by default."],
118
- ["--no-notify", "Stop those notifications. Scans and the digest are unaffected."],
119
+ ["--no-notify", "Stop desktop notifications. Scheduled scans and optional email alerts are unaffected."],
119
120
  ["--status", "Whether scheduling is on, where reports go, the daemon's state, and when the next scan is due."],
120
121
  ["-h, --help", "Show this help."],
121
122
  ],
122
123
  },
123
124
  ],
124
- footer: ["Everything runs on this machine; only a scheduled digest ever leaves it."],
125
+ footer: [
126
+ "Every scan runs on this machine. Leak alerts appear as desktop system",
127
+ "notifications, never inside agent sessions. If you add email, only the",
128
+ "newest masked credential exposure and its metadata leave the machine.",
129
+ ],
125
130
  },
126
131
  helpOptsFor(process.stdout),
127
132
  );
@@ -209,6 +214,18 @@ function startProgress(): Progress {
209
214
  const lines = Array.from({ length: n }, (_, i) => lineFor(i));
210
215
  // Move the cursor back up over the previously-drawn block, then clear and
211
216
  // rewrite each line in place.
217
+ //
218
+ // INVARIANT: nothing else may write to the terminal while this is running.
219
+ // The cursor-up is a fixed count, so any stray line pushes the cursor down
220
+ // and the next redraw repaints the block lower — stranding the top of the
221
+ // old frame above it, which reads as the audit having run twice. That is
222
+ // not hypothetical: Node's "SQLite is an experimental feature" warning did
223
+ // exactly this on every first run, two lines of it, and it is why
224
+ // `lib/sqlite-reader.ts` now filters that one warning at the source.
225
+ //
226
+ // There is no defensive fix from inside here — erasing to end of screen
227
+ // still leaves the stranded lines ABOVE the cursor. Keep the terminal
228
+ // quiet instead.
212
229
  if (printed) process.stdout.write(`\x1b[${n}A`);
213
230
  process.stdout.write(lines.map((l) => `\x1b[2K${l}`).join("\n") + "\n");
214
231
  printed = true;
@@ -360,9 +377,12 @@ function heldByLine(held: AuditLockInfo | null): string {
360
377
  * turning a good scan into exit 1 would make the scheduler back off from
361
378
  * running the thing that actually matters.
362
379
  */
363
- async function announceLeaksOnDesktop(result: AuditResult): Promise<void> {
380
+ async function announceLeaksOnDesktop(
381
+ result: AuditResult,
382
+ report: Awaited<ReturnType<typeof reportHarm>>,
383
+ ): Promise<void> {
364
384
  try {
365
- await announceLeaksOrThrow(result);
385
+ await announceLeaksOrThrow(result, report);
366
386
  } catch (err) {
367
387
  // The catch is not decoration. `notifyDesktop` documents that it never
368
388
  // throws, but it is doing socket I/O, and so are the config read and the
@@ -376,42 +396,41 @@ async function announceLeaksOnDesktop(result: AuditResult): Promise<void> {
376
396
  }
377
397
  }
378
398
 
379
- async function announceLeaksOrThrow(result: AuditResult): Promise<void> {
380
- const ids = result.newLeakIds ?? [];
399
+ async function announceLeaksOrThrow(
400
+ result: AuditResult,
401
+ report: Awaited<ReturnType<typeof reportHarm>>,
402
+ ): Promise<void> {
403
+ // `leakIds` includes credentials this scan encountered before. A scheduled
404
+ // run is a fresh safety check, so it must raise a fresh system notification
405
+ // when the leak is still present; the old per-finding marker is why only the
406
+ // first test run ever produced a banner. Older cached results fall back to
407
+ // first-seen ids.
408
+ const ids = result.leakIds ?? result.newLeakIds ?? [];
381
409
  if (ids.length === 0) return;
382
410
  // Read at fire time, not at scan time: the switch answers "should this
383
411
  // interrupt me", and it is the user's most recent answer that counts.
384
412
  if (!readConfig().audit.notify) return;
385
413
 
386
- // Claimed BEFORE the call goes out, and in the desktop channel only. Before,
387
- // because a process that dies mid-notify would otherwise re-announce the same
388
- // finding on every scheduled run forever; desktop-only, because a banner the
389
- // user may never have seen must not also silence the in-session notice.
390
- const claimed = markLeakNoticeDelivered(ids, undefined, "desktop");
391
- if (claimed.length === 0) return;
392
-
393
- const n = claimed.length === 1 ? "a credential" : `${claimed.length} credentials`;
394
- const title = "failproofai found a leaked credential";
395
- // Fixed text with a count interpolated — the same rule as the in-CLI notice,
396
- // for the same reason. A finding's own text comes from a repository this
397
- // machine cloned, and notification bodies render markup on several Linux
398
- // desktops.
399
- //
400
- // The second sentence is the only place most users are ever offered the
401
- // digest. The scan is local and needs no account, so nothing else in the
402
- // product has a reason to ask for an address — which is exactly why the
403
- // audit's findings have historically reached nobody. A banner someone is
404
- // already reading, about a key of their own, is the one moment the offer is
405
- // worth anything.
414
+ const n = ids.length === 1 ? "a possible credential exposure" : `${ids.length} possible credential exposures`;
415
+ // Fixed text with no scanned content and no count. A finding's own text comes
416
+ // from a repository this machine cloned, and notification bodies render
417
+ // markup on several Linux desktops. The count is intentionally left to the
418
+ // report too: a banner must not turn detector candidates into a claim that
419
+ // the user leaked N real credentials.
406
420
  //
407
421
  // No action buttons, deliberately. `Notify` supports them, but a server
408
422
  // delivers the click back as an `ActionInvoked` signal to the sender — and
409
423
  // this process exits as soon as the scan finishes, so the button would be
410
- // dead. Two plain commands the user can copy beat one button that does
411
- // nothing.
412
- const body =
413
- `${n} in your agent transcripts. Run failproofai audit to see them — ` +
414
- "or failproofai audit --schedule to get them by email.";
424
+ // dead. A plain command the user can copy beats a button that does nothing.
425
+ const emailState: DesktopLeakEmailState =
426
+ report.kind === "sent"
427
+ ? "sent"
428
+ : report.kind === "signed-out" || !readAuth()
429
+ ? "missing"
430
+ : report.kind === "failed"
431
+ ? "failed"
432
+ : "held";
433
+ const { title, body } = desktopLeakNotice(emailState);
415
434
 
416
435
  // The two platforms need opposite things, and the split is not cosmetic. On
417
436
  // Linux this process can reach the session bus itself. On macOS it cannot
@@ -420,12 +439,12 @@ async function announceLeaksOrThrow(result: AuditResult): Promise<void> {
420
439
  // one. See `macos-notifier.ts` for why that agent exists.
421
440
  if (process.platform === "darwin") {
422
441
  pruneMacNotifyQueue();
423
- if (!queueMacNotification(claimed[0], title, body)) {
442
+ if (!queueMacNotification(ids[0], title, body)) {
424
443
  process.stderr.write(`failproofai: found ${n} but could not queue a notification\n`);
425
444
  } else if (!macNotifierInstalled()) {
426
- // Queued, but nothing will collect it: setup either never ran or could
427
- // not compile the applet. Worth a line, because the symptom otherwise is
428
- // simply silence.
445
+ // Queued, but nothing currently collects it. The payload is durable and
446
+ // will be consumed when the LaunchAgent returns.
447
+ // Worth a line, because the immediate symptom is otherwise silence.
429
448
  process.stderr.write(
430
449
  `failproofai: found ${n}; the notifier is not installed, so nothing will show it ` +
431
450
  `(run \`failproofai config\`)\n`,
@@ -437,10 +456,9 @@ async function announceLeaksOrThrow(result: AuditResult): Promise<void> {
437
456
  const outcome = await notifyDesktop(
438
457
  title,
439
458
  body,
440
- // A stable id would let a repeat scan replace the previous bubble instead
441
- // of stacking. We do not have one yet the server assigns it and we would
442
- // have to persist it — so a fresh notification is correct here, and it is
443
- // bounded: at most one per distinct credential, ever.
459
+ // `0` asks the notification server for a fresh id. Each scheduled scan that
460
+ // still sees a leak is a new reminder, rather than replacing or suppressing
461
+ // the first banner forever.
444
462
  0,
445
463
  );
446
464
  if (!outcome.ok) {
@@ -523,12 +541,9 @@ export async function runScheduledAudit(): Promise<number> {
523
541
  `${num(result.transcripts.scanned)} sessions, ${num(result.totals.hits)} hits\n`,
524
542
  );
525
543
 
526
- // Tell whoever owns this machine, on the machine, before anything is sent
527
- // anywhere. Local first: this is the channel that works with no account, no
528
- // email and no network, which describes most users.
529
- await announceLeaksOnDesktop(result);
530
-
531
- // Report harmful findings upstream, if the user switched emailed reports on.
544
+ // Email is optional and never controls the local scan. Run it first only so
545
+ // the desktop notification can truthfully say whether this particular
546
+ // alert was also emailed; every failure remains a non-fatal outcome.
532
547
  //
533
548
  // AFTER the dashboard cache is written and AFTER the success line, because
534
549
  // the scan is the product and this is an optional extra on top of it.
@@ -542,6 +557,7 @@ export async function runScheduledAudit(): Promise<number> {
542
557
  // would also make the manual command do a network call that
543
558
  // `audit --help` promises it does not.
544
559
  const outcome = await reportHarm(result);
560
+ await announceLeaksOnDesktop(result, outcome);
545
561
  const line = describeOutcome(outcome);
546
562
  if (line) {
547
563
  // Anything other than a successful send goes to stderr: on a scheduled run
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Turning an audit result into a harm report the api-server can act on.
3
3
  *
4
- * Runs only after a SCHEDULED scan (`failproofai audit --scheduled`), only when
5
- * the user has switched emailed reports on, and only ever from the audit child —
6
- * never the daemon, which holds no human credential precisely so that refresh
7
- * rotation stays inside the audit lock. See `crates/failproofaid/src/audit_lane.rs`.
4
+ * Runs only after a SCHEDULED scan (`failproofai audit --scheduled`) and only
5
+ * ever from the audit child never the daemon, which holds no human credential
6
+ * precisely so that refresh rotation stays inside the audit lock. Without an
7
+ * email identity the scan and desktop notification still work; this module
8
+ * simply reports `signed-out` and sends nothing.
8
9
  *
9
10
  * ## What counts as harm
10
11
  *
@@ -302,7 +303,10 @@ export function selectLeaks(findings: LeakFinding[], from: Date, to: Date): Repo
302
303
  // is now, not where it was first noticed.
303
304
  // Defensive even though `readLeakRecord` already sanitises: this is
304
305
  // exported and takes whatever a caller hands it.
305
- const seen = f.sightings?.[f.sightings.length - 1];
306
+ const seen = f.sightings?.reduce((latest, candidate) => {
307
+ if (!latest) return candidate;
308
+ return (ts(candidate.at) ?? 0) >= (ts(latest.at) ?? 0) ? candidate : latest;
309
+ }, undefined as LeakFinding["sightings"][number] | undefined);
306
310
  out.push({
307
311
  id: f.id,
308
312
  display: f.fingerprint?.display ?? "[credential]",
@@ -356,6 +360,8 @@ export function buildHarmReport(
356
360
  window_from: from.toISOString(),
357
361
  window_to: windowTo.toISOString(),
358
362
  harmful: selectHarmful(result, from, windowTo, { includeUnplaceable: isFirstReport }),
359
- leaks: selectLeaks(leakFindings, from, windowTo),
363
+ // One email, one actionable credential. Sending every row turns a leak
364
+ // alert into a backlog digest and obscures the event that just happened.
365
+ leaks: selectLeaks(leakFindings, from, windowTo).slice(0, 1),
360
366
  };
361
367
  }
@@ -19,7 +19,7 @@ import { severityForBuiltin } from "./features";
19
19
  import { findSecrets, flattenToolInput } from "./leak-scan";
20
20
  import { fingerprintSecret, fingerprintId } from "./leak-fingerprint";
21
21
  import { readLeakIdentity, readLeakRecord, writeLeakRecord } from "./leak-store";
22
- import { upsertFinding, describeMechanism } from "./leak-record";
22
+ import { upsertFinding, describeMechanism, emptyRecord } from "./leak-record";
23
23
  import { shortenPaths } from "./redact-example";
24
24
  import { readCachedTranscript, writeCachedTranscriptResult } from "./cache";
25
25
  import { initReplay, replayEvent, restoreReplay } from "./replay";
@@ -37,6 +37,64 @@ import {
37
37
 
38
38
  const TRANSCRIPT_CONCURRENCY = 8;
39
39
 
40
+ /**
41
+ * Raw transcript bytes allowed in flight at once.
42
+ *
43
+ * The concurrency above counts FILES, which is the wrong unit. `streamEventsFrom`
44
+ * returns an array — despite the name it materialises every event of a
45
+ * transcript into JS objects — and a 57 MB JSONL becomes several hundred MB of
46
+ * them. On a real 1.15 GB corpus, 7 files hold 224 MB and the largest is
47
+ * 57.5 MB, so eight workers could put a quarter of the corpus in memory
48
+ * simultaneously. That machine printed `Aborted(OOM)` 22 times in one run.
49
+ *
50
+ * This is NOT what fixed those lines, and the distinction is worth keeping
51
+ * honest: they were measured at ~22 per run, appear within the first two
52
+ * seconds, are unchanged by this budget at 48 MB or at 16 MB, and do not fail
53
+ * the run — its counts come back correct. Their source is still unidentified.
54
+ * This bounds a real and separate failure mode (eight of the seven largest
55
+ * files in flight at once); it should not be read as having fixed the other.
56
+ *
57
+ * 48 MB of raw input is the budget because the expansion factor from JSONL to
58
+ * parsed objects is roughly 5-10x, which keeps the peak inside a default heap
59
+ * with room for the eight small-file workers this is meant not to slow down.
60
+ * Chosen to bound memory, not to be exactly right: the failure it prevents is a
61
+ * crash, and the cost of being conservative is that one very large file scans
62
+ * alone for a moment.
63
+ */
64
+ const TRANSCRIPT_BYTE_BUDGET = 48 * 1024 * 1024;
65
+
66
+ /**
67
+ * Admission control by weight, so a worker waits for MEMORY as well as a slot.
68
+ *
69
+ * A file bigger than the whole budget is admitted alone rather than refused —
70
+ * otherwise the largest transcript on the machine could never be scanned, which
71
+ * is precisely the one most likely to hold something.
72
+ */
73
+ class ByteGate {
74
+ private inFlight = 0;
75
+ private waiting: Array<() => void> = [];
76
+
77
+ constructor(private readonly budget: number) {}
78
+
79
+ async acquire(bytes: number): Promise<void> {
80
+ const want = Math.max(0, bytes);
81
+ while (this.inFlight > 0 && this.inFlight + want > this.budget) {
82
+ await new Promise<void>((resolve) => this.waiting.push(resolve));
83
+ }
84
+ this.inFlight += want;
85
+ }
86
+
87
+ release(bytes: number): void {
88
+ this.inFlight -= Math.max(0, bytes);
89
+ if (this.inFlight < 0) this.inFlight = 0;
90
+ // Wake everyone and let each re-test its own weight: a small task behind a
91
+ // large one should not be held by a queue position it does not need.
92
+ const waiters = this.waiting;
93
+ this.waiting = [];
94
+ for (const wake of waiters) wake();
95
+ }
96
+ }
97
+
40
98
  /** Canonicalize a policy name to its short, qualified form for display
41
99
  * (`failproofai/foo` → `foo`). */
42
100
  function shortPolicyName(name: string): string {
@@ -118,6 +176,50 @@ interface ScanOutcome {
118
176
  resumed: boolean;
119
177
  }
120
178
 
179
+ const CREDENTIAL_RESEARCH_MARKERS: ReadonlyArray<RegExp> = [
180
+ /\b(?:secret|credential)[-_ ](?:scanner|detection|detector|pattern|regex|corpus)\b/i,
181
+ /\b(?:trufflehog|trufflesecurity|gitleaks|detect-secrets|secret-detection-rules)\b/i,
182
+ /\b(?:SECRET_PATTERNS|findSecrets|isDocsLiteral|leak-scan)\b/,
183
+ /\b(?:fixture|placeholder|synthetic|sample) (?:api )?(?:key|token|secret|credential)s?\b/i,
184
+ /\b(?:grep|rg|ripgrep|scan|hunt|search)\b[^\n]{0,240}\b(?:secret|credential|token)s?\b/i,
185
+ ];
186
+
187
+ const CREDENTIAL_RESEARCH_PURPOSE =
188
+ /\b(?:hunt|scan|search|research|test)(?:ing)?\b[^\n]{0,80}\b(?:secret|credential|token)s?\b|\b(?:secret|credential|token)s?\b[^\n]{0,80}\b(?:scanner|detector|detection|research|fixtures?|corpus)\b/i;
189
+
190
+ /**
191
+ * Deliberate secret-detector research is full of realistic fake credentials.
192
+ * Reporting those as leaks is the feedback loop that made one measured session
193
+ * contribute 455 of the 500 rows in its own audit.
194
+ *
195
+ * Require independent signals across tool INPUTS. Results are excluded from
196
+ * classification: a normal command can print hostile text, while commands and
197
+ * URLs state what the agent intentionally set out to inspect. Two signals keep
198
+ * an ordinary discussion that happens to say "secret scanner" from silencing a
199
+ * session; dedicated detector/test work reliably carries several.
200
+ */
201
+ export function isCredentialResearchSession(
202
+ events: NormalizedToolEvent[],
203
+ sessionDescription?: string,
204
+ ): boolean {
205
+ if (sessionDescription && CREDENTIAL_RESEARCH_PURPOSE.test(sessionDescription)) return true;
206
+ const matched = new Set<number>();
207
+ let evidenceEvents = 0;
208
+ for (const event of events) {
209
+ const intent = flattenToolInput(event.toolInput);
210
+ let eventMatched = false;
211
+ for (let i = 0; i < CREDENTIAL_RESEARCH_MARKERS.length; i++) {
212
+ if (CREDENTIAL_RESEARCH_MARKERS[i].test(intent)) {
213
+ matched.add(i);
214
+ eventMatched = true;
215
+ }
216
+ }
217
+ if (eventMatched) evidenceEvents++;
218
+ if (matched.size >= 2 || evidenceEvents >= 3) return true;
219
+ }
220
+ return false;
221
+ }
222
+
121
223
  async function scanOneTranscript(
122
224
  meta: TranscriptMetadata,
123
225
  resume?: { fromByte: number; detectorState: DetectorSessionState },
@@ -186,6 +288,8 @@ async function scanOneTranscript(
186
288
  // Capture the session's cwd from the first event that carried one — every
187
289
  // event in a single transcript shares the same cwd by construction.
188
290
  result.cwd = result.cwd || events[0].cwd || "";
291
+ const suppressLeakScan = isCredentialResearchSession(events, meta.sessionDescription);
292
+ if (suppressLeakScan) result.leakScanSuppressed = "credential-research";
189
293
 
190
294
  for (const event of events) {
191
295
  // The 8 behavioural detectors are switched off with the rest of the old
@@ -226,7 +330,7 @@ async function scanOneTranscript(
226
330
  // decision per policy, never the text that matched. Scanned separately so
227
331
  // the leak report can name WHICH key, and so detection can be tuned for
228
332
  // precision without dragging the redactor's recall down with it.
229
- recordLeaks(result, event);
333
+ if (!suppressLeakScan) recordLeaks(result, event);
230
334
  }
231
335
 
232
336
  return { result, bytesScanned, detectorState: sessionState, resumed };
@@ -300,10 +404,19 @@ function leakSalt(): string | null {
300
404
  * human about. Never throws: a scan that finds leaks it cannot write down has
301
405
  * still found them, and the caller reports on the in-memory result either way.
302
406
  */
303
- function persistLeaks(perTranscript: TranscriptAuditResult[]): string[] {
407
+ export function persistLeaks(perTranscript: TranscriptAuditResult[], replaceExisting: boolean): string[] {
304
408
  const fresh: string[] = [];
305
409
  try {
306
- const record = readLeakRecord();
410
+ const previous = readLeakRecord();
411
+ const previousIds = new Set(previous.findings.map((f) => f.id));
412
+ // A successful all-history scan is authoritative. Rebuilding is what lets
413
+ // detector precision fixes remove findings they now reject; merging made
414
+ // every false positive live for 90 days even after the code was fixed.
415
+ // Scoped or incomplete scans still merge because absence outside their
416
+ // coverage says nothing.
417
+ const record = replaceExisting
418
+ ? emptyRecord(previous.salt, new Date().toISOString())
419
+ : previous;
307
420
  for (const t of perTranscript) {
308
421
  for (const leak of t.leaks ?? []) {
309
422
  const { isNew } = upsertFinding(record, {
@@ -322,7 +435,10 @@ function persistLeaks(perTranscript: TranscriptAuditResult[]): string[] {
322
435
  mechanism: describeMechanism(leak.toolName, leak.direction, leak.path),
323
436
  },
324
437
  });
325
- if (isNew) fresh.push(leak.id);
438
+ // During an authoritative rebuild every insertion is new to the empty
439
+ // record, but only ids absent from the previous record are new to the
440
+ // HUMAN and eligible for a notification.
441
+ if (isNew && !previousIds.has(leak.id)) fresh.push(leak.id);
326
442
  }
327
443
  }
328
444
  writeLeakRecord(record);
@@ -351,7 +467,7 @@ function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): s
351
467
  * came from the FIRST event in the file and the tail's came from the first
352
468
  * event after the offset, so the older one wins.
353
469
  */
354
- function mergeIncremental(
470
+ export function mergeIncremental(
355
471
  cached: TranscriptAuditResult,
356
472
  tail: TranscriptAuditResult,
357
473
  ): TranscriptAuditResult {
@@ -365,6 +481,12 @@ function mergeIncremental(
365
481
  examplesByName: {},
366
482
  rangeByName: { ...cached.rangeByName },
367
483
  };
484
+ if (cached.leakScanSuppressed || tail.leakScanSuppressed) {
485
+ out.leakScanSuppressed = "credential-research";
486
+ out.leaks = [];
487
+ } else {
488
+ out.leaks = [...(cached.leaks ?? []), ...(tail.leaks ?? [])];
489
+ }
368
490
  for (const [name, list] of Object.entries(cached.examplesByName)) {
369
491
  out.examplesByName[name] = [...list];
370
492
  }
@@ -547,18 +669,22 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
547
669
 
548
670
  // 1. Discover transcripts across all selected CLIs.
549
671
  const allTranscripts: TranscriptMetadata[] = [];
672
+ let discoveryErrors = 0;
550
673
  for (const cli of clis) {
551
674
  const adapter = ADAPTERS[cli];
552
675
  let list: TranscriptMetadata[];
553
676
  try {
554
677
  list = await adapter.listTranscripts({ projects: opts.projects, sinceMs });
555
678
  } catch {
679
+ discoveryErrors++;
556
680
  continue; // adapter failures shouldn't kill the whole audit
557
681
  }
558
682
  allTranscripts.push(...list);
559
683
  }
560
684
 
561
- // 2. Scan each transcript (cache-aware), 8 in parallel.
685
+ // 2. Scan each transcript (cache-aware), 8 at a time and within a memory
686
+ // budget — see TRANSCRIPT_BYTE_BUDGET for why a file count is the wrong unit.
687
+ const gate = new ByteGate(TRANSCRIPT_BYTE_BUDGET);
562
688
  let skipped = 0;
563
689
  let errors = 0;
564
690
  const tasks = allTranscripts.map((meta) => async (): Promise<TranscriptAuditResult> => {
@@ -572,6 +698,9 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
572
698
  : undefined;
573
699
  cachedPrefix = found?.kind === "resume" ? found.result : null;
574
700
  }
701
+ // Memory admission. Taken AFTER the cache check above, so a cache hit — the
702
+ // common case on a warm machine — never waits for a slot it does not use.
703
+ await gate.acquire(meta.sizeBytes);
575
704
  try {
576
705
  const scan = await scanOneTranscript(meta, resume);
577
706
  // A resumed scan produced hits for the TAIL only; the cached result holds
@@ -615,6 +744,11 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
615
744
  examplesByName: {},
616
745
  rangeByName: {},
617
746
  };
747
+ } finally {
748
+ // Released on every path, including the error one — a task that threw
749
+ // still freed its memory, and holding its weight would shrink the budget
750
+ // permanently over a long scan until nothing could be admitted at all.
751
+ gate.release(meta.sizeBytes);
618
752
  }
619
753
  });
620
754
 
@@ -652,10 +786,19 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
652
786
  // not alert again however many fresh sightings it accumulates, and a
653
787
  // credential seen for the first time must alert even though its rule has
654
788
  // fired a thousand times before.
655
- const newFindingIds = persistLeaks(perTranscript);
789
+ const coversAllHistory =
790
+ opts.clis === undefined &&
791
+ opts.projects === undefined &&
792
+ opts.since === undefined &&
793
+ discoveryErrors === 0 &&
794
+ errors === 0 &&
795
+ skipped === 0;
796
+ const newFindingIds = persistLeaks(perTranscript, coversAllHistory);
797
+ const leakIds = [...new Set(perTranscript.flatMap((t) => (t.leaks ?? []).map((leak) => leak.id)))];
656
798
 
657
799
  const auditResult: AuditResult = {
658
800
  version: 2,
801
+ leakIds,
659
802
  newLeakIds: newFindingIds,
660
803
  scannedAt: new Date(startedAt).toISOString(),
661
804
  scope: {
@@ -178,10 +178,8 @@ export function fingerprintSecret(value: string, fallbackLabel = "secret"): Leak
178
178
  /**
179
179
  * Every id `fingerprintId` can produce, and nothing else.
180
180
  *
181
- * Ids become FILENAMES one marker file per notified finding, one queue file
182
- * per pending macOS banner so an id is a path component, and a path component
183
- * built from unvalidated input is a directory traversal. Measured: passing
184
- * `"../../../../tmp/PWNED"` to `markLeakNoticeDelivered` created that file.
181
+ * Ids become FILENAMES for pending macOS banners, so an id is a path component,
182
+ * and a path component built from unvalidated input is a directory traversal.
185
183
  *
186
184
  * Ids are HMAC hex today, so nothing in a normal run can carry a `/`. That is
187
185
  * not the same as safe: the record is JSON read off disk, and `leaks.json` is a
@@ -156,15 +156,19 @@ export function upsertFinding(
156
156
  if (sighting.at > existing.lastSeen) existing.lastSeen = sighting.at;
157
157
  if (sighting.at < existing.firstSeen) existing.firstSeen = sighting.at;
158
158
 
159
- // Keep the FIRST sightings rather than the most recent. The first exposure is
160
- // the one that explains how the credential got into the transcript at all;
161
- // later ones are usually the same value being replayed, and a checkpoint
162
- // record replaying a prompt is not new information.
163
- if (existing.sightings.length < MAX_SIGHTINGS) {
164
- const seen = existing.sightings.some(
165
- (s) => s.sessionId === sighting.sessionId && s.at === sighting.at,
166
- );
167
- if (!seen) existing.sightings.push(sighting);
159
+ // Keep the earliest sighting AND the most recent evidence. The report/email
160
+ // promises the CLI and timestamp of the latest exposure; keeping only the
161
+ // first five made those fields stale forever after a busy credential crossed
162
+ // the cap. Middle sightings are the expendable ones.
163
+ const seen = existing.sightings.some(
164
+ (s) => s.sessionId === sighting.sessionId && s.at === sighting.at,
165
+ );
166
+ if (!seen) {
167
+ existing.sightings.push(sighting);
168
+ existing.sightings.sort((a, b) => a.at.localeCompare(b.at));
169
+ if (existing.sightings.length > MAX_SIGHTINGS) {
170
+ existing.sightings.splice(1, existing.sightings.length - MAX_SIGHTINGS);
171
+ }
168
172
  }
169
173
  return { record, isNew: false };
170
174
  }