failproofai 1.0.4-beta.4 → 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 (162) 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.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +5 -5
  32. package/.next/standalone/.next/server/app/audit/page.js +3 -3
  33. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/index.html +1 -1
  36. package/.next/standalone/.next/server/app/index.rsc +14 -14
  37. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  38. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  39. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  40. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  41. package/.next/standalone/.next/server/app/page.js +2 -2
  42. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  45. package/.next/standalone/.next/server/app/policies/page.js +4 -4
  46. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page.js +3 -3
  50. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +4 -4
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/projects/page.js +3 -3
  59. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  62. package/.next/standalone/.next/server/app/settings/page.js +2 -2
  63. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0--lkk6._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q0qzx1._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0rv7m0k._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.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/_0tovk6q._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
  76. package/.next/standalone/.next/server/chunks/lib_factory-projects_ts_1eo_bk-._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0p-5p8u._.js → [root-of-the-server]__0-4ja6q._.js} +3 -3
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1p2otjt._.js → [root-of-the-server]__00cr3xu._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__013jr2b._.js → [root-of-the-server]__04px77e._.js} +3 -3
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__190won8._.js → [root-of-the-server]__0dxf928._.js} +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0s740oi._.js → [root-of-the-server]__0egr5s1._.js} +3 -3
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01wy8d-._.js → [root-of-the-server]__0i0l1di._.js} +3 -3
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0n_lxhg._.js → [root-of-the-server]__0kokc8z._.js} +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ctpynv._.js → [root-of-the-server]__0n2txhk._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__02npjtd._.js → [root-of-the-server]__0r3c1_0._.js} +3 -3
  88. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ftmoxc._.js → [root-of-the-server]__17xnxlx._.js} +3 -3
  89. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0da85px._.js → [root-of-the-server]__1ckpmkk._.js} +3 -3
  90. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__00-s7h8._.js → [root-of-the-server]__1fvfw59._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0yl9wb5._.js → [root-of-the-server]__1ui5ur8._.js} +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/{_0guxnh-._.js → _02b3i98._.js} +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/_04ng_6x._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/{_1u8-lu2._.js → _07t3jol._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/{_0h_douw._.js → _0s-77v5._.js} +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +4 -14
  99. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  105. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  106. package/.next/standalone/.next/server/pages/404.html +1 -1
  107. package/.next/standalone/.next/server/pages/500.html +1 -1
  108. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  109. package/.next/standalone/.next/server/server-reference-manifest.json +22 -22
  110. package/.next/standalone/.next/static/chunks/{364cqbpeg1y5i.js → 07c6l1lq5_69b.js} +1 -1
  111. package/.next/standalone/.next/static/chunks/{2bxr2_2h4kaqq.js → 12bsgz8czvcnn.js} +1 -1
  112. package/.next/standalone/.next/static/chunks/{38dqur43qthrl.js → 1gn08w2pgqh09.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{1i-i0pxg9q_8y.js → 1xgq22tri86an.js} +1 -1
  114. package/.next/standalone/.next/static/chunks/{17tfki9x5rd_7.js → 20xkiqzvfkuvm.js} +1 -1
  115. package/.next/standalone/.next/static/chunks/{42gwu2bsz4ske.js → 2a84ug32tzik2.js} +2 -2
  116. package/.next/standalone/.next/static/chunks/{0po8n4tyotjf2.js → 32m2ryfankf_2.js} +1 -1
  117. package/.next/standalone/.next/static/chunks/3ioub_lpen-sq.js +1 -0
  118. package/.next/standalone/.next/static/chunks/{323cyb3a2s06y.js → 3jxd133cw2vm3.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{0qx7gav1c2_vu.js → 44ncu88eq6z71.js} +1 -1
  120. package/.next/standalone/app/actions/get-leaks.ts +0 -5
  121. package/.next/standalone/app/actions/update-scheduled-audit.ts +4 -56
  122. package/.next/standalone/app/api/auth/status/route.ts +3 -2
  123. package/.next/standalone/app/settings/page.tsx +1 -1
  124. package/.next/standalone/app/settings/settings-client.tsx +13 -39
  125. package/.next/standalone/lib/auth/api-server-client.ts +6 -6
  126. package/.next/standalone/lib/codex-sessions.ts +20 -4
  127. package/.next/standalone/lib/copilot-sessions.ts +32 -1
  128. package/.next/standalone/lib/cursor-sessions.ts +62 -5
  129. package/.next/standalone/package.json +9 -9
  130. package/.next/standalone/server.js +1 -1
  131. package/dist/cli.mjs +952 -1063
  132. package/dist/worker.mjs +173 -380
  133. package/lib/auth/api-server-client.ts +6 -6
  134. package/lib/codex-sessions.ts +20 -4
  135. package/lib/copilot-sessions.ts +32 -1
  136. package/lib/cursor-sessions.ts +62 -5
  137. package/package.json +9 -9
  138. package/pi-extension/index.ts +10 -18
  139. package/src/audit/cli.ts +72 -81
  140. package/src/audit/harm-report.ts +12 -6
  141. package/src/audit/index.ts +2 -0
  142. package/src/audit/leak-fingerprint.ts +2 -4
  143. package/src/audit/leak-record.ts +13 -9
  144. package/src/audit/leak-scan.ts +1 -1
  145. package/src/audit/leak-store.ts +1 -46
  146. package/src/audit/machine-store.ts +8 -9
  147. package/src/audit/macos-notifier.ts +2 -2
  148. package/src/audit/redact-example.ts +2 -0
  149. package/src/audit/report-harm.ts +31 -46
  150. package/src/audit/schedule-cli.ts +17 -33
  151. package/src/audit/types.ts +5 -0
  152. package/src/hooks/configure-wizard.ts +28 -0
  153. package/src/hooks/fp-config.ts +29 -54
  154. package/src/hooks/handler.ts +1 -79
  155. package/src/hooks/integrations.ts +4 -15
  156. package/src/hooks/notice.ts +12 -179
  157. package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +0 -3
  158. package/.next/standalone/.next/static/chunks/04o5zfl7p5lhx.js +0 -1
  159. package/src/audit/leak-notice.ts +0 -259
  160. /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → aeM365dNTtUZOZvNkX8ba}/_buildManifest.js +0 -0
  161. /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → aeM365dNTtUZOZvNkX8ba}/_clientMiddlewareManifest.js +0 -0
  162. /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → aeM365dNTtUZOZvNkX8ba}/_ssgManifest.js +0 -0
