failproofai 1.0.3 → 1.0.4-beta.2

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 (251) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/prerender-manifest.json +5 -5
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  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/build-manifest.json +2 -2
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  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 +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  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/invite/route_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route_client-reference-manifest.js +1 -1
  28. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/status/route_client-reference-manifest.js +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-verify/route_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +37 -4
  42. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  43. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/index.html +1 -1
  46. package/.next/standalone/.next/server/app/index.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  48. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  56. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  72. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  73. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[externals]__0-9ti7z._.js → [externals]__1lh7m5d._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/{[externals]__0u3wrvq._.js → [externals]__1rqkg_y._.js} +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/{_1-akg6w._.js → _08w6xzm._.js} +2 -2
  80. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
  83. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +23 -0
  99. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/_0l2hi_d._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +47 -13
  103. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -69
  106. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_rerun-button_tsx_0blihgw._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  115. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  116. package/.next/standalone/.next/server/pages/404.html +1 -1
  117. package/.next/standalone/.next/server/pages/500.html +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.json +54 -21
  120. package/.next/standalone/.next/static/chunks/010bv1w6j171t.js +1 -0
  121. package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +1 -0
  122. package/.next/standalone/.next/static/chunks/{29-iqnp9v_axt.css → 0o-hh5_turzlz.css} +1 -1
  123. package/.next/standalone/.next/static/chunks/{40nr6ehj4p3cw.css → 0qmsunv04x4na.css} +1 -1
  124. package/.next/standalone/.next/static/chunks/{0ollg-nnu3t3e.js → 0wz8yftk18ts2.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +1 -0
  126. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1pb1oztsbwcss.js +1 -0
  128. package/.next/standalone/.next/static/chunks/{1-_zkmnm-jwei.js → 2aquitk72k2op.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/2bi_1y0a_smt7.js +6 -0
  130. package/.next/standalone/.next/static/chunks/{42yfkhekx440s.js → 2ej3b8gk5ittu.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{354-n7p7labpn.js → 2zafgs90r_leq.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{2xm5mj1d21b4n.js → 32spub4wqjem-.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{1a6u7l3giejch.js → 3m4upvybtrexd.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/{2k9f4tyv04809.css → 3uyhmm01g64k4.css} +1 -0
  135. package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-3s3c-u_u_l0u9.js} +1 -1
  136. package/.next/standalone/.opencode/plugins/failproofai.mjs +51 -27
  137. package/.next/standalone/app/actions/get-leaks.ts +100 -0
  138. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +112 -33
  139. package/.next/standalone/app/audit/_components/audit-poster.tsx +29 -10
  140. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +6 -1
  141. package/.next/standalone/app/audit/_components/empty-state.tsx +4 -1
  142. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +19 -5
  143. package/.next/standalone/app/audit/_components/leak-section.tsx +179 -0
  144. package/.next/standalone/app/audit/_components/share-templates.ts +110 -44
  145. package/.next/standalone/app/audit/audit-styles.css +114 -1
  146. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +26 -3
  147. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +31 -8
  148. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +1 -0
  149. package/.next/standalone/fp-cloud-cli/pyproject.toml +9 -2
  150. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +54 -0
  151. package/.next/standalone/fp-cloud-cli/uv.lock +9 -12
  152. package/.next/standalone/lib/auth/api-server-client.ts +28 -0
  153. package/.next/standalone/lib/claude-sessions.ts +101 -27
  154. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  155. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  156. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
  157. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
  158. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +1 -1
  159. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +5 -5
  160. package/.next/standalone/node_modules/next/dist/experimental/testmode/fetch.js +10 -2
  161. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  162. package/.next/standalone/node_modules/next/dist/lib/typescript/runTypeScriptCli.js +2 -2
  163. package/.next/standalone/node_modules/next/dist/lib/verify-typescript-setup.js +3 -1
  164. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  165. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  166. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  167. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  168. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +17 -4
  169. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  170. package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/escape-path-delimiters.js +1 -1
  171. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  172. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  173. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  174. package/.next/standalone/node_modules/next/package.json +11 -11
  175. package/.next/standalone/package.json +14 -13
  176. package/.next/standalone/sdk/python/CHANGELOG.md +155 -0
  177. package/.next/standalone/sdk/python/README.md +8 -0
  178. package/.next/standalone/sdk/python/examples/evaluator_worker.py +121 -0
  179. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  180. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +101 -0
  181. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__main__.py +49 -0
  182. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/_sandbox_runner.py +63 -0
  183. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/authoring.py +404 -0
  184. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +299 -0
  185. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/protocol.py +754 -0
  186. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +936 -0
  187. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/source.py +684 -0
  188. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/README.md +28 -0
  189. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/contract.json +252 -0
  190. package/.next/standalone/sdk/python/tests/test_evaluator_authoring.py +124 -0
  191. package/.next/standalone/sdk/python/tests/test_evaluator_client.py +253 -0
  192. package/.next/standalone/sdk/python/tests/test_evaluator_example.py +35 -0
  193. package/.next/standalone/sdk/python/tests/test_evaluator_http_e2e.py +636 -0
  194. package/.next/standalone/sdk/python/tests/test_evaluator_main.py +47 -0
  195. package/.next/standalone/sdk/python/tests/test_evaluator_protocol.py +246 -0
  196. package/.next/standalone/sdk/python/tests/test_evaluator_review_fixes.py +164 -0
  197. package/.next/standalone/sdk/python/tests/test_evaluator_runtime.py +1103 -0
  198. package/.next/standalone/sdk/python/tests/test_evaluator_source.py +430 -0
  199. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +17 -0
  200. package/.next/standalone/sdk/python/uv.lock +29 -29
  201. package/.next/standalone/server.js +1 -1
  202. package/README.md +13 -58
  203. package/dist/cli.mjs +5368 -8756
  204. package/dist/worker.mjs +539 -312
  205. package/lib/auth/api-server-client.ts +28 -0
  206. package/lib/claude-sessions.ts +101 -27
  207. package/package.json +14 -13
  208. package/pi-extension/index.ts +42 -4
  209. package/scripts/changelog-open.py +115 -0
  210. package/src/audit/cli.ts +134 -0
  211. package/src/audit/desktop-notify.ts +420 -0
  212. package/src/audit/harm-report.ts +101 -0
  213. package/src/audit/index.ts +141 -13
  214. package/src/audit/leak-fingerprint.ts +200 -0
  215. package/src/audit/leak-notice.ts +161 -0
  216. package/src/audit/leak-record.ts +232 -0
  217. package/src/audit/leak-scan.ts +292 -0
  218. package/src/audit/leak-store.ts +217 -0
  219. package/src/audit/macos-notifier.ts +310 -0
  220. package/src/audit/redact-example.ts +266 -14
  221. package/src/audit/report-harm.ts +12 -1
  222. package/src/audit/report.ts +6 -3
  223. package/src/audit/schedule-cli.ts +37 -0
  224. package/src/audit/scoring.ts +49 -0
  225. package/src/audit/types.ts +41 -0
  226. package/src/hooks/builtin-policies.ts +67 -2
  227. package/src/hooks/configure-wizard.ts +20 -0
  228. package/src/hooks/fp-config.ts +71 -11
  229. package/src/hooks/fp-home.ts +27 -0
  230. package/src/hooks/handler.ts +59 -1
  231. package/src/hooks/integrations.ts +47 -13
  232. package/src/hooks/notice.ts +155 -0
  233. package/src/hooks/uninstall-cli.ts +15 -0
  234. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1b5jzxr._.js +0 -3
  235. package/.next/standalone/.next/server/chunks/ssr/_0j6gd_k._.js +0 -23
  236. package/.next/standalone/.next/server/chunks/ssr/_0v8eebd._.js +0 -3
  237. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +0 -3
  238. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +0 -3
  239. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_1ao30b1.js +0 -3
  240. package/.next/standalone/.next/static/chunks/03slu0c8_71y7.js +0 -1
  241. package/.next/standalone/.next/static/chunks/0nm11ysn0153h.js +0 -1
  242. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +0 -1
  243. package/.next/standalone/.next/static/chunks/1r9676mkvihto.js +0 -1
  244. package/.next/standalone/.next/static/chunks/30pct5s734jua.js +0 -6
  245. package/.next/standalone/.next/static/chunks/3pibxnbhjza7n.js +0 -69
  246. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +0 -1
  247. package/.next/standalone/.next/static/chunks/3zkg2s2vzxc3d.js +0 -1
  248. package/.next/standalone/.next/static/chunks/41b5ptaocvi_l.js +0 -1
  249. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_buildManifest.js +0 -0
  250. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_clientMiddlewareManifest.js +0 -0
  251. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_ssgManifest.js +0 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Persistence for the leak record.
3
+ *
4
+ * Two files, because the data has two different reset semantics and one class
5
+ * cannot be right for both — the same lesson `fp-home.ts` learned when
6
+ * `session.json` moved into a directory classified wholesale as `derived`:
7
+ *
8
+ * audit/leaks.json derived — every finding is reproducible by
9
+ * rescanning, so losing it costs a scan
10
+ * audit/leak-identity.json identity — the HMAC salt and the user's
11
+ * dismissals, neither of which any
12
+ * rescan can recreate
13
+ *
14
+ * Regenerate the salt and every finding id changes, so a machine that has been
15
+ * quiet for months re-alerts for every credential at once. Drop the dismissals
16
+ * and findings a human already judged not-a-secret come back, which is worse
17
+ * than a missed alert: it teaches them the tool does not listen.
18
+ *
19
+ * ## Never throws
20
+ *
21
+ * A scan must not fail because a cache file is corrupt, unreadable, or written
22
+ * by a newer version. Every read degrades to "no record yet" and every write
23
+ * degrades to a no-op with a logged reason. The audit's job is to find leaks,
24
+ * and refusing to run because its own bookkeeping is damaged would be the
25
+ * failure mode that matters least causing the one that matters most.
26
+ */
27
+ import { randomBytes } from "node:crypto";
28
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
29
+ import { dirname } from "node:path";
30
+
31
+ import { auditLeaksFile, auditLeakIdentityFile } from "../hooks/fp-home";
32
+ import { writeJsonAtomically } from "../../lib/atomic-write";
33
+ import {
34
+ emptyRecord,
35
+ pruneRecord,
36
+ LEAK_RECORD_SCHEMA_VERSION,
37
+ type LeakRecord,
38
+ type LeakFinding,
39
+ type LeakSighting,
40
+ } from "./leak-record";
41
+ import { isFindingId } from "./leak-fingerprint";
42
+
43
+ interface LeakIdentity {
44
+ /** Hex HMAC salt. Generated once, never transmitted, never derived from
45
+ * anything guessable — see `fingerprintId`. */
46
+ salt: string;
47
+ /** Finding ids the user has said are not secrets. */
48
+ dismissed: string[];
49
+ }
50
+
51
+ /** Mode for both files. They carry no credential, but they do carry a machine
52
+ * salt and a map of which projects leaked what — not world-readable. */
53
+ const FILE_MODE = 0o600;
54
+
55
+ function readJson<T>(path: string): T | null {
56
+ try {
57
+ if (!existsSync(path)) return null;
58
+ return JSON.parse(readFileSync(path, "utf8")) as T;
59
+ } catch {
60
+ // Corrupt, truncated by a crash, or written by a version we cannot read.
61
+ // Treated as absent: the next write replaces it.
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The machine's leak identity, minting a salt on first use.
68
+ *
69
+ * `randomBytes(32)`, not a hash of anything about the machine. A salt derived
70
+ * from a hostname or a uid would be reproducible by anyone who knows those, and
71
+ * the whole point of salting the fingerprint is that a third party holding a
72
+ * guessable candidate cannot confirm it by recomputing the digest.
73
+ */
74
+ export function readLeakIdentity(home?: string): LeakIdentity {
75
+ const existing = readJson<Partial<LeakIdentity>>(auditLeakIdentityFile(home));
76
+ if (existing && typeof existing.salt === "string" && existing.salt.length >= 32) {
77
+ return { salt: existing.salt, dismissed: existing.dismissed ?? [] };
78
+ }
79
+ const minted: LeakIdentity = { salt: randomBytes(32).toString("hex"), dismissed: [] };
80
+ writeLeakIdentity(minted, home);
81
+ return minted;
82
+ }
83
+
84
+ export function writeLeakIdentity(identity: LeakIdentity, home?: string): boolean {
85
+ const path = auditLeakIdentityFile(home);
86
+ try {
87
+ mkdirSync(dirname(path), { recursive: true });
88
+ writeJsonAtomically(path, identity, { mode: FILE_MODE });
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Load the record, or an empty one.
97
+ *
98
+ * A record written by a NEWER schema is discarded rather than migrated or
99
+ * partially read: this file is a cache of derived findings, so throwing it away
100
+ * costs one rescan, while guessing at an unknown shape risks reporting a
101
+ * fingerprint built by rules we no longer understand.
102
+ */
103
+ /**
104
+ * Drop a finding this build cannot render, and repair one it can.
105
+ *
106
+ * `leaks.json` is a file on disk. It can be truncated by a full disk mid-write,
107
+ * edited by hand, or written by a NEWER build whose findings carry a shape this
108
+ * one does not expect — and the envelope check below cannot see any of that,
109
+ * because it only looks at `schemaVersion` and `findings` being an array.
110
+ *
111
+ * The failure that motivated this was not hypothetical in its consequences: one
112
+ * malformed entry made `selectLeaks` dereference `undefined`, which threw out
113
+ * of `buildHarmReport`, which sits OUTSIDE `reportHarm`'s try — so a scheduled
114
+ * run that scanned correctly and wrote its cache correctly still exited 1, and
115
+ * would keep doing so on every run until somebody opened the file.
116
+ *
117
+ * A bad SIGHTING loses the sighting, not the finding: the credential is the
118
+ * thing that needs rotating, and "seen in a transcript" with no detail beats
119
+ * silence about a leaked key.
120
+ */
121
+ function sanitizeFinding(raw: unknown): LeakFinding | null {
122
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
123
+ const f = raw as Record<string, unknown>;
124
+ // The id becomes a FILENAME (notice markers, the macOS queue), so anything
125
+ // that is not the exact minted shape disqualifies the whole entry.
126
+ if (typeof f.id !== "string" || !isFindingId(f.id)) return null;
127
+
128
+ const fp = f.fingerprint as Record<string, unknown> | undefined;
129
+ if (!fp || typeof fp !== "object" || typeof fp.display !== "string" || typeof fp.label !== "string") {
130
+ return null; // with no way to name it, a row would say nothing at all
131
+ }
132
+
133
+ const sightings = Array.isArray(f.sightings)
134
+ ? (f.sightings as unknown[]).filter((s): s is LeakSighting => {
135
+ if (!s || typeof s !== "object") return false;
136
+ const m = (s as Record<string, unknown>).mechanism as Record<string, unknown> | undefined;
137
+ return !!m && typeof m === "object" && typeof m.summary === "string";
138
+ })
139
+ : [];
140
+
141
+ return {
142
+ ...(f as unknown as LeakFinding),
143
+ fingerprint: {
144
+ display: fp.display,
145
+ label: fp.label,
146
+ length: typeof fp.length === "number" && Number.isFinite(fp.length) ? fp.length : 0,
147
+ attributed: fp.attributed === true,
148
+ },
149
+ name: typeof f.name === "string" ? f.name : null,
150
+ firstSeen: typeof f.firstSeen === "string" ? f.firstSeen : "",
151
+ lastSeen: typeof f.lastSeen === "string" ? f.lastSeen : "",
152
+ occurrences:
153
+ typeof f.occurrences === "number" && Number.isFinite(f.occurrences) ? f.occurrences : sightings.length,
154
+ sightings,
155
+ };
156
+ }
157
+
158
+ export function readLeakRecord(home?: string): LeakRecord {
159
+ const identity = readLeakIdentity(home);
160
+ const raw = readJson<LeakRecord>(auditLeaksFile(home));
161
+ if (!raw || raw.schemaVersion !== LEAK_RECORD_SCHEMA_VERSION || !Array.isArray(raw.findings)) {
162
+ return emptyRecord(identity.salt, new Date().toISOString());
163
+ }
164
+ // Every consumer — the digest, the dashboard, the notice guard — reads through
165
+ // here, so validating once at the gate is what keeps them all from each
166
+ // needing to defend against the same corrupt file.
167
+ const findings = (raw.findings as unknown[])
168
+ .map(sanitizeFinding)
169
+ .filter((f): f is LeakFinding => f !== null);
170
+ // The salt always comes from the identity file, never from the record — if
171
+ // the two ever disagree the identity file is authoritative, because it is the
172
+ // one classified to survive a reset.
173
+ return { ...raw, findings, salt: identity.salt };
174
+ }
175
+
176
+ /**
177
+ * Persist the record, pruned.
178
+ *
179
+ * Pruning on WRITE rather than on read, so the bound is enforced exactly once
180
+ * per scan and a reader never pays for it. Returns false on failure rather than
181
+ * throwing — see the module header.
182
+ */
183
+ export function writeLeakRecord(record: LeakRecord, home?: string, nowMs = Date.now()): boolean {
184
+ const path = auditLeaksFile(home);
185
+ try {
186
+ mkdirSync(dirname(path), { recursive: true });
187
+ const pruned = pruneRecord(record, nowMs);
188
+ pruned.updatedAt = new Date(nowMs).toISOString();
189
+ writeJsonAtomically(path, pruned, { mode: FILE_MODE });
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ /** Mark a finding as not-a-secret. Survives a reset; see the module header. */
197
+ export function dismissFinding(id: string, home?: string): boolean {
198
+ const identity = readLeakIdentity(home);
199
+ if (identity.dismissed.includes(id)) return true;
200
+ identity.dismissed.push(id);
201
+ return writeLeakIdentity(identity, home);
202
+ }
203
+
204
+ /**
205
+ * Findings worth showing a human: undismissed, newest first.
206
+ *
207
+ * The dismissal list is applied HERE rather than at write time, so a dismissal
208
+ * never destroys evidence — the finding stays in the record and simply stops
209
+ * being surfaced. A user who dismisses by mistake has lost a row in a list, not
210
+ * the fact that a credential leaked.
211
+ */
212
+ export function activeFindings(record: LeakRecord, home?: string): LeakRecord["findings"] {
213
+ const dismissed = new Set(readLeakIdentity(home).dismissed);
214
+ return record.findings
215
+ .filter((f) => !dismissed.has(f.id))
216
+ .sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : a.lastSeen > b.lastSeen ? -1 : 0));
217
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Getting a notification onto a Mac's screen from a process that has no screen.
3
+ *
4
+ * On Linux the scheduled audit talks to the session bus directly and is done
5
+ * (`desktop-notify.ts`). macOS has no equivalent shortcut, and the reason is
6
+ * structural rather than a missing permission: launchd has two domains, and
7
+ * Notification Center lives in only one of them. `failproofaid` is installed as
8
+ * a **LaunchDaemon** — that is what makes it start at boot and survive logout,
9
+ * the property the whole daemon design turns on — so it and every child it
10
+ * spawns run in the *system* domain, outside the logged-in user's GUI session.
11
+ * A `display notification` from there posts into nothing. Apple's TN2083 draws
12
+ * exactly this line, and Time Machine ships the same split: a privileged daemon
13
+ * doing the work, an agent in the user's session doing the talking.
14
+ *
15
+ * So this module installs the second half — a **LaunchAgent**, which launchd
16
+ * starts inside the Aqua session, where Notification Center is reachable.
17
+ *
18
+ * ## Three choices worth stating, because the obvious alternative is wrong
19
+ *
20
+ * **`~/Library/LaunchAgents`, not `/Library/LaunchAgents`.** The per-user
21
+ * directory is owned by the user and writable without elevation, so installing
22
+ * this asks for nothing — no password, no second sudo prompt in a wizard that
23
+ * already spent its one. The system-wide agent directory would need admin and
24
+ * would install for every account on the box, which is not what a per-user
25
+ * notification is.
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.
34
+ *
35
+ * **A watched drop directory, not a resident process.** The agent is not a
36
+ * daemon: `WatchPaths` makes launchd start it only when a file appears, and it
37
+ * exits as soon as the directory is empty. Nothing is resident, nothing polls,
38
+ * and a machine that never leaks a credential never runs it at all.
39
+ *
40
+ * ## What this does NOT claim
41
+ *
42
+ * That the user saw it. macOS can be in Do Not Disturb, the display can be
43
+ * asleep, and the user can deny the bundle in System Settings — none of which
44
+ * is visible from here. The banner is a prompt to look; the record on disk is
45
+ * the thing that lasts.
46
+ */
47
+ import { execFileSync } from "node:child_process";
48
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
+ import { homedir } from "node:os";
50
+ import { resolve } from "node:path";
51
+
52
+ import { runDir } from "../hooks/fp-home";
53
+ import { isFindingId } from "./leak-fingerprint";
54
+
55
+ /** The bundle identifier the banner is attributed to, and the agent's label. */
56
+ export const MAC_NOTIFIER_LABEL = "ai.failproof.notifier";
57
+
58
+ /** Where a queued notification waits for the agent to pick it up. */
59
+ export const macNotifyDir = (home?: string) => resolve(runDir(home), "notify");
60
+
61
+ /** The compiled applet. Under `~/.failproofai` rather than `/Applications`,
62
+ * because it is a delivery mechanism, not something to launch. */
63
+ export function macNotifierAppPath(home?: string): string {
64
+ return resolve(runDir(home), "..", "bin", "FailproofAI Notifier.app");
65
+ }
66
+
67
+ export function macNotifierPlistPath(): string {
68
+ return resolve(homedir(), "Library", "LaunchAgents", `${MAC_NOTIFIER_LABEL}.plist`);
69
+ }
70
+
71
+ function escapeXml(s: string): string {
72
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
73
+ }
74
+
75
+ /**
76
+ * The AppleScript the applet runs: drain the directory, say each line, exit.
77
+ *
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.
81
+ *
82
+ * The file is REMOVED BEFORE the notification is posted, and that order is
83
+ * deliberate: `WatchPaths` fires on every change to the directory, so a file
84
+ * that cannot be displayed and is not removed would wake this agent forever.
85
+ * At-most-once is the right failure here because the caller has already claimed
86
+ * the finding on disk — a dropped banner costs one silent finding, a wake loop
87
+ * costs the machine.
88
+ */
89
+ export function notifierScript(home?: string): string {
90
+ 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
116
+ end run
117
+ `;
118
+ }
119
+
120
+ /**
121
+ * Exported for the same reason `launchdPlistContents` is: it is the only way to
122
+ * assert this file's shape from a Linux CI runner, which is every CI runner we
123
+ * have.
124
+ */
125
+ export function notifierPlistContents(appPath: string, watchDir: string): string {
126
+ return `<?xml version="1.0" encoding="UTF-8"?>
127
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
128
+ <plist version="1.0">
129
+ <dict>
130
+ <key>Label</key>
131
+ <string>${escapeXml(MAC_NOTIFIER_LABEL)}</string>
132
+ <key>ProgramArguments</key>
133
+ <array>
134
+ <string>${escapeXml(resolve(appPath, "Contents", "MacOS", "applet"))}</string>
135
+ </array>
136
+ <!-- Started only when something is queued, and it exits when the queue is
137
+ empty. RunAtLoad would fire it once at login with nothing to say. -->
138
+ <key>WatchPaths</key>
139
+ <array>
140
+ <string>${escapeXml(watchDir)}</string>
141
+ </array>
142
+ <key>RunAtLoad</key>
143
+ <false/>
144
+ <!-- Not a service: it does one pass and exits, and launchd must not treat
145
+ that as a crash to restart. -->
146
+ <key>KeepAlive</key>
147
+ <false/>
148
+ <key>ProcessType</key>
149
+ <string>Interactive</string>
150
+ </dict>
151
+ </plist>
152
+ `;
153
+ }
154
+
155
+ export interface MacNotifierResult {
156
+ installed: boolean;
157
+ reason?: string;
158
+ }
159
+
160
+ /** Bounded, and never inherits a TTY — this runs from inside a wizard. */
161
+ function run(cmd: string, args: string[]): void {
162
+ execFileSync(cmd, args, { stdio: "ignore", timeout: 20_000 });
163
+ }
164
+
165
+ /**
166
+ * Install the notifier, silently.
167
+ *
168
+ * Silent on purpose. An earlier draft printed "installing a small helper so we
169
+ * can notify you about leaks (macOS only)" during setup, which reads as an
170
+ * apology for something the user already asked for by running setup — every
171
+ * other app that can raise a notification installs its delivery path without
172
+ * narrating it. What the user is asked is the thing that matters (does this
173
+ * machine scan, and may it interrupt), and both of those are real settings.
174
+ *
175
+ * 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.
178
+ */
179
+ export function installMacNotifier(home?: string): MacNotifierResult {
180
+ if (process.platform !== "darwin") return { installed: false, reason: "not macOS" };
181
+ try {
182
+ const app = macNotifierAppPath(home);
183
+ const watch = macNotifyDir(home);
184
+ 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.
203
+
204
+ mkdirSync(resolve(macNotifierPlistPath(), ".."), { recursive: true });
205
+ writeFileSync(macNotifierPlistPath(), notifierPlistContents(app, watch), { mode: 0o644 });
206
+
207
+ const target = `gui/${process.getuid?.() ?? 0}`;
208
+ // bootout first: bootstrap fails outright on an already-loaded label, and
209
+ // a reinstall must land the NEW plist rather than leave the old one live.
210
+ try {
211
+ run("launchctl", ["bootout", `${target}/${MAC_NOTIFIER_LABEL}`]);
212
+ } catch {
213
+ // Not loaded. That is the normal first-install case.
214
+ }
215
+ run("launchctl", ["bootstrap", target, macNotifierPlistPath()]);
216
+ return { installed: true };
217
+ } catch (err) {
218
+ return { installed: false, reason: err instanceof Error ? err.message : String(err) };
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Remove the agent and the bundle.
224
+ *
225
+ * Called from `failproofai uninstall --purge`. Unconditional and quiet: an
226
+ * agent left behind after an uninstall is a plist launchd keeps trying to start
227
+ * from a path that no longer exists, which shows up in the user's log forever
228
+ * and is the exact class of leftover an uninstall exists to prevent.
229
+ */
230
+ export function uninstallMacNotifier(home?: string): void {
231
+ if (process.platform !== "darwin") return;
232
+ try {
233
+ run("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/${MAC_NOTIFIER_LABEL}`]);
234
+ } catch {
235
+ // Already gone, or never loaded.
236
+ }
237
+ rmSync(macNotifierPlistPath(), { force: true });
238
+ rmSync(macNotifierAppPath(home), { recursive: true, force: true });
239
+ rmSync(macNotifyDir(home), { recursive: true, force: true });
240
+ }
241
+
242
+ /**
243
+ * Queue one notification for the agent to deliver.
244
+ *
245
+ * Written to a sibling directory and RENAMED in, so the watcher never observes
246
+ * a half-written file: `WatchPaths` fires on the first byte, and a partial read
247
+ * would be a truncated banner that the agent then deletes.
248
+ *
249
+ * Returns whether the file landed — not whether anything was shown, which this
250
+ * side cannot know and does not claim.
251
+ */
252
+ export function queueMacNotification(
253
+ id: string,
254
+ title: string,
255
+ body: string,
256
+ home?: string,
257
+ ): boolean {
258
+ // The id becomes a filename in a watched directory. Same guard, same reason
259
+ // as `markLeakNoticeDelivered` — see `isFindingId`.
260
+ if (!isFindingId(id)) return false;
261
+ const dir = macNotifyDir(home);
262
+ const tmp = resolve(dir, "..", `notify-${id}.tmp`);
263
+ try {
264
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
265
+ // One line each. Newlines inside either field would be read as extra body
266
+ // lines by the applet, so they are flattened here rather than there.
267
+ const payload = `${title.replace(/\s+/g, " ")}\n${body.replace(/\s+/g, " ")}\n`;
268
+ writeFileSync(tmp, payload, { mode: 0o600 });
269
+ renameSync(tmp, resolve(dir, id));
270
+ return true;
271
+ } catch {
272
+ // Clean up the staging file. Without this, every failed queue attempt left
273
+ // a `notify-*.tmp` beside the watched directory forever — invisible to the
274
+ // agent, which only reads inside it, and never collected by anything.
275
+ try {
276
+ rmSync(tmp, { force: true });
277
+ } catch { /* nothing left to do */ }
278
+ return false;
279
+ }
280
+ }
281
+
282
+ /** True when an agent is installed and could pick a queued file up. */
283
+ export function macNotifierInstalled(home?: string): boolean {
284
+ return existsSync(macNotifierPlistPath()) && existsSync(macNotifierAppPath(home));
285
+ }
286
+
287
+ /**
288
+ * Drop anything the agent never collected.
289
+ *
290
+ * A machine that queued a banner while logged out, or with the agent removed,
291
+ * would otherwise show a pile of stale findings at the next login — "we found
292
+ * this key" for keys already rotated weeks ago.
293
+ */
294
+ export function pruneMacNotifyQueue(home?: string, maxAgeMs = 7 * 86_400_000): void {
295
+ try {
296
+ const dir = macNotifyDir(home);
297
+ if (!existsSync(dir)) return;
298
+ const { statSync } = require("node:fs") as typeof import("node:fs");
299
+ for (const name of readdirSync(dir)) {
300
+ const path = resolve(dir, name);
301
+ try {
302
+ if (Date.now() - statSync(path).mtimeMs > maxAgeMs) rmSync(path, { force: true });
303
+ } catch {
304
+ // Raced with the agent collecting it. Nothing to do.
305
+ }
306
+ }
307
+ } catch {
308
+ // Housekeeping only.
309
+ }
310
+ }