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
@@ -16,6 +16,11 @@ import { INTEGRATION_TYPES, type IntegrationType } from "../hooks/types";
16
16
  import { ADAPTERS } from "./cli-adapters";
17
17
  import { AUDIT_DETECTORS } from "./detectors";
18
18
  import { severityForBuiltin } from "./features";
19
+ import { findSecrets, flattenToolInput } from "./leak-scan";
20
+ import { fingerprintSecret, fingerprintId } from "./leak-fingerprint";
21
+ import { readLeakIdentity, readLeakRecord, writeLeakRecord } from "./leak-store";
22
+ import { upsertFinding, describeMechanism } from "./leak-record";
23
+ import { shortenPaths } from "./redact-example";
19
24
  import { readCachedTranscript, writeCachedTranscriptResult } from "./cache";
20
25
  import { initReplay, replayEvent, restoreReplay } from "./replay";
21
26
  import {
@@ -183,19 +188,23 @@ async function scanOneTranscript(
183
188
  result.cwd = result.cwd || events[0].cwd || "";
184
189
 
185
190
  for (const event of events) {
186
- // Run audit detectors first (stateful, must see every event).
187
- for (const detector of AUDIT_DETECTORS) {
188
- const hit = detector.detect(event, sessionState);
189
- if (!hit) continue;
190
- recordHit(
191
- result,
192
- detector.name,
193
- event.timestamp,
194
- event.cwd,
195
- truncateExample(hit.example),
196
- );
197
- }
198
- // Then replay through every builtin policy.
191
+ // The 8 behavioural detectors are switched off with the rest of the old
192
+ // audit see the header of `src/audit/scoring.ts`. The modules and their
193
+ // unit tests are untouched; nothing calls them. `sessionState` is still
194
+ // threaded through so a resumed scan keeps its shape and restoring this
195
+ // loop needs no plumbing.
196
+ // for (const detector of AUDIT_DETECTORS) {
197
+ // const hit = detector.detect(event, sessionState);
198
+ // if (!hit) continue;
199
+ // recordHit(
200
+ // result,
201
+ // detector.name,
202
+ // event.timestamp,
203
+ // event.cwd,
204
+ // truncateExample(hit.example),
205
+ // );
206
+ // }
207
+ // Replay through every builtin policy.
199
208
  let replayHits;
200
209
  try {
201
210
  replayHits = await replayEvent(event);
@@ -212,11 +221,117 @@ async function scanOneTranscript(
212
221
  truncateExample(example),
213
222
  );
214
223
  }
224
+
225
+ // Credential VALUES, which the replay above cannot report: it returns a
226
+ // decision per policy, never the text that matched. Scanned separately so
227
+ // the leak report can name WHICH key, and so detection can be tuned for
228
+ // precision without dragging the redactor's recall down with it.
229
+ recordLeaks(result, event);
215
230
  }
216
231
 
217
232
  return { result, bytesScanned, detectorState: sessionState, resumed };
218
233
  }
219
234
 
235
+ /**
236
+ * Fingerprint every credential in one event and attach the sightings.
237
+ *
238
+ * The raw value exists only inside this function. It is read out of the event,
239
+ * hashed into a salted id, rendered into a mask, and dropped — nothing that
240
+ * leaves here can reconstruct it.
241
+ *
242
+ * Input and result are scanned separately and tagged, because they are
243
+ * different exposures: an input is what the agent SENT (deniable at PreToolUse
244
+ * on 12/12 CLIs, and 39.5% of measured appearances) while a result is what it
245
+ * RECEIVED (blockable on 2/12, and only after the fact).
246
+ */
247
+ function recordLeaks(result: TranscriptAuditResult, event: NormalizedToolEvent): void {
248
+ const salt = leakSalt();
249
+ if (!salt) return;
250
+
251
+ const filePath = (event.toolInput as { file_path?: unknown }).file_path;
252
+ const shortPath = typeof filePath === "string" ? shortenPaths(filePath) : null;
253
+
254
+ const scan = (text: string, direction: "input" | "result"): void => {
255
+ for (const match of findSecrets(text)) {
256
+ (result.leaks ??= []).push({
257
+ id: fingerprintId(match.value, salt),
258
+ fingerprint: fingerprintSecret(match.value, match.rule),
259
+ name: match.name,
260
+ rule: match.rule,
261
+ shaped: match.shaped,
262
+ timestamp: event.timestamp,
263
+ cwd: event.cwd,
264
+ toolName: event.toolName,
265
+ direction,
266
+ path: shortPath,
267
+ });
268
+ }
269
+ };
270
+
271
+ scan(flattenToolInput(event.toolInput), "input");
272
+ if (event.toolResultText !== undefined) scan(event.toolResultText, "result");
273
+ }
274
+
275
+ /**
276
+ * The machine's HMAC salt, read once per process.
277
+ *
278
+ * Cached because `scanOneTranscript` runs this per EVENT — 340,918 of them on
279
+ * the measured corpus — and re-reading a file that many times would dominate
280
+ * the scan. `null` when the store is unreadable, which disables leak recording
281
+ * rather than failing the audit: a scan that cannot fingerprint is still a scan
282
+ * that can count.
283
+ */
284
+ let cachedSalt: string | null | undefined;
285
+ function leakSalt(): string | null {
286
+ if (cachedSalt === undefined) {
287
+ try {
288
+ cachedSalt = readLeakIdentity().salt;
289
+ } catch {
290
+ cachedSalt = null;
291
+ }
292
+ }
293
+ return cachedSalt;
294
+ }
295
+
296
+ /**
297
+ * Merge this scan's sightings into `~/.failproofai/audit/leaks.json`.
298
+ *
299
+ * Returns the ids seen for the FIRST time, which is the set worth telling a
300
+ * human about. Never throws: a scan that finds leaks it cannot write down has
301
+ * still found them, and the caller reports on the in-memory result either way.
302
+ */
303
+ function persistLeaks(perTranscript: TranscriptAuditResult[]): string[] {
304
+ const fresh: string[] = [];
305
+ try {
306
+ const record = readLeakRecord();
307
+ for (const t of perTranscript) {
308
+ for (const leak of t.leaks ?? []) {
309
+ const { isNew } = upsertFinding(record, {
310
+ id: leak.id,
311
+ fingerprint: leak.fingerprint,
312
+ name: leak.name,
313
+ rule: leak.rule,
314
+ // A vendor shape is what licenses an alert to name a console; a
315
+ // name-only match knows something leaked, not who issued it.
316
+ confidence: leak.shaped ? "doc-verified" : "name-only",
317
+ sighting: {
318
+ cli: t.cli,
319
+ sessionId: t.sessionId,
320
+ cwd: shortenPaths(leak.cwd),
321
+ at: leak.timestamp,
322
+ mechanism: describeMechanism(leak.toolName, leak.direction, leak.path),
323
+ },
324
+ });
325
+ if (isNew) fresh.push(leak.id);
326
+ }
327
+ }
328
+ writeLeakRecord(record);
329
+ } catch {
330
+ // Bookkeeping failure must not fail the audit — see leak-store.ts.
331
+ }
332
+ return fresh;
333
+ }
334
+
220
335
  function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): string {
221
336
  if (event.toolName === "Bash") {
222
337
  const command = (event.toolInput as { command?: unknown }).command;
@@ -527,8 +642,21 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
527
642
  eventsScanned += t.eventsScanned ?? 0;
528
643
  }
529
644
 
645
+ // Fold every sighting into the persistent record. This is the ONLY place the
646
+ // leak record is written: `scanOneTranscript` runs 8-way concurrent and a
647
+ // per-transcript write would be eight processes racing one file — the exact
648
+ // shape that lost updates when it was measured (2 concurrent writers, 5/5
649
+ // trials, the loser's entry gone permanently).
650
+ //
651
+ // `newFindingIds` is what a notice keys on: an id already in the record must
652
+ // not alert again however many fresh sightings it accumulates, and a
653
+ // credential seen for the first time must alert even though its rule has
654
+ // fired a thousand times before.
655
+ const newFindingIds = persistLeaks(perTranscript);
656
+
530
657
  const auditResult: AuditResult = {
531
658
  version: 2,
659
+ newLeakIds: newFindingIds,
532
660
  scannedAt: new Date(startedAt).toISOString(),
533
661
  scope: {
534
662
  cli: clis,
@@ -0,0 +1,200 @@
1
+ /**
2
+ * How a leaked credential is NAMED once it leaves the machine.
3
+ *
4
+ * Every other redactor in this codebase exists to destroy a value. This one
5
+ * exists to describe one, because the leak report has the opposite job: a
6
+ * finding the reader cannot tie to a row in a vendor console is a finding they
7
+ * cannot act on. `[REDACTED: GitHub token]` tells you nothing you can revoke.
8
+ *
9
+ * The rule is: **enough to identify, never enough to use.**
10
+ *
11
+ * ## Why the mask character matters, which is not obvious
12
+ *
13
+ * The three most natural masking characters — `X`, `x` and `0` — RE-MATCH every
14
+ * vendor prefix pattern we ship. `AKIA` + sixteen `X`s satisfies
15
+ * `AKIA[A-Z0-9]{16}`, so a masked AWS key is reported by failproofai as a live
16
+ * AWS key on the next scan. The same holds for a masked connection string,
17
+ * whose pattern keys on `://…@` and does not care what sits between. So the
18
+ * mask is `•` (U+2022), which is in no credential charset anywhere, and the
19
+ * unit tests assert that every masked form fails to re-match.
20
+ *
21
+ * That is not a hypothetical: this product measured its own audit output
22
+ * feeding back into its own corpus, one leaked credential becoming seven
23
+ * reported findings across four sessions.
24
+ *
25
+ * ## Why the last four, and why not always
26
+ *
27
+ * Last-4 is what vendor consoles display next to a key, so it is the token that
28
+ * lets a human find the right row and press revoke. But it is only safe when
29
+ * what remains is genuinely unguessable. For a 40-character GitHub token,
30
+ * showing `ghp_` and four trailing characters leaves ~32 unknown base62
31
+ * characters — no help to anyone. For a 13-character human-chosen password,
32
+ * last-4 is nearly a third of the secret, and the corpus's one confirmed-live
33
+ * password was exactly 13 characters at 3.19 bits per character.
34
+ *
35
+ * So the tail is shown only when the value clears BOTH bars: a recognised
36
+ * public vendor prefix (which proves it is a minted token, not a typed
37
+ * password) AND enough residual length. Everything else is described by class
38
+ * and length alone.
39
+ */
40
+
41
+ /** The mask glyph. Never `X`, `x` or `0` — see the module header. */
42
+ const MASK_CHAR = "•";
43
+
44
+ /** Longest run of mask glyphs rendered, so a 200-char token stays readable. */
45
+ const MAX_MASK_RUN = 8;
46
+
47
+ /**
48
+ * Minimum total length before any trailing characters are shown.
49
+ *
50
+ * 24 is chosen so that even the shortest minted token we recognise keeps well
51
+ * over 100 bits of residual entropy after the prefix and tail are removed.
52
+ * Human-chosen passwords essentially never reach it, which is the point.
53
+ */
54
+ const MIN_LEN_FOR_TAIL = 24;
55
+
56
+ /** How many trailing characters a vendor console typically shows. */
57
+ const TAIL_CHARS = 4;
58
+
59
+ /**
60
+ * Public, minted prefixes — the part of a credential that carries no secret.
61
+ *
62
+ * Each of these is published by its issuer and identical across every key they
63
+ * mint, so showing it discloses nothing while telling the reader which console
64
+ * to open. Ordered longest-first, so `sk-ant-api03-` wins over `sk-ant-` and
65
+ * `sk-`; the first match is the one rendered.
66
+ *
67
+ * Deliberately NOT the full detection vocabulary. This list only has to be
68
+ * right about what is safe to PRINT — a prefix missing from it costs a less
69
+ * specific label, never a disclosure.
70
+ */
71
+ const PUBLIC_PREFIXES: ReadonlyArray<readonly [string, string]> = [
72
+ ["sk-ant-api03-", "Anthropic API key"],
73
+ ["sk-ant-oat01-", "Anthropic OAuth token"],
74
+ ["sk-ant-", "Anthropic key"],
75
+ ["sk-proj-", "OpenAI project key"],
76
+ ["sk-svcacct-", "OpenAI service-account key"],
77
+ ["sk-admin-", "OpenAI admin key"],
78
+ ["sk-or-v1-", "OpenRouter key"],
79
+ ["github_pat_", "GitHub fine-grained token"],
80
+ ["ghp_", "GitHub personal access token"],
81
+ ["gho_", "GitHub OAuth token"],
82
+ ["ghu_", "GitHub user-to-server token"],
83
+ ["ghs_", "GitHub server-to-server token"],
84
+ ["ghr_", "GitHub refresh token"],
85
+ ["glpat-", "GitLab personal access token"],
86
+ ["xoxb-", "Slack bot token"],
87
+ ["xoxp-", "Slack user token"],
88
+ ["xapp-", "Slack app-level token"],
89
+ ["sk_live_", "Stripe live secret key"],
90
+ ["sk_test_", "Stripe test secret key"],
91
+ ["rk_live_", "Stripe restricted key"],
92
+ ["AKIA", "AWS access key ID"],
93
+ ["ASIA", "AWS temporary access key ID"],
94
+ ["AIza", "Google API key"],
95
+ ["hf_", "Hugging Face token"],
96
+ ["gsk_", "Groq API key"],
97
+ ["r8_", "Replicate token"],
98
+ ["xai-", "xAI key"],
99
+ ["npm_", "npm access token"],
100
+ ["dop_v1_", "DigitalOcean token"],
101
+ ["shpat_", "Shopify access token"],
102
+ ["sbp_", "Supabase token"],
103
+ ];
104
+
105
+ export interface LeakFingerprint {
106
+ /** What the reader sees: `ghp_••••••••4f2a` or `[13-char password]`. */
107
+ display: string;
108
+ /** Human label for the class, e.g. "GitHub personal access token". */
109
+ label: string;
110
+ /** Total character length of the original value. */
111
+ length: number;
112
+ /** True when a minted public prefix was recognised — i.e. we can name a
113
+ * console to revoke at. False means "we know it leaked, not who issued it". */
114
+ attributed: boolean;
115
+ }
116
+
117
+ /** The recognised public prefix for a value, longest match first. */
118
+ function matchPublicPrefix(value: string): readonly [string, string] | null {
119
+ for (const entry of PUBLIC_PREFIXES) {
120
+ if (value.startsWith(entry[0])) return entry;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Render a credential as something a human can recognise and nobody can use.
127
+ *
128
+ * `fallbackLabel` names the class when no public prefix is recognised — pass
129
+ * the detecting rule's own label (e.g. "assigned secret", "database password")
130
+ * so an unattributed finding still says what KIND of thing leaked.
131
+ */
132
+ export function fingerprintSecret(value: string, fallbackLabel = "secret"): LeakFingerprint {
133
+ const length = value.length;
134
+ const matched = matchPublicPrefix(value);
135
+
136
+ if (!matched) {
137
+ // No minted prefix: this is a password, a first-party key, or a format we
138
+ // do not recognise. Nothing but the class and the length is safe to print,
139
+ // and the length is what lets the owner recognise their own value.
140
+ return {
141
+ display: `[${length}-char ${fallbackLabel}]`,
142
+ label: fallbackLabel,
143
+ length,
144
+ attributed: false,
145
+ };
146
+ }
147
+
148
+ const [prefix, label] = matched;
149
+ const bodyLength = length - prefix.length;
150
+ const run = MASK_CHAR.repeat(Math.max(1, Math.min(MAX_MASK_RUN, bodyLength)));
151
+
152
+ // The tail is the actionable half — but only when what stays hidden is
153
+ // genuinely unguessable. See the module header.
154
+ if (length >= MIN_LEN_FOR_TAIL && bodyLength > TAIL_CHARS * 2) {
155
+ return {
156
+ display: `${prefix}${run}${value.slice(-TAIL_CHARS)}`,
157
+ label,
158
+ length,
159
+ attributed: true,
160
+ };
161
+ }
162
+
163
+ return { display: `${prefix}${run}`, label, length, attributed: true };
164
+ }
165
+
166
+ /**
167
+ * A stable identity for one distinct credential, for deduping across scans.
168
+ *
169
+ * Salted with a per-machine secret so the digest cannot be used as a lookup
170
+ * oracle: without the salt, an attacker holding a guessable candidate (and the
171
+ * corpus is full of dictionary passwords) could confirm it by comparing
172
+ * hashes. With it, the id is meaningful only on the machine that produced it.
173
+ *
174
+ * The salt therefore forecloses cross-machine dedupe until an org-scoped key
175
+ * exists — a deliberate trade recorded here so nobody reads a per-machine salt
176
+ * as an oversight.
177
+ */
178
+ /**
179
+ * Every id `fingerprintId` can produce, and nothing else.
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.
185
+ *
186
+ * Ids are HMAC hex today, so nothing in a normal run can carry a `/`. That is
187
+ * not the same as safe: the record is JSON read off disk, and `leaks.json` is a
188
+ * file a user (or anything running as them) can edit. The guard is one regex
189
+ * and it makes the property structural instead of incidental.
190
+ */
191
+ export function isFindingId(id: string): boolean {
192
+ return /^[0-9a-f]{16}$/.test(id);
193
+ }
194
+
195
+ export function fingerprintId(value: string, machineSalt: string): string {
196
+ // Lazily required so this module stays importable in the browser bundle,
197
+ // where the dashboard renders findings but never computes ids.
198
+ const { createHmac } = require("node:crypto") as typeof import("node:crypto");
199
+ return createHmac("sha256", machineSalt).update(value).digest("hex").slice(0, 16);
200
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Deciding whether a finding still owes the user a notice — exactly once.
3
+ *
4
+ * ## Why this is not a field on the record
5
+ *
6
+ * The obvious design is `notifiedAt` on each finding, written back after the
7
+ * notice goes out. Measured against this repo's real `writeJsonAtomically`
8
+ * semantics, that fails 100% of the time under the conditions it actually runs
9
+ * in: two concurrent sessions with the same finding produced two notices, four
10
+ * produced four, eight produced eight. Worse, two concurrent sessions with
11
+ * DIFFERENT findings produced two notices and a surviving watermark containing
12
+ * only one of them — the loser's mark permanently gone, so it re-notified
13
+ * forever afterwards. `lib/atomic-write.ts` says so in its own header: atomic
14
+ * per FILE, last-writer-wins per CONTENT, i.e. a lost update on a map.
15
+ *
16
+ * Running several agents at once in one project is not an edge case here; it is
17
+ * how the tool is used.
18
+ *
19
+ * So the claim is one marker FILE per finding, created with `wx` — O_EXCL,
20
+ * which the kernel makes atomic across processes. Eight concurrent claimants
21
+ * produce exactly one winner, verified 3/3 trials. Concurrent claims on
22
+ * different findings all succeed and all survive, because they touch different
23
+ * files.
24
+ *
25
+ * ## Claim ordering, and which way to fail
26
+ *
27
+ * The marker is written BEFORE the notice reaches the stream, and the caller
28
+ * only keeps it if the notice was actually shaped. The alternative orderings
29
+ * were both measured: emit-then-mark nags forever if the process dies between
30
+ * them (5 sessions → 5 notices), and mark-then-never-emit swallows the alert
31
+ * permanently with nothing on disk saying so (total delivered: 0).
32
+ *
33
+ * Between "told twice" and "never told", a security tool picks told twice.
34
+ */
35
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, rmSync } from "node:fs";
36
+ import { resolve } from "node:path";
37
+
38
+ import { auditDir } from "../hooks/fp-home";
39
+ import { readLeakRecord, activeFindings } from "./leak-store";
40
+ import { isFindingId } from "./leak-fingerprint";
41
+
42
+ /**
43
+ * Which way the user was told. Each channel claims independently.
44
+ *
45
+ * They are NOT one guarantee. The desktop banner and the in-CLI notice reach a
46
+ * person at different moments and fail in unrelated ways — and the banner has a
47
+ * failure it cannot detect at all: on a locked screen the shell accepts the
48
+ * call and returns an id while nobody sees anything. Letting a successful
49
+ * `Notify` claim the finding would then silence the in-session notice too, and
50
+ * the only channel that actually reached a human is the one we suppressed.
51
+ *
52
+ * So each channel gets its own marker directory and its own once-per-finding
53
+ * guarantee. A user at a desktop sees the banner AND the next session's notice;
54
+ * that is a bounded, deliberate duplicate, and it is the right side of the
55
+ * trade this file already made once (told twice beats never told).
56
+ */
57
+ export type NoticeChannel = "cli" | "desktop";
58
+
59
+ /** One empty file per claimed finding, per channel. Cheap: a few hundred
60
+ * zero-byte files at the very worst, and the record itself is capped at 500
61
+ * findings. "cli" keeps the original directory name so an existing machine's
62
+ * claims survive the upgrade — renaming it would re-notify for every finding
63
+ * already delivered. */
64
+ const NOTICE_DIR = (home?: string, channel: NoticeChannel = "cli") =>
65
+ resolve(auditDir(home), channel === "cli" ? "notified" : `notified-${channel}`);
66
+
67
+ /** Markers older than this are swept, so the directory cannot grow forever on a
68
+ * machine that has been running for years. Comfortably longer than the
69
+ * record's own 90-day TTL, so a marker never outlives its finding. */
70
+ const MARKER_TTL_MS = 120 * 86_400_000;
71
+
72
+ export interface PendingNotice {
73
+ /** Findings that have never been notified about and are not dismissed. */
74
+ ids: string[];
75
+ count: number;
76
+ }
77
+
78
+ /**
79
+ * Findings that still owe the user a notice.
80
+ *
81
+ * Reads the record and subtracts what has already been claimed. Never throws:
82
+ * on any failure it returns "nothing pending", because a hook that cannot read
83
+ * its bookkeeping must stay silent rather than guess.
84
+ */
85
+ export function pendingLeakNotice(home?: string, channel: NoticeChannel = "cli"): PendingNotice {
86
+ try {
87
+ const dir = NOTICE_DIR(home, channel);
88
+ const claimed = new Set(existsSync(dir) ? readdirSync(dir) : []);
89
+ const ids = activeFindings(readLeakRecord(home), home)
90
+ .map((f) => f.id)
91
+ .filter((id) => !claimed.has(id));
92
+ return { ids, count: ids.length };
93
+ } catch {
94
+ return { ids: [], count: 0 };
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Claim these findings, atomically and per-finding.
100
+ *
101
+ * Returns the ids this process actually won. A caller that loses a race simply
102
+ * gets a shorter list — it never blocks, and it never overwrites a claim made
103
+ * by a concurrent session.
104
+ */
105
+ export function markLeakNoticeDelivered(
106
+ ids: string[],
107
+ home?: string,
108
+ channel: NoticeChannel = "cli",
109
+ ): string[] {
110
+ const won: string[] = [];
111
+ try {
112
+ const dir = NOTICE_DIR(home, channel);
113
+ mkdirSync(dir, { recursive: true });
114
+ for (const id of ids) {
115
+ // An id is about to become a filename. Anything that is not the exact
116
+ // shape `fingerprintId` mints is refused rather than resolved — see
117
+ // `isFindingId`. Refusing also means NOT reporting it as won, so a caller
118
+ // treats it as unclaimed and never records it as delivered.
119
+ if (!isFindingId(id)) continue;
120
+ try {
121
+ // `wx` is O_EXCL: it throws EEXIST if another process got here first.
122
+ // That throw IS the lock — there is no read-then-write window for two
123
+ // processes to both pass through.
124
+ writeFileSync(resolve(dir, id), "", { flag: "wx", mode: 0o600 });
125
+ won.push(id);
126
+ } catch {
127
+ // Already claimed by a concurrent session. Correct outcome: the user
128
+ // is told once, by whichever session got there first.
129
+ }
130
+ }
131
+ } catch {
132
+ // Cannot claim at all — the caller has already decided to emit, and being
133
+ // told twice beats never being told.
134
+ }
135
+ return won;
136
+ }
137
+
138
+ /** Drop markers for findings that no longer exist, and very old ones. */
139
+ export function pruneNoticeMarkers(home?: string, nowMs = Date.now()): void {
140
+ try {
141
+ const live = new Set(readLeakRecord(home).findings.map((f) => f.id));
142
+ for (const channel of ["cli", "desktop"] as const) {
143
+ const dir = NOTICE_DIR(home, channel);
144
+ if (!existsSync(dir)) continue;
145
+ for (const name of readdirSync(dir)) {
146
+ const path = resolve(dir, name);
147
+ let stale = !live.has(name);
148
+ if (!stale) {
149
+ try {
150
+ stale = nowMs - statSync(path).mtimeMs > MARKER_TTL_MS;
151
+ } catch {
152
+ stale = false;
153
+ }
154
+ }
155
+ if (stale) rmSync(path, { force: true });
156
+ }
157
+ }
158
+ } catch {
159
+ // Housekeeping only.
160
+ }
161
+ }