failproofai 1.0.4-beta.4 → 1.0.4-beta.7

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 (170) 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 +15 -4
  62. package/.next/standalone/.next/server/app/settings/page.js +4 -4
  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 +11 -11
  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]__0yl9wb5._.js → [root-of-the-server]__12hbpha._.js} +2 -2
  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/_04ng_6x._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/{_1u8-lu2._.js → _07t3jol._.js} +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/{_0guxnh-._.js → _0a0zlpr._.js} +2 -2
  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_builtin-policies_ts_09j2ndl._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  106. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  107. package/.next/standalone/.next/server/pages/404.html +1 -1
  108. package/.next/standalone/.next/server/pages/500.html +1 -1
  109. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  110. package/.next/standalone/.next/server/server-reference-manifest.json +33 -22
  111. package/.next/standalone/.next/static/chunks/{17tfki9x5rd_7.js → 083fk73jtv9a0.js} +1 -1
  112. package/.next/standalone/.next/static/chunks/{1i-i0pxg9q_8y.js → 0divvxng208xo.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{38dqur43qthrl.js → 0f1pznnc7zlub.js} +1 -1
  114. package/.next/standalone/.next/static/chunks/{323cyb3a2s06y.js → 0ntdfr2552x-h.js} +1 -1
  115. package/.next/standalone/.next/static/chunks/{0po8n4tyotjf2.js → 0t6ea7d3bkmdv.js} +1 -1
  116. package/.next/standalone/.next/static/chunks/{364cqbpeg1y5i.js → 15uon66hpwbgg.js} +1 -1
  117. package/.next/standalone/.next/static/chunks/{42gwu2bsz4ske.js → 1csubx9gwhsf6.js} +2 -2
  118. package/.next/standalone/.next/static/chunks/27du9n7ssja7l.js +1 -0
  119. package/.next/standalone/.next/static/chunks/2h_pcb1y8_jde.js +1 -0
  120. package/.next/standalone/.next/static/chunks/{2bxr2_2h4kaqq.js → 2pwfrn6_mw-1u.js} +1 -1
  121. package/.next/standalone/app/actions/get-leaks.ts +0 -5
  122. package/.next/standalone/app/actions/get-scheduled-audit.ts +5 -0
  123. package/.next/standalone/app/actions/update-scheduled-audit.ts +18 -57
  124. package/.next/standalone/app/api/auth/status/route.ts +3 -2
  125. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +1 -1
  126. package/.next/standalone/app/audit/_components/empty-state.tsx +1 -1
  127. package/.next/standalone/app/audit/_components/leak-section.tsx +1 -1
  128. package/.next/standalone/app/settings/page.tsx +1 -1
  129. package/.next/standalone/app/settings/settings-client.tsx +50 -45
  130. package/.next/standalone/lib/auth/api-server-client.ts +6 -6
  131. package/.next/standalone/lib/codex-sessions.ts +20 -4
  132. package/.next/standalone/lib/copilot-sessions.ts +32 -1
  133. package/.next/standalone/lib/cursor-sessions.ts +62 -5
  134. package/.next/standalone/package.json +9 -9
  135. package/.next/standalone/server.js +1 -1
  136. package/dist/cli.mjs +991 -1101
  137. package/dist/worker.mjs +175 -382
  138. package/lib/auth/api-server-client.ts +6 -6
  139. package/lib/codex-sessions.ts +20 -4
  140. package/lib/copilot-sessions.ts +32 -1
  141. package/lib/cursor-sessions.ts +62 -5
  142. package/package.json +9 -9
  143. package/pi-extension/index.ts +10 -18
  144. package/src/audit/cli.ts +72 -81
  145. package/src/audit/harm-report.ts +12 -6
  146. package/src/audit/index.ts +3 -1
  147. package/src/audit/leak-fingerprint.ts +2 -4
  148. package/src/audit/leak-record.ts +26 -10
  149. package/src/audit/leak-scan.ts +1 -1
  150. package/src/audit/leak-store.ts +1 -46
  151. package/src/audit/machine-store.ts +8 -9
  152. package/src/audit/macos-notifier.ts +56 -68
  153. package/src/audit/redact-example.ts +2 -0
  154. package/src/audit/report-harm.ts +31 -46
  155. package/src/audit/schedule-cli.ts +17 -33
  156. package/src/audit/types.ts +5 -0
  157. package/src/hooks/builtin-policies.ts +2 -2
  158. package/src/hooks/configure-wizard.ts +36 -1
  159. package/src/hooks/fp-config.ts +29 -54
  160. package/src/hooks/handler.ts +1 -79
  161. package/src/hooks/integrations.ts +4 -15
  162. package/src/hooks/notice.ts +12 -179
  163. package/src/hooks/uninstall-cli.ts +3 -3
  164. package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +0 -3
  165. package/.next/standalone/.next/static/chunks/04o5zfl7p5lhx.js +0 -1
  166. package/.next/standalone/.next/static/chunks/0qx7gav1c2_vu.js +0 -1
  167. package/src/audit/leak-notice.ts +0 -259
  168. /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → YiPsVpjrc-pEQoN7XvhZ0}/_buildManifest.js +0 -0
  169. /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → YiPsVpjrc-pEQoN7XvhZ0}/_clientMiddlewareManifest.js +0 -0
  170. /package/.next/standalone/.next/static/{P6icI2V2PvVuePJrqjHCr → YiPsVpjrc-pEQoN7XvhZ0}/_ssgManifest.js +0 -0