@@ -794,9 +794,11 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
794
794
  errors === 0 &&
795
795
  skipped === 0;
796
796
  const newFindingIds = persistLeaks(perTranscript, coversAllHistory);
797
+ const leakIds = [...new Set(perTranscript.flatMap((t) => (t.leaks ?? []).map((leak) => leak.id)))];
797
798
 
798
799
  const auditResult: AuditResult = {
799
800
  version: 2,
801
+ leakIds,
800
802
  newLeakIds: newFindingIds,
801
803
  scannedAt: new Date(startedAt).toISOString(),
802
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
  }
@@ -33,7 +33,7 @@ import { SECRET_PATTERNS } from "../hooks/builtin-policies";
33
33
  * `result.leaks` forever, which made a precision fix appear to do nothing on a
34
34
  * warm machine. Bump this whenever matching or suppression semantics change.
35
35
  */
36
- export const LEAK_SCAN_VERSION = 5;
36
+ export const LEAK_SCAN_VERSION = 6;
37
37
 
38
38
  /** One credential value found in one event. */
39
39
  export interface SecretMatch {
@@ -46,24 +46,6 @@ interface LeakIdentity {
46
46
  salt: string;
47
47
  /** Finding ids the user has said are not secrets. */
48
48
  dismissed: string[];
49
- /**
50
- * When the user last actually LOOKED at the leak report, as epoch ms.
51
- *
52
- * This is what stops the in-CLI notice, and it replaces a per-finding "we
53
- * emitted it once" marker that silently lost the alert twice in one day: once
54
- * because it was claimed on an event whose channel the host ignores, and once
55
- * because hooks were disabled in the project being tested. Both marked every
56
- * finding delivered and showed nothing, with no retry, ever.
57
- *
58
- * Keyed on the user's own action rather than on ours, the failure direction
59
- * inverts: a dropped notice simply reappears next session, and the only thing
60
- * that silences it is the user opening the report — which is the outcome the
61
- * notice exists to produce.
62
- *
63
- * `identity`, alongside `dismissed`, for the same reason: losing it re-alerts
64
- * about credentials somebody has already seen and judged.
65
- */
66
- reportViewedAt?: number;
67
49
  }
68
50
 
69
51
  /** Mode for both files. They carry no credential, but they do carry a machine
@@ -95,10 +77,6 @@ export function readLeakIdentity(home?: string): LeakIdentity {
95
77
  return {
96
78
  salt: existing.salt,
97
79
  dismissed: existing.dismissed ?? [],
98
- reportViewedAt:
99
- typeof existing.reportViewedAt === "number" && Number.isFinite(existing.reportViewedAt)
100
- ? existing.reportViewedAt
101
- : undefined,
102
80
  };
103
81
  }
104
82
  const minted: LeakIdentity = { salt: randomBytes(32).toString("hex"), dismissed: [] };
@@ -146,7 +124,7 @@ export function writeLeakIdentity(identity: LeakIdentity, home?: string): boolea
146
124
  function sanitizeFinding(raw: unknown): LeakFinding | null {
147
125
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
148
126
  const f = raw as Record<string, unknown>;
149
- // The id becomes a FILENAME (notice markers, the macOS queue), so anything
127
+ // The id becomes a filename in the macOS notification queue, so anything
150
128
  // that is not the exact minted shape disqualifies the whole entry.
151
129
  if (typeof f.id !== "string" || !isFindingId(f.id)) return null;
152
130
 
@@ -219,29 +197,6 @@ export function writeLeakRecord(record: LeakRecord, home?: string, nowMs = Date.
219
197
  }
220
198
 
221
199
  /** Mark a finding as not-a-secret. Survives a reset; see the module header. */
222
- /**
223
- * Record that the user has seen the leak report.
224
- *
225
- * Called from the dashboard when it reads the findings, and from the
226
- * interactive `failproofai audit`, which prints them. Both are the user
227
- * looking — which is the only signal worth silencing the notice on.
228
- *
229
- * Never throws: failing to record a view costs one extra notice, which is the
230
- * side this whole design errs on.
231
- */
232
- export function markLeakReportViewed(home?: string, nowMs = Date.now()): void {
233
- try {
234
- const identity = readLeakIdentity(home);
235
- writeJsonAtomically(
236
- auditLeakIdentityFile(home),
237
- { ...identity, reportViewedAt: nowMs },
238
- { mode: FILE_MODE },
239
- );
240
- } catch {
241
- // See the doc comment: an unrecorded view re-notifies once.
242
- }
243
- }
244
-
245
200
  export function dismissFinding(id: string, home?: string): boolean {
246
201
  const identity = readLeakIdentity(home);
247
202
  if (identity.dismissed.includes(id)) return true;
@@ -7,9 +7,8 @@
7
7
  * - `machine_id` is what the api-server keys reports on. Regenerate it and the
8
8
  * server sees a brand-new machine, which burns a slot off the account's cap
9
9
  * on every logout and splits one box's history into two.
10
- * - `last_reported_at` is how far the last digest reached. Reset it and the
11
- * next report re-covers months of history, and the user gets a digest of
12
- * everything that ever happened as though it just did.
10
+ * - `last_reported_at` is how far the last credential alert reached. Reset it
11
+ * and the next report can re-cover old exposures as though they just happened.
13
12
  *
14
13
  * That is why this is a separate file from `session.json` rather than two more
15
14
  * keys in it: signing out deletes the session, and neither of these may go with
@@ -22,7 +21,7 @@
22
21
  * `state/telemetry-id` is already a stable per-machine random id and would have
23
22
  * been free to reuse. It is deliberately not reused: that id is the anonymous
24
23
  * PostHog person, and sending it alongside a verified email address would link
25
- * the two the moment somebody turns emailed reports on. Opting into a digest
24
+ * the two the moment somebody adds email alerts. Opting into email
26
25
  * should not de-anonymise telemetry, so this feature gets its own id and the
27
26
  * two never meet.
28
27
  */
@@ -66,8 +65,8 @@ export function readMachineIdentity(home?: string): MachineIdentity | null {
66
65
  /**
67
66
  * Read the identity, creating it on first call.
68
67
  *
69
- * Only ever called from the reporting path, so a machine that never opts into
70
- * emailed reports never gets an id at all — there is nothing to mint one for.
68
+ * Only ever called from the email-reporting path, so a machine without a valid
69
+ * email identity never gets an id at all — there is nothing to mint one for.
71
70
  */
72
71
  export function ensureMachineIdentity(home?: string): MachineIdentity {
73
72
  const existing = readMachineIdentity(home);
@@ -81,13 +80,13 @@ export function ensureMachineIdentity(home?: string): MachineIdentity {
81
80
  }
82
81
 
83
82
  /**
84
- * Record how far the last DELIVERED digest reached.
83
+ * Record how far the last delivered credential alert reached.
85
84
  *
86
85
  * The value is the server's `next_window_from`, not the window this run
87
86
  * scanned. The server is authoritative because it knows which reports actually
88
87
  * produced an email — a report held by the cooldown, or one whose send failed,
89
88
  * must not advance the watermark or its findings are silently dropped from every
90
- * future digest.
89
+ * future alert.
91
90
  */
92
91
  export function recordReportWatermark(nextWindowFrom: string, home?: string): void {
93
92
  const current = ensureMachineIdentity(home);
@@ -105,7 +104,7 @@ export function deleteMachineIdentity(home?: string): void {
105
104
  /**
106
105
  * A display name for this machine — its hostname.
107
106
  *
108
- * Shown in the digest so somebody with three boxes can tell which one is
107
+ * Shown in the email alert so somebody with three boxes can tell which one is
109
108
  * misbehaving, which is the whole reason it is sent. Falls back to `undefined`
110
109
  * rather than a placeholder: the server keeps whatever label it already has when
111
110
  * one is omitted, so guessing here would overwrite a good name with a bad one.
@@ -255,8 +255,8 @@ export function queueMacNotification(
255
255
  body: string,
256
256
  home?: string,
257
257
  ): boolean {
258
- // The id becomes a filename in a watched directory. Same guard, same reason
259
- // as `markLeakNoticeDelivered` — see `isFindingId`.
258
+ // The id becomes a filename in a watched directory, so validate the path
259
+ // component before resolving it — see `isFindingId`.
260
260
  if (!isFindingId(id)) return false;
261
261
  const dir = macNotifyDir(home);
262
262
  const tmp = resolve(dir, "..", `notify-${id}.tmp`);
@@ -573,6 +573,7 @@ export function redactAuditResult(result: AuditResult, home = homedir()): AuditR
573
573
  // Salted, machine-local ids. They carry no path and cannot be reversed to
574
574
  // a credential, so they pass through — but the decision is recorded here
575
575
  // rather than inherited from a spread.
576
+ leakIds: result.leakIds,
576
577
  newLeakIds: result.newLeakIds,
577
578
 
578
579
  // Everything below carries a path or user content and is rewritten.
@@ -615,5 +616,6 @@ export const REDACTED_AUDIT_RESULT_KEYS: ReadonlyArray<keyof AuditResult> = [
615
616
  "projectsScanned",
616
617
  "eventsScanned",
617
618
  "enabledBuiltinNames",
619
+ "leakIds",
618
620
  "newLeakIds",
619
621
  ];
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The side effect a scheduled audit has that no other audit does: telling the
3
- * api-server what it found, so a harm digest can be mailed.
2
+ * The optional remote side effect of a scheduled audit: telling the api-server
3
+ * about the newest masked credential exposure so an email alert can be sent.
4
4
  *
5
5
  * Separated from `harm-report.ts` on purpose. That module is pure — result in,
6
6
  * payload out — and is where the windowing rules live and are tested. This one
@@ -15,7 +15,7 @@
15
15
  * token expired, whose network is down, or whose api-server is having a bad day
16
16
  * must keep auditing itself locally and keep showing results on its own
17
17
  * dashboard — the local feature does not depend on the remote one, and a person
18
- * who never enabled emailed reports must never be able to tell this code exists.
18
+ * who never added an email must never have the local scan depend on this code.
19
19
  *
20
20
  * ## Why the CHILD does this and not the daemon
21
21
  *
@@ -35,6 +35,7 @@ import { activeFindings, readLeakRecord } from "./leak-store";
35
35
  import {
36
36
  ensureMachineIdentity,
37
37
  machineLabel,
38
+ readMachineIdentity,
38
39
  recordReportWatermark,
39
40
  } from "./machine-store";
40
41
  import type { AuditResult } from "./types";
@@ -42,31 +43,26 @@ import type { AuditResult } from "./types";
42
43
  /** What happened, for the one line the scheduled run prints. */
43
44
  export type HarmReportOutcome =
44
45
  | { kind: "disabled" }
45
- | { kind: "consent-required" }
46
+ | { kind: "no-leak" }
46
47
  | { kind: "signed-out" }
47
- | { kind: "sent"; hits: number }
48
- | { kind: "held"; hits: number; reason: string }
48
+ | { kind: "sent"; leaks: number }
49
+ | { kind: "held"; leaks: number; reason: string }
49
50
  | { kind: "failed"; error: string };
50
51
 
51
52
  /**
52
- * Report this scan's harmful findings, if the user asked for that.
53
+ * Report this scan's newest credential exposure when an email identity exists.
53
54
  *
54
55
  * Returns an outcome rather than a boolean so the caller can say something
55
- * truthful. "held" in particular is not a failure — a machine below the
56
- * threshold, or inside its cooldown, is working exactly as intended, and a line
57
- * that called that an error would train people to ignore the line.
56
+ * truthful. "held" in particular is not a failure — a machine inside its
57
+ * cooldown is working exactly as intended, and a line that called that an error
58
+ * would train people to ignore the line.
58
59
  */
59
60
  export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome> {
60
- // ONE switch. `auto` means "scan on a timer AND tell me", because the reason
61
- // to put a scan on a timer is to be told — a machine scanning quietly with
62
- // nothing to report to is a feature that looks on and does nothing visible.
63
61
  let auto = false;
64
- let consentedAt: number | undefined;
65
62
  let intervalDays = 7;
66
63
  try {
67
64
  const config = readConfig();
68
65
  auto = config.audit.auto;
69
- consentedAt = config.audit.reportsConsentedAt;
70
66
  // Also the width of a FIRST report's window, so a new machine's opening
71
67
  // digest covers the same period every later one will.
72
68
  intervalDays = config.audit.intervalDays;
@@ -76,13 +72,18 @@ export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome
76
72
  }
77
73
  if (!auto) return { kind: "disabled" };
78
74
 
79
- // `auto` alone is not consent to SEND, on a machine that set it before this
80
- // existed. Through 1.0.0 that key meant "scan locally on a timer" — no
81
- // account, no network, and the toggle that wrote it said so. Sending is
82
- // gated on the separate stamp every current opt-in path writes, so a
83
- // machine upgrading into this release keeps scanning and mails nothing until
84
- // a person opts in again and sees what that sends. See `audit.reportsConsentedAt`.
85
- if (consentedAt === undefined) return { kind: "consent-required" };
75
+ // Decide whether there is anything emailable before touching auth or the
76
+ // network. Scheduled scans are local by default; an email identity is an
77
+ // optional delivery channel, and a scan with no credential leak must make no
78
+ // report request at all.
79
+ const prior = readMachineIdentity();
80
+ const report = buildHarmReport(
81
+ result,
82
+ prior?.last_reported_at,
83
+ intervalDays,
84
+ activeFindings(readLeakRecord()),
85
+ );
86
+ if (report.leaks.length === 0) return { kind: "no-leak" };
86
87
 
87
88
  const auth = await getValidAccessToken();
88
89
  if (!auth) {
@@ -102,18 +103,6 @@ export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome
102
103
  return { kind: "failed", error: err instanceof Error ? err.message : String(err) };
103
104
  }
104
105
 
105
- // The leak record is read here rather than carried on the AuditResult: the
106
- // digest wants every credential still live in this window, not only the ones
107
- // this particular scan happened to re-encounter. A key that leaked last month
108
- // and was not touched by today's transcripts is still a key to rotate.
109
- const report = buildHarmReport(
110
- result,
111
- identity.last_reported_at,
112
- intervalDays,
113
- activeFindings(readLeakRecord()),
114
- );
115
- const hits = report.harmful.reduce((n, p) => n + p.hits, 0);
116
-
117
106
  try {
118
107
  const res = await submitAuditReport(auth.access_token, {
119
108
  machine_id: identity.machine_id,
@@ -139,8 +128,8 @@ export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome
139
128
  }
140
129
 
141
130
  return res.emailed
142
- ? { kind: "sent", hits }
143
- : { kind: "held", hits, reason: res.reason ?? "not_sent" };
131
+ ? { kind: "sent", leaks: report.leaks.length }
132
+ : { kind: "held", leaks: report.leaks.length, reason: res.reason ?? "not_sent" };
144
133
  } catch (err) {
145
134
  // A 401 here means the session died between `getValidAccessToken` and this
146
135
  // call — rare, and indistinguishable from any other failure as far as this
@@ -160,19 +149,15 @@ export async function reportHarm(result: AuditResult): Promise<HarmReportOutcome
160
149
  export function describeOutcome(outcome: HarmReportOutcome): string | null {
161
150
  switch (outcome.kind) {
162
151
  case "disabled":
163
- return null; // Say nothing at all to the majority who never opted in.
164
- case "consent-required":
165
- return "failproofai: scheduled scans are on, but emailing what they find needs a fresh opt-in — run `failproofai audit --schedule` (or visit /settings) to turn digests on";
152
+ case "no-leak":
153
+ return null;
166
154
  case "signed-out":
167
- // Names the two places that can actually fix it. It used to say "sign in
168
- // from the audit page", which stopped being true when this release moved
169
- // that dialog behind "invite a friend".
170
- return "failproofai: emailed reports are on but this machine is signed out — run `failproofai audit --schedule` or visit /settings to resume them";
155
+ return "failproofai: a credential exposure was found, but no email is configured — run `failproofai audit --schedule --email you@example.com`";
171
156
  case "sent":
172
- return `failproofai: emailed a harm digest (${outcome.hits} finding${outcome.hits === 1 ? "" : "s"})`;
157
+ return "failproofai: emailed the most recent credential exposure";
173
158
  case "held":
174
- return `failproofai: ${outcome.hits} finding${outcome.hits === 1 ? "" : "s"} reported, no email (${outcome.reason})`;
159
+ return `failproofai: credential exposure reported, no email (${outcome.reason})`;
175
160
  case "failed":
176
- return `failproofai: could not send the harm report: ${outcome.error}`;
161
+ return `failproofai: could not send the credential alert: ${outcome.error}`;
177
162
  }
178
163
  }
@@ -68,13 +68,9 @@ const dim = (s: string) => wrap(ANSI_DIM, s);
68
68
  const bold = (s: string) => wrap(ANSI_BOLD, s);
69
69
 
70
70
  /**
71
- * Turn scheduled audits on, signing in first if needed.
72
- *
73
- * Scheduling and mailing are ONE decision — the reason to put a scan on a timer
74
- * is to be told what it found — so this requires a session, exactly as the
75
- * dashboard's `setAutoAuditAction` does. A timer set with nobody to tell is a
76
- * switch that reads as on and produces nothing, discoverable only by noticing
77
- * that no digest ever arrives.
71
+ * Turn scheduled audits on. Email is optional: `--email` signs in, while a
72
+ * plain `--schedule` enables the local scan and desktop notification without
73
+ * prompting for an account.
78
74
  *
79
75
  * The interval is written and then RE-READ, so what is printed is what the
80
76
  * config actually kept — `readIntervalDays` owns the 1..90 clamp and a second
@@ -108,17 +104,13 @@ export async function runScheduleOn(
108
104
  if (bad) throw new ScheduleCliError(bad);
109
105
  }
110
106
 
111
- const { user, prompted } = await ensureSignedIn(emailArg);
107
+ const signedIn = emailArg === undefined
108
+ ? { user: readAuth()?.user ?? null, prompted: false }
109
+ : await ensureSignedIn(emailArg);
112
110
 
113
111
  const next = updateConfig({
114
- // Stamped in the SAME call that sets `auto`, never separately: this records
115
- // that a person completed a sign-in and read the disclosure printed below,
116
- // and it is what `reportHarm` gates sending on. A machine that inherited
117
- // `auto` from a release where it meant "scan locally" has no stamp and
118
- // sends nothing until it comes through here.
119
112
  audit: {
120
113
  auto: true,
121
- reportsConsentedAt: Date.now(),
122
114
  ...(days !== undefined ? { intervalDays: days } : {}),
123
115
  },
124
116
  });
@@ -134,12 +126,14 @@ export async function runScheduleOn(
134
126
  // The list is the real payload from `report-harm.ts`: machine id, hostname,
135
127
  // platform, the window bounds and the redacted examples.
136
128
  const summary = [
137
- `every ${interval} day${interval === 1 ? "" : "s"} · reports to ${user.email}`,
138
- "you only hear from it when a scan finds something harmful",
139
- "each report sends: finding counts, redacted example commands, this machine's name",
129
+ `every ${interval} day${interval === 1 ? "" : "s"} · desktop notification on leaks`,
130
+ signedIn.user
131
+ ? `masked leak alerts also go to ${signedIn.user.email}`
132
+ : "email is optional · add it with --email you@example.com",
133
+ "email contains only the most recent masked credential exposure",
140
134
  ];
141
135
 
142
- if (prompted) {
136
+ if (signedIn.prompted) {
143
137
  // A sign-in just drew the frame, so the result continues it and the `└`
144
138
  // closes both at once — rather than the frame ending and a loose line
145
139
  // appearing underneath.
@@ -158,7 +152,7 @@ export async function runScheduleOn(
158
152
  // exists to make visible.
159
153
  warnIfDaemonWontRun();
160
154
 
161
- if (prompted) {
155
+ if (signedIn.prompted) {
162
156
  outro("failproofai audit --status · when the next scan is due");
163
157
  } else {
164
158
  process.stdout.write(dim(`\n failproofai audit --status when the next scan is due\n\n`));
@@ -217,7 +211,7 @@ export function runScheduleStatus(): void {
217
211
 
218
212
  // A session whose refresh window has closed cannot mint another access token,
219
213
  // so it is a destination in name only. Showing the address for one would tell
220
- // somebody their digests are going somewhere they are not.
214
+ // somebody their alerts are going somewhere they are not.
221
215
  const live = auth && auth.refresh_expires_at * 1000 > Date.now() ? auth : null;
222
216
  // Beside the schedule, because they are the two halves of "what does this
223
217
  // machine do on its own" — one decides whether it scans, the other whether it
@@ -230,19 +224,9 @@ export function runScheduleStatus(): void {
230
224
  detail.push(["reports to", live ? live.user.email : dim("— signed out")]);
231
225
  if (on && !live) {
232
226
  // The state the reporter surfaces as "signed-out". Named here for the same
233
- // reason the settings panel names it: the scans keep running, so silence
234
- // about the digests would look like the feature failing.
235
- detail.push(["", pink("scans continue; digests are paused until you sign in")]);
236
- } else if (on && config.audit.reportsConsentedAt === undefined) {
237
- // Signed in, scheduled, and still not sending: this machine set `audit.auto`
238
- // when it only meant "scan locally", so nothing has consented to the digest
239
- // leaving the box. Without this row the status screen would show a healthy
240
- // schedule and a live address and still mail nothing, with no explanation
241
- // anywhere the user can see.
242
- detail.push([
243
- "",
244
- pink("scans continue; digests need a fresh opt-in — run `--schedule` to turn them on"),
245
- ]);
227
+ // reason the settings panel names it: the scans and desktop notifications
228
+ // keep running, so silence about email would look like the feature failing.
229
+ detail.push(["", pink("scans and desktop alerts continue; email alerts are paused")]);
246
230
  }
247
231
 
248
232
  detail.push(["daemon", describeDaemon(daemon)]);
@@ -217,6 +217,11 @@ export interface AuditResult {
217
217
  * Lets the dashboard answer "is this policy already on?" for
218
218
  * detector-mapped policies that may not have hit during this audit. */
219
219
  enabledBuiltinNames: string[];
220
+ /** Ids of credentials encountered by this scan, including ones already in
221
+ * the persistent leak record. Scheduled desktop notifications key on this
222
+ * list so every completed scan that still sees a leak can notify; the
223
+ * narrower `newLeakIds` remains for first-seen-only surfaces. */
224
+ leakIds?: string[];
220
225
  /**
221
226
  * Ids of credentials seen for the FIRST time in this scan.
222
227
  *
@@ -106,6 +106,7 @@ import {
106
106
  type RetryProbe,
107
107
  } from "./onboarding-attempt";
108
108
  import { acquireOnboardingLock } from "./onboarding-lock";
109
+ import { readConfigRaw, updateConfig } from "./fp-config";
109
110
 
110
111
  export interface WizardIO {
111
112
  stdin?: TTYIn;
@@ -755,6 +756,19 @@ export async function runConfigureWizard(
755
756
  // question, and no flag can supply it.
756
757
  const preAnswered = Boolean(answers.token);
757
758
 
759
+ // Capture the preference BEFORE the daemon setup writes config.json. A fresh
760
+ // machine reaches `setDaemonConfigured(true)` with no config file; that helper
761
+ // necessarily materialises the conservative no-file default (`auto: false`).
762
+ // Looking after that write cannot distinguish "the user switched it off"
763
+ // from "setup just wrote its fallback", which is how new installs ended up
764
+ // with a seven-day scheduler that was explicitly disabled.
765
+ const initialAudit = readConfigRaw().raw.audit;
766
+ const hadExplicitAuditAuto =
767
+ !!initialAudit &&
768
+ typeof initialAudit === "object" &&
769
+ !Array.isArray(initialAudit) &&
770
+ typeof (initialAudit as Record<string, unknown>).auto === "boolean";
771
+
758
772
 
759
773
  // Running the wizard itself under sudo configures the WRONG ACCOUNT, and
760
774
  // does it silently: homedir() becomes /root, so the hooks land in root's
@@ -1439,6 +1453,20 @@ export async function runConfigureWizard(
1439
1453
  // binary filename are both derived from it.
1440
1454
  if (daemonInstalled) {
1441
1455
  setDaemonConfigured(true, cliVersion);
1456
+ // A completed setup is the default opt-in to the LOCAL seven-day scan. It
1457
+ // does not imply an email address or make a network request: reporting is
1458
+ // independently gated by the signed-in audit identity. Reconfiguration
1459
+ // preserves an explicit `auto: false`, so the user's off switch remains an
1460
+ // off switch instead of being undone whenever they repair or upgrade the
1461
+ // daemon.
1462
+ if (!hadExplicitAuditAuto) {
1463
+ try {
1464
+ updateConfig({ audit: { auto: true } });
1465
+ } catch {
1466
+ // The daemon and hooks are already installed. Keep setup successful;
1467
+ // the status screen will expose that scheduling could not be persisted.
1468
+ }
1469
+ }
1442
1470
  // Only now — the unit points at the new binary, so older ones are no
1443
1471
  // longer referenced by anything. Keeps the previous version for an
1444
1472
  // offline rollback.