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
@@ -24,18 +24,26 @@
24
24
  * them apart so the report can tell a user which of the two happened.
25
25
  */
26
26
  import { SECRET_PATTERNS } from "../hooks/builtin-policies";
27
- import { isSecretName } from "./redact-example";
27
+
28
+ /**
29
+ * Cache key for the credential detector.
30
+ *
31
+ * The transcript cache used to key only on builtin policies and behavioural
32
+ * audit detectors. Changing THIS scanner therefore kept serving the old
33
+ * `result.leaks` forever, which made a precision fix appear to do nothing on a
34
+ * warm machine. Bump this whenever matching or suppression semantics change.
35
+ */
36
+ export const LEAK_SCAN_VERSION = 6;
28
37
 
29
38
  /** One credential value found in one event. */
30
39
  export interface SecretMatch {
31
40
  /** The raw value. Held in memory only — the caller fingerprints it and must
32
41
  * never persist or transmit this. */
33
42
  value: string;
34
- /** The identifier it was assigned to, when it came from an assignment. For
35
- * the first-party class (230 of 237 secret-named assignments match no vendor
36
- * pattern) this is the only actionable label there is. */
43
+ /** The identifier it was assigned to, when a recognized shaped value also
44
+ * appeared in an assignment. Used only as an actionable label. */
37
45
  name: string | null;
38
- /** Which rule found it — a vendor label, or "assigned secret". */
46
+ /** Which recognizable credential shape found it. */
39
47
  rule: string;
40
48
  /** Whether the value carried a recognised vendor shape. Drives the record's
41
49
  * confidence, and therefore whether an alert may name a console. */
@@ -69,36 +77,6 @@ export interface SecretMatch {
69
77
  const ASSIGNMENT_RE =
70
78
  /(["']?)\b([A-Za-z_][A-Za-z0-9_]{0,127})\1([ \t]*(?::(?!\/\/)|=)[ \t]*)("[^"]*"|'[^']*'|[^\s;|&"']+)/g;
71
79
 
72
- /** Shortest value worth reporting. Below this it is a flag, a boolean or a
73
- * placeholder far more often than a credential. */
74
- const MIN_VALUE_LEN = 8;
75
-
76
- /**
77
- * Values that are structurally incapable of being a leaked credential.
78
- *
79
- * Deliberately structural, never entropy: the corpus's one confirmed-live
80
- * password measures 3.19 bits per character while 1.3 million UUIDs in the same
81
- * corpus sit at 3.72, so any entropy threshold that catches the password also
82
- * catches every UUID. These tests are decisive instead — a `$VAR` reference
83
- * contains no secret by construction, and `process.env.X` is the CORRECT secure
84
- * form, which the redactor currently flags 7,870 times.
85
- */
86
- function isNotACredential(value: string): boolean {
87
- if (value.length < MIN_VALUE_LEN) return true;
88
- // Shell/template indirection: the value is a reference, not a secret.
89
- if (/^[$<{(`%]/.test(value)) return true;
90
- if (value.includes("${") || value.includes("{{") || value.includes("%(")) return true;
91
- // A code expression — `process.env.DISCORD_TOKEN` is how you SHOULD do it.
92
- if (/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/.test(value)) return true;
93
- // Already masked, by us or by anyone else.
94
- if (value.includes("[REDACTED") || /^[•*#.]+$/.test(value)) return true;
95
- if (/(.)\1{5,}/.test(value)) return true; // xxxxxxxx, 00000000
96
- if (/^(true|false|null|undefined|none)$/i.test(value)) return true;
97
- if (/^-?\d+(\.\d+)?$/.test(value)) return true; // pure number
98
- if (/^\/|^~\/|^\.\.?\//.test(value)) return true; // a path
99
- return false;
100
- }
101
-
102
80
  /** Strip one layer of surrounding quotes. */
103
81
  function unquote(v: string): string {
104
82
  return (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))
@@ -249,19 +227,17 @@ export function findSecrets(text: string): SecretMatch[] {
249
227
  }
250
228
  }
251
229
 
230
+ // Names annotate a value the shape layer already proved; they no longer
231
+ // create findings. Even after increasingly elaborate value heuristics, the
232
+ // name-only lane still produced hundreds of code literals and test fixtures
233
+ // on the measured machine. The product now deliberately prefers missing an
234
+ // unrecognisable first-party password over telling somebody they leaked 500
235
+ // credentials they do not have.
252
236
  for (const m of text.matchAll(ASSIGNMENT_RE)) {
253
237
  const name = m[2];
254
238
  const value = unquote(m[4]);
255
- if (!isSecretName(name) || isNotACredential(value) || isDocsLiteral(value)) continue;
256
239
  const existing = byValue.get(value);
257
- if (existing) {
258
- // A vendor-shaped value that also has a name: keep the vendor rule (it
259
- // can name a console) and gain the name (it says which of the user's
260
- // variables to change).
261
- existing.name ??= name;
262
- continue;
263
- }
264
- byValue.set(value, { value, name, rule: "assigned secret", shaped: false });
240
+ if (existing) existing.name ??= name;
265
241
  }
266
242
 
267
243
  return [...byValue.values()];
@@ -74,7 +74,10 @@ function readJson<T>(path: string): T | null {
74
74
  export function readLeakIdentity(home?: string): LeakIdentity {
75
75
  const existing = readJson<Partial<LeakIdentity>>(auditLeakIdentityFile(home));
76
76
  if (existing && typeof existing.salt === "string" && existing.salt.length >= 32) {
77
- return { salt: existing.salt, dismissed: existing.dismissed ?? [] };
77
+ return {
78
+ salt: existing.salt,
79
+ dismissed: existing.dismissed ?? [],
80
+ };
78
81
  }
79
82
  const minted: LeakIdentity = { salt: randomBytes(32).toString("hex"), dismissed: [] };
80
83
  writeLeakIdentity(minted, home);
@@ -121,7 +124,7 @@ export function writeLeakIdentity(identity: LeakIdentity, home?: string): boolea
121
124
  function sanitizeFinding(raw: unknown): LeakFinding | null {
122
125
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
123
126
  const f = raw as Record<string, unknown>;
124
- // 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
125
128
  // that is not the exact minted shape disqualifies the whole entry.
126
129
  if (typeof f.id !== "string" || !isFindingId(f.id)) return null;
127
130
 
@@ -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`);
@@ -147,14 +147,40 @@ const SECRET_NAME_SUBSTRINGS = [
147
147
  "APIKEY",
148
148
  "PRIVATEKEY",
149
149
  ];
150
- const SECRET_NAME_COMPONENTS = ["KEY", "PASS", "AUTH", "PAT", "SIG", "SIGNATURE", "SESSION", "COOKIE"];
150
+ const SECRET_NAME_COMPONENTS = ["PASS", "AUTH", "PAT", "SIG", "SIGNATURE", "COOKIE"];
151
151
 
152
152
  /**
153
- * `PWD` names a credential only in a COMPOUND identifier. `MYSQL_PWD` is
154
- * MySQL's documented password variable; a bare `PWD` is the shell's working
155
- * directory, which is on every second line of a captured session.
153
+ * Names that mean "credential" only inside a COMPOUND identifier.
154
+ *
155
+ * Every entry here was measured as a dominant false positive on a real 2,664-
156
+ * transcript corpus, and the reason they are demotions rather than deletions is
157
+ * that the SHAPE layer already catches real vendor keys regardless of what they
158
+ * are called. The name layer exists for FIRST-PARTY secrets with no recognisable
159
+ * format — and those essentially always carry a qualified name
160
+ * (`COMPOSIO_API_KEY`, `DB_PASSWORD`), never a bare one. So requiring a compound
161
+ * costs almost nothing real and removes most of the noise.
162
+ *
163
+ * PWD `MYSQL_PWD` is MySQL's documented password variable; a bare `PWD`
164
+ * is the shell's working directory, on every second line of a
165
+ * captured session.
166
+ * KEY a bare `key=` is a React list prop, a map entry, a sort field.
167
+ * 80 findings on the measured corpus, none of them credentials.
168
+ *
169
+ * `SIG` was demoted here too and put back: a bare `sig=` in a URL query IS a
170
+ * request signature, and an existing test says so. It also never appeared in
171
+ * the measured noise, unlike the two above — the rule is that a demotion needs
172
+ * evidence, not a plausible story.
173
+ *
174
+ * `SESSION` is REMOVED from both lists rather than demoted, and the difference
175
+ * matters: demoting it would have changed nothing, because
176
+ * `DBUS_SESSION_BUS_ADDRESS` is already a compound. It was the single largest
177
+ * source of noise measured — 254 of 500 findings, every one of them
178
+ * `DBUS_SESSION_BUS_ADDRESS`, `XDG_SESSION_TYPE`, `SESSION_MANAGER`,
179
+ * `session_id` or `sessionUpdate`. A session IDENTIFIER is not a rotatable
180
+ * credential; a session SECRET still matches through `SECRET`, `TOKEN`, or
181
+ * `KEY` in `sessionKey`.
156
182
  */
157
- const COMPOUND_ONLY_COMPONENTS = ["PWD"];
183
+ const COMPOUND_ONLY_COMPONENTS = ["PWD", "KEY"];
158
184
 
159
185
  /**
160
186
  * Split an identifier into its uppercased components, on `_`, `-`, `.` AND
@@ -300,6 +326,35 @@ export function isPublishedByDesign(name: string): boolean {
300
326
  return PUBLISHED_MARKERS.test(upper);
301
327
  }
302
328
 
329
+ /**
330
+ * How confidently the NAME alone says "credential".
331
+ *
332
+ * `strong` — the word means secret and nothing else: `DB_PASSWORD`,
333
+ * `STRIPE_SECRET`, `PRIVATE_KEY`. A short value here is still worth flagging;
334
+ * `hunter2` assigned to `PASSWORD` is a leaked password.
335
+ *
336
+ * `weak` — the word is ALSO ordinary programming vocabulary: `key`, `token`,
337
+ * `auth`, `sig`. Measured on a real 2,664-transcript machine these produced
338
+ * `keyType`, `tokenLimitCancelled`, `max_output_tokens`, `resultKey`,
339
+ * `configDirKey` — 394 findings from this layer, of which 389 had no vendor
340
+ * prefix and 227 were under 24 characters. Nothing that short is an API key.
341
+ * A weak name has to be backed by a value that actually looks like a secret.
342
+ */
343
+ export type SecretNameStrength = "strong" | "weak" | "none";
344
+
345
+ const STRONG_NAME_WORDS = ["SECRET", "PASSWORD", "PASSWD", "PASSPHRASE", "CREDENTIAL", "APIKEY", "PRIVATEKEY"];
346
+
347
+ export function secretNameStrength(name: string): SecretNameStrength {
348
+ if (!isSecretName(name)) return "none";
349
+ const upper = name.toUpperCase();
350
+ if (STRONG_NAME_WORDS.some((w) => upper.includes(w))) return "strong";
351
+ // `API_KEY` / `apiKey` split into components rather than matching APIKEY as a
352
+ // substring, and they are as unambiguous as the substring form.
353
+ const parts = identifierComponents(name);
354
+ if (parts.includes("API") && parts.includes("KEY")) return "strong";
355
+ return "weak";
356
+ }
357
+
303
358
  export function isSecretName(name: string): boolean {
304
359
  // Runs first and beats every other signal: a value the framework compiles
305
360
  // into the browser bundle is public no matter what the rest of the name says.
@@ -518,6 +573,7 @@ export function redactAuditResult(result: AuditResult, home = homedir()): AuditR
518
573
  // Salted, machine-local ids. They carry no path and cannot be reversed to
519
574
  // a credential, so they pass through — but the decision is recorded here
520
575
  // rather than inherited from a spread.
576
+ leakIds: result.leakIds,
521
577
  newLeakIds: result.newLeakIds,
522
578
 
523
579
  // Everything below carries a path or user content and is rewritten.
@@ -560,5 +616,6 @@ export const REDACTED_AUDIT_RESULT_KEYS: ReadonlyArray<keyof AuditResult> = [
560
616
  "projectsScanned",
561
617
  "eventsScanned",
562
618
  "enabledBuiltinNames",
619
+ "leakIds",
563
620
  "newLeakIds",
564
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)]);
@@ -45,6 +45,9 @@ export interface TranscriptMetadata {
45
45
  mtimeMs: number;
46
46
  /** Byte size of the transcript file. Used for cache invalidation. */
47
47
  sizeBytes: number;
48
+ /** Optional host-authored session purpose. Used only to distinguish
49
+ * deliberate credential-scanner research from evidence of a leak. */
50
+ sessionDescription?: string;
48
51
  }
49
52
 
50
53
  /** Per-session detector state. Detectors mutate this freely. */
@@ -172,6 +175,9 @@ export interface TranscriptAuditResult {
172
175
  * absent reads as "this transcript predates leak scanning", not "clean".
173
176
  */
174
177
  leaks?: TranscriptLeak[];
178
+ /** The session was explicitly generating/researching credential fixtures,
179
+ * so its matches are examples rather than evidence of user leakage. */
180
+ leakScanSuppressed?: "credential-research";
175
181
  /** First/last timestamp per name. */
176
182
  rangeByName: Record<string, { first: string; last: string }>;
177
183
  }
@@ -211,6 +217,11 @@ export interface AuditResult {
211
217
  * Lets the dashboard answer "is this policy already on?" for
212
218
  * detector-mapped policies that may not have hit during this audit. */
213
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[];
214
225
  /**
215
226
  * Ids of credentials seen for the FIRST time in this scan.
216
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.