@@ -70,7 +70,7 @@ const TRANSCRIPT_BYTE_BUDGET = 48 * 1024 * 1024;
70
70
  * otherwise the largest transcript on the machine could never be scanned, which
71
71
  * is precisely the one most likely to hold something.
72
72
  */
73
- class ByteGate {
73
+ export class ByteGate {
74
74
  private inFlight = 0;
75
75
  private waiting: Array<() => void> = [];
76
76
 
@@ -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
@@ -152,19 +152,30 @@ export function upsertFinding(
152
152
  return { record, isNew: true };
153
153
  }
154
154
 
155
+ // Keep the earliest sighting AND the most recent evidence. The report/email
156
+ // promises the CLI and timestamp of the latest exposure; keeping only the
157
+ // first five made those fields stale forever after a busy credential crossed
158
+ // the cap. Middle sightings are the expendable ones.
159
+ const seen = existing.sightings.some(
160
+ (s) =>
161
+ s.sessionId === sighting.sessionId &&
162
+ s.at === sighting.at &&
163
+ s.mechanism.direction === sighting.mechanism.direction &&
164
+ s.mechanism.summary === sighting.mechanism.summary,
165
+ );
166
+ // Cached transcript results are folded through this function again. An
167
+ // identical sighting is therefore not a new exposure and must not inflate
168
+ // the count each time the same cache entry is reused.
169
+ if (seen) return { record, isNew: false };
170
+
155
171
  existing.occurrences += 1;
156
172
  if (sighting.at > existing.lastSeen) existing.lastSeen = sighting.at;
157
173
  if (sighting.at < existing.firstSeen) existing.firstSeen = sighting.at;
158
174
 
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);
175
+ existing.sightings.push(sighting);
176
+ existing.sightings.sort((a, b) => a.at.localeCompare(b.at));
177
+ if (existing.sightings.length > MAX_SIGHTINGS) {
178
+ existing.sightings.splice(1, existing.sightings.length - MAX_SIGHTINGS);
168
179
  }
169
180
  return { record, isNew: false };
170
181
  }
@@ -177,7 +188,12 @@ export function upsertFinding(
177
188
  */
178
189
  export function pruneRecord(record: LeakRecord, nowMs: number): LeakRecord {
179
190
  const cutoff = new Date(nowMs - FINDING_TTL_DAYS * 86_400_000).toISOString();
180
- const live = record.findings.filter((f) => f.lastSeen >= cutoff);
191
+ // A missing or malformed timestamp cannot be aged out safely. Keep it until
192
+ // the bounded-cap pass below rather than silently deleting a finding that the
193
+ // reporting path deliberately surfaces as having an unknown date.
194
+ const live = record.findings.filter(
195
+ (f) => !f.lastSeen || !Number.isFinite(Date.parse(f.lastSeen)) || f.lastSeen >= cutoff,
196
+ );
181
197
  live.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : a.lastSeen > b.lastSeen ? -1 : 0));
182
198
  record.findings = live.slice(0, MAX_FINDINGS);
183
199
  return record;
@@ -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.
@@ -24,13 +24,13 @@
24
24
  * would install for every account on the box, which is not what a per-user
25
25
  * notification is.
26
26
  *
27
- * **An app bundle, not a bare `osascript -e 'display notification'`.** A raw
28
- * osascript notification is attributed to whatever host ran it — in practice
29
- * "Script Editor" — so it inherits Script Editor's notification permission,
30
- * appears under Script Editor in System Settings, and is indistinguishable from
31
- * any other script on the machine. Compiling a tiny applet with `osacompile`
32
- * gives it its own bundle identifier, so the banner says failproofai, and the
33
- * user gets a real entry they can allow, silence or deny like any other app.
27
+ * **A user-session `osascript`, not an applet binary launched directly.** The
28
+ * compiled applet path looked attractive because it could carry a FailproofAI
29
+ * bundle identifier, but launching `Contents/MacOS/applet` directly from
30
+ * launchd is not a dependable Notification Center client on current macOS.
31
+ * `osascript` from a user LaunchAgent is the same mechanism ordinary scheduled
32
+ * jobs use successfully. Delivery matters more than owning the label shown by
33
+ * Notification Center, so the runner uses that proven path.
34
34
  *
35
35
  * **A watched drop directory, not a resident process.** The agent is not a
36
36
  * daemon: `WatchPaths` makes launchd start it only when a file appears, and it
@@ -45,25 +45,29 @@
45
45
  * the thing that lasts.
46
46
  */
47
47
  import { execFileSync } from "node:child_process";
48
- import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
48
+ import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
49
  import { homedir } from "node:os";
50
50
  import { resolve } from "node:path";
51
51
 
52
52
  import { runDir } from "../hooks/fp-home";
53
53
  import { isFindingId } from "./leak-fingerprint";
54
54
 
55
- /** The bundle identifier the banner is attributed to, and the agent's label. */
55
+ /** Stable label used to load, inspect, and remove the per-user LaunchAgent. */
56
56
  export const MAC_NOTIFIER_LABEL = "ai.failproof.notifier";
57
57
 
58
58
  /** Where a queued notification waits for the agent to pick it up. */
59
59
  export const macNotifyDir = (home?: string) => resolve(runDir(home), "notify");
60
60
 
61
- /** The compiled applet. Under `~/.failproofai` rather than `/Applications`,
62
- * because it is a delivery mechanism, not something to launch. */
61
+ /** Legacy compiled applet path, retained so upgrades and uninstall remove it. */
63
62
  export function macNotifierAppPath(home?: string): string {
64
63
  return resolve(runDir(home), "..", "bin", "FailproofAI Notifier.app");
65
64
  }
66
65
 
66
+ /** User-session runner invoked by launchd when a notification is queued. */
67
+ export function macNotifierRunnerPath(home?: string): string {
68
+ return resolve(runDir(home), "..", "bin", "failproofai-notifier.zsh");
69
+ }
70
+
67
71
  export function macNotifierPlistPath(): string {
68
72
  return resolve(homedir(), "Library", "LaunchAgents", `${MAC_NOTIFIER_LABEL}.plist`);
69
73
  }
@@ -73,11 +77,11 @@ function escapeXml(s: string): string {
73
77
  }
74
78
 
75
79
  /**
76
- * The AppleScript the applet runs: drain the directory, say each line, exit.
80
+ * The user-session runner: drain the directory, say each notification, exit.
77
81
  *
78
- * Payload format is two lines — title, then body — chosen over JSON because
79
- * AppleScript has no JSON parser and shelling out to one would put a second
80
- * interpreter in the path of a two-field message.
82
+ * Payload format is two lines — title, then body. Values are passed to
83
+ * AppleScript as argv, never interpolated into source, so notification text
84
+ * cannot become AppleScript code.
81
85
  *
82
86
  * The file is REMOVED BEFORE the notification is posted, and that order is
83
87
  * deliberate: `WatchPaths` fires on every change to the directory, so a file
@@ -88,32 +92,25 @@ function escapeXml(s: string): string {
88
92
  */
89
93
  export function notifierScript(home?: string): string {
90
94
  const dir = macNotifyDir(home);
91
- return `on run
92
- set dropDir to ${JSON.stringify(dir + "/")}
93
- set listing to ""
94
- try
95
- set listing to do shell script "ls -1 " & quoted form of dropDir & " 2>/dev/null"
96
- end try
97
- repeat with entryName in paragraphs of listing
98
- set entryPath to dropDir & (entryName as string)
99
- if (entryName as string) is not "" then
100
- try
101
- set payload to do shell script "cat " & quoted form of entryPath & " 2>/dev/null"
102
- do shell script "rm -f " & quoted form of entryPath
103
- set lines_ to paragraphs of payload
104
- if (count of lines_) is greater than 1 then
105
- set noteTitle to item 1 of lines_
106
- set noteBody to ""
107
- repeat with i from 2 to (count of lines_)
108
- if noteBody is not "" then set noteBody to noteBody & " "
109
- set noteBody to noteBody & (item i of lines_)
110
- end repeat
111
- display notification noteBody with title noteTitle
112
- end if
113
- end try
114
- end if
115
- end repeat
95
+ const quotedDir = `'${dir.replace(/'/g, `'\\''`)}'`;
96
+ return `#!/bin/zsh
97
+ set -u
98
+ setopt NULL_GLOB
99
+
100
+ drop_dir=${quotedDir}
101
+
102
+ for entry_path in "$drop_dir"/*; do
103
+ [ -f "$entry_path" ] || continue
104
+ note_title=$(/usr/bin/sed -n '1p' "$entry_path")
105
+ note_body=$(/usr/bin/sed -n '2,$p' "$entry_path" | /usr/bin/tr '\\n' ' ')
106
+ /bin/rm -f "$entry_path"
107
+ [ -n "$note_title" ] || continue
108
+ /usr/bin/osascript - "$note_title" "$note_body" <<'APPLESCRIPT'
109
+ on run argv
110
+ display notification (item 2 of argv) with title (item 1 of argv)
116
111
  end run
112
+ APPLESCRIPT
113
+ done
117
114
  `;
118
115
  }
119
116
 
@@ -122,7 +119,7 @@ end run
122
119
  * assert this file's shape from a Linux CI runner, which is every CI runner we
123
120
  * have.
124
121
  */
125
- export function notifierPlistContents(appPath: string, watchDir: string): string {
122
+ export function notifierPlistContents(runnerPath: string, watchDir: string): string {
126
123
  return `<?xml version="1.0" encoding="UTF-8"?>
127
124
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
128
125
  <plist version="1.0">
@@ -131,7 +128,7 @@ export function notifierPlistContents(appPath: string, watchDir: string): string
131
128
  <string>${escapeXml(MAC_NOTIFIER_LABEL)}</string>
132
129
  <key>ProgramArguments</key>
133
130
  <array>
134
- <string>${escapeXml(resolve(appPath, "Contents", "MacOS", "applet"))}</string>
131
+ <string>${escapeXml(runnerPath)}</string>
135
132
  </array>
136
133
  <!-- Started only when something is queued, and it exits when the queue is
137
134
  empty. RunAtLoad would fire it once at login with nothing to say. -->
@@ -173,36 +170,26 @@ function run(cmd: string, args: string[]): void {
173
170
  * machine scan, and may it interrupt), and both of those are real settings.
174
171
  *
175
172
  * Returns rather than throws. Notifications are one channel of several, and a
176
- * Mac that cannot compile an applet must still finish setting up its daemon,
177
- * its hooks and its policies — the parts that actually enforce anything.
173
+ * Mac that cannot load the user LaunchAgent must still finish setting up its
174
+ * daemon, hooks and policies — the parts that actually enforce anything.
178
175
  */
179
176
  export function installMacNotifier(home?: string): MacNotifierResult {
180
177
  if (process.platform !== "darwin") return { installed: false, reason: "not macOS" };
181
178
  try {
182
- const app = macNotifierAppPath(home);
179
+ const runner = macNotifierRunnerPath(home);
183
180
  const watch = macNotifyDir(home);
184
181
  mkdirSync(watch, { recursive: true, mode: 0o700 });
185
- mkdirSync(resolve(app, ".."), { recursive: true });
186
-
187
- const scriptPath = resolve(watch, "..", "notifier.applescript");
188
- writeFileSync(scriptPath, notifierScript(home), { mode: 0o600 });
189
- // osacompile refuses to overwrite an existing bundle, so a reinstall (a
190
- // second `failproofai config`, or an upgrade) has to clear it first.
191
- rmSync(app, { recursive: true, force: true });
192
- run("osacompile", ["-o", app, scriptPath]);
193
- rmSync(scriptPath, { force: true });
194
-
195
- // Give the bundle its own identity, so the banner is attributed to
196
- // failproofai instead of to whatever compiled it, and so the user gets one
197
- // entry in System Settings > Notifications they can turn off themselves.
198
- const plist = resolve(app, "Contents", "Info.plist");
199
- run("defaults", ["write", plist, "CFBundleIdentifier", MAC_NOTIFIER_LABEL]);
200
- run("defaults", ["write", plist, "CFBundleName", "failproofai"]);
201
- // `defaults write` leaves the file 0600 and owned by us but in binary
202
- // format; launchd and LaunchServices both read either, so no conversion.
182
+ mkdirSync(resolve(runner, ".."), { recursive: true });
183
+ writeFileSync(runner, notifierScript(home), { mode: 0o700 });
184
+ // writeFileSync preserves an existing file's mode. Repair it explicitly on
185
+ // upgrades from a partially-created or hand-edited runner.
186
+ chmodSync(runner, 0o700);
187
+ // Remove the applet used by older builds. The LaunchAgent below no longer
188
+ // references it, and leaving it behind makes diagnosis ambiguous.
189
+ rmSync(macNotifierAppPath(home), { recursive: true, force: true });
203
190
 
204
191
  mkdirSync(resolve(macNotifierPlistPath(), ".."), { recursive: true });
205
- writeFileSync(macNotifierPlistPath(), notifierPlistContents(app, watch), { mode: 0o644 });
192
+ writeFileSync(macNotifierPlistPath(), notifierPlistContents(runner, watch), { mode: 0o644 });
206
193
 
207
194
  const target = `gui/${process.getuid?.() ?? 0}`;
208
195
  // bootout first: bootstrap fails outright on an already-loaded label, and
@@ -220,7 +207,7 @@ export function installMacNotifier(home?: string): MacNotifierResult {
220
207
  }
221
208
 
222
209
  /**
223
- * Remove the agent and the bundle.
210
+ * Remove the agent and its runner, including the applet used by older builds.
224
211
  *
225
212
  * Called from `failproofai uninstall --purge`. Unconditional and quiet: an
226
213
  * agent left behind after an uninstall is a plist launchd keeps trying to start
@@ -236,6 +223,7 @@ export function uninstallMacNotifier(home?: string): void {
236
223
  }
237
224
  rmSync(macNotifierPlistPath(), { force: true });
238
225
  rmSync(macNotifierAppPath(home), { recursive: true, force: true });
226
+ rmSync(macNotifierRunnerPath(home), { force: true });
239
227
  rmSync(macNotifyDir(home), { recursive: true, force: true });
240
228
  }
241
229
 
@@ -255,8 +243,8 @@ export function queueMacNotification(
255
243
  body: string,
256
244
  home?: string,
257
245
  ): boolean {
258
- // The id becomes a filename in a watched directory. Same guard, same reason
259
- // as `markLeakNoticeDelivered` — see `isFindingId`.
246
+ // The id becomes a filename in a watched directory, so validate the path
247
+ // component before resolving it — see `isFindingId`.
260
248
  if (!isFindingId(id)) return false;
261
249
  const dir = macNotifyDir(home);
262
250
  const tmp = resolve(dir, "..", `notify-${id}.tmp`);
@@ -281,7 +269,7 @@ export function queueMacNotification(
281
269
 
282
270
  /** True when an agent is installed and could pick a queued file up. */
283
271
  export function macNotifierInstalled(home?: string): boolean {
284
- return existsSync(macNotifierPlistPath()) && existsSync(macNotifierAppPath(home));
272
+ return existsSync(macNotifierPlistPath()) && existsSync(macNotifierRunnerPath(home));
285
273
  }
286
274
 
287
275
  /**
@@ -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
  }