failproofai 1.0.4-beta.1 → 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 (340) 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 +1 -1
  39. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  42. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +37 -4
  43. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  44. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/index.html +1 -1
  47. package/.next/standalone/.next/server/app/index.rsc +15 -15
  48. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  49. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  50. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  51. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  53. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  57. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  58. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  61. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/page.js +3 -3
  63. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  72. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js +1 -1
  74. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  75. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  76. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  77. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  78. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/chunks/{[externals]__1zndo1i._.js → [externals]__1lh7m5d._.js} +1 -1
  81. package/.next/standalone/.next/server/chunks/{[externals]__0_gzk8q._.js → [externals]__1rqkg_y._.js} +1 -1
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08dchfu._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cuho4x._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +3 -0
  87. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p-qi2t._.js +5 -0
  88. package/.next/standalone/.next/server/chunks/_08w6xzm._.js +3 -0
  89. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +14 -21
  93. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00-s7h8._.js +3 -0
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0wvusu0._.js → [root-of-the-server]__0n_lxhg._.js} +2 -2
  106. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +3 -0
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__190won8._.js +3 -0
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +3 -0
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  113. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1sc84bb._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zl_r0t._.js +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +23 -0
  117. package/.next/standalone/.next/server/chunks/ssr/_0-yi74u._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  119. package/.next/standalone/.next/server/chunks/ssr/_0l2hi_d._.js +3 -0
  120. package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +3 -0
  121. package/.next/standalone/.next/server/chunks/ssr/_0wso5d3._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +48 -168
  123. package/.next/standalone/.next/server/chunks/ssr/{_08f0jd2._.js → _1if9ixi._.js} +1 -1
  124. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  126. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -69
  127. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_rerun-button_tsx_0blihgw._.js +3 -0
  128. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  129. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  130. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  131. package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
  132. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
  133. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  134. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  135. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  136. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  137. package/.next/standalone/.next/server/pages/404.html +1 -1
  138. package/.next/standalone/.next/server/pages/500.html +1 -1
  139. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  140. package/.next/standalone/.next/server/server-reference-manifest.json +54 -21
  141. package/.next/standalone/.next/static/chunks/{1_bxotmf9qngs.js → 010bv1w6j171t.js} +1 -1
  142. package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +1 -0
  143. package/.next/standalone/.next/static/chunks/{29-iqnp9v_axt.css → 0o-hh5_turzlz.css} +1 -1
  144. package/.next/standalone/.next/static/chunks/0qmsunv04x4na.css +2 -0
  145. package/.next/standalone/.next/static/chunks/{00gv0v91mycm-.js → 0wz8yftk18ts2.js} +1 -1
  146. package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +1 -0
  147. package/.next/standalone/.next/static/chunks/{0qn9khv0oo9ex.js → 1pb1oztsbwcss.js} +1 -1
  148. package/.next/standalone/.next/static/chunks/2aquitk72k2op.js +1 -0
  149. package/.next/standalone/.next/static/chunks/2bi_1y0a_smt7.js +6 -0
  150. package/.next/standalone/.next/static/chunks/{1po3lirgk2riw.js → 2ej3b8gk5ittu.js} +1 -1
  151. package/.next/standalone/.next/static/chunks/{01hvwwrhvbzyh.js → 2zafgs90r_leq.js} +1 -1
  152. package/.next/standalone/.next/static/chunks/{1_2jsatfuthrw.js → 32spub4wqjem-.js} +1 -1
  153. package/.next/standalone/.next/static/chunks/{3_--3ibia6ba1.js → 3l36d73kj18zw.js} +1 -1
  154. package/.next/standalone/.next/static/chunks/{2bvwsyu6x__ia.js → 3m4upvybtrexd.js} +1 -1
  155. package/.next/standalone/.next/static/chunks/{2k9f4tyv04809.css → 3uyhmm01g64k4.css} +1 -0
  156. package/.next/standalone/.next/static/chunks/{2bnf_4pgyk1rq.js → 40lpt2o2b283e.js} +1 -1
  157. package/.next/standalone/.next/static/chunks/{turbopack-18qf4jodtft6b.js → turbopack-3s3c-u_u_l0u9.js} +1 -1
  158. package/.next/standalone/.opencode/plugins/failproofai.mjs +51 -27
  159. package/.next/standalone/app/actions/get-leaks.ts +100 -0
  160. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +112 -33
  161. package/.next/standalone/app/audit/_components/audit-poster.tsx +29 -10
  162. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +6 -1
  163. package/.next/standalone/app/audit/_components/empty-state.tsx +4 -1
  164. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +19 -5
  165. package/.next/standalone/app/audit/_components/leak-section.tsx +179 -0
  166. package/.next/standalone/app/audit/_components/share-templates.ts +110 -44
  167. package/.next/standalone/app/audit/audit-styles.css +114 -1
  168. package/.next/standalone/app/project/[name]/page.tsx +4 -24
  169. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +26 -3
  170. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +31 -8
  171. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +1 -0
  172. package/.next/standalone/fp-cloud-cli/pyproject.toml +9 -2
  173. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +54 -0
  174. package/.next/standalone/fp-cloud-cli/uv.lock +9 -12
  175. package/.next/standalone/lib/auth/api-server-client.ts +28 -0
  176. package/.next/standalone/lib/claude-sessions.ts +101 -27
  177. package/.next/standalone/lib/cli-registry.ts +1 -33
  178. package/.next/standalone/lib/download-session.ts +0 -37
  179. package/.next/standalone/lib/projects.ts +3 -27
  180. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  181. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  182. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
  183. package/.next/standalone/node_modules/next/dist/experimental/testmode/fetch.js +10 -2
  184. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  185. package/.next/standalone/node_modules/next/dist/lib/typescript/runTypeScriptCli.js +2 -2
  186. package/.next/standalone/node_modules/next/dist/lib/verify-typescript-setup.js +3 -1
  187. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  188. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  189. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  190. package/.next/standalone/node_modules/next/dist/server/image-optimizer.js +2 -2
  191. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  192. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  193. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  194. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  195. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  196. package/.next/standalone/node_modules/next/package.json +11 -11
  197. package/.next/standalone/package.json +15 -14
  198. package/.next/standalone/sdk/python/CHANGELOG.md +155 -0
  199. package/.next/standalone/sdk/python/README.md +8 -0
  200. package/.next/standalone/sdk/python/examples/evaluator_worker.py +121 -0
  201. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  202. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +101 -0
  203. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__main__.py +49 -0
  204. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/_sandbox_runner.py +63 -0
  205. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/authoring.py +404 -0
  206. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +299 -0
  207. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/protocol.py +754 -0
  208. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +936 -0
  209. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/source.py +684 -0
  210. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/README.md +28 -0
  211. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/contract.json +252 -0
  212. package/.next/standalone/sdk/python/tests/test_evaluator_authoring.py +124 -0
  213. package/.next/standalone/sdk/python/tests/test_evaluator_client.py +253 -0
  214. package/.next/standalone/sdk/python/tests/test_evaluator_example.py +35 -0
  215. package/.next/standalone/sdk/python/tests/test_evaluator_http_e2e.py +636 -0
  216. package/.next/standalone/sdk/python/tests/test_evaluator_main.py +47 -0
  217. package/.next/standalone/sdk/python/tests/test_evaluator_protocol.py +246 -0
  218. package/.next/standalone/sdk/python/tests/test_evaluator_review_fixes.py +164 -0
  219. package/.next/standalone/sdk/python/tests/test_evaluator_runtime.py +1103 -0
  220. package/.next/standalone/sdk/python/tests/test_evaluator_source.py +430 -0
  221. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +17 -0
  222. package/.next/standalone/sdk/python/uv.lock +18 -18
  223. package/.next/standalone/server.js +1 -1
  224. package/README.md +19 -95
  225. package/bin/failproofai.mjs +7 -24
  226. package/dist/cli.mjs +19910 -25855
  227. package/dist/worker.mjs +4818 -6344
  228. package/lib/auth/api-server-client.ts +28 -0
  229. package/lib/claude-sessions.ts +101 -27
  230. package/lib/cli-registry.ts +1 -33
  231. package/lib/download-session.ts +0 -37
  232. package/lib/projects.ts +3 -27
  233. package/package.json +15 -14
  234. package/pi-extension/index.ts +42 -4
  235. package/scripts/changelog-open.py +115 -0
  236. package/src/audit/cli-adapters/index.ts +0 -24
  237. package/src/audit/cli.ts +134 -0
  238. package/src/audit/desktop-notify.ts +420 -0
  239. package/src/audit/harm-report.ts +101 -0
  240. package/src/audit/index.ts +141 -13
  241. package/src/audit/leak-fingerprint.ts +200 -0
  242. package/src/audit/leak-notice.ts +161 -0
  243. package/src/audit/leak-record.ts +232 -0
  244. package/src/audit/leak-scan.ts +292 -0
  245. package/src/audit/leak-store.ts +217 -0
  246. package/src/audit/macos-notifier.ts +310 -0
  247. package/src/audit/redact-example.ts +266 -14
  248. package/src/audit/report-harm.ts +12 -1
  249. package/src/audit/report.ts +6 -3
  250. package/src/audit/schedule-cli.ts +37 -0
  251. package/src/audit/scoring.ts +49 -0
  252. package/src/audit/types.ts +41 -0
  253. package/src/hooks/builtin-policies.ts +71 -8
  254. package/src/hooks/configure-wizard.ts +20 -0
  255. package/src/hooks/enforcement-capability.ts +0 -97
  256. package/src/hooks/fp-config.ts +71 -11
  257. package/src/hooks/fp-home.ts +27 -0
  258. package/src/hooks/handler.ts +64 -62
  259. package/src/hooks/harness-cli.ts +1 -3
  260. package/src/hooks/integrations.ts +49 -769
  261. package/src/hooks/normalize-cli-payload.ts +0 -126
  262. package/src/hooks/notice.ts +155 -0
  263. package/src/hooks/policy-evaluator.ts +6 -297
  264. package/src/hooks/resolve-transcript-path.ts +0 -10
  265. package/src/hooks/tool-name-canonicalize.ts +0 -64
  266. package/src/hooks/types.ts +2 -533
  267. package/src/hooks/uninstall-cli.ts +15 -0
  268. package/.next/standalone/.grok/hooks/failproofai.json +0 -172
  269. package/.next/standalone/.next/server/chunks/[root-of-the-server]__07hor7h._.js +0 -5
  270. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0elsuli._.js +0 -3
  271. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0gnj_qa._.js +0 -3
  272. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ljuzm_._.js +0 -3
  273. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0w23bpg._.js +0 -3
  274. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0x5c7sh._.js +0 -3
  275. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yik9_w._.js +0 -3
  276. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1jg6s9m._.js +0 -3
  277. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1s6qci9._.js +0 -3
  278. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1xfm05z._.js +0 -3
  279. package/.next/standalone/.next/server/chunks/_0tdy_6l._.js +0 -3
  280. package/.next/standalone/.next/server/chunks/lib_grok-projects_ts_1i2epl_._.js +0 -3
  281. package/.next/standalone/.next/server/chunks/lib_qwen-projects_ts_0wu_a8e._.js +0 -3
  282. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0_4gcz8._.js +0 -3
  283. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0d--wmy._.js +0 -3
  284. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qqrlf_._.js +0 -3
  285. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s11fx1._.js +0 -3
  286. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gamwq._.js +0 -3
  287. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1bbk-si._.js +0 -3
  288. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1c3ybxr._.js +0 -3
  289. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1izi7zn._.js +0 -3
  290. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__21bj7uw._.js +0 -3
  291. package/.next/standalone/.next/server/chunks/ssr/_00z5uuq._.js +0 -3
  292. package/.next/standalone/.next/server/chunks/ssr/_1byck5v._.js +0 -23
  293. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +0 -3
  294. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +0 -3
  295. package/.next/standalone/.next/server/chunks/ssr/lib_cline-projects_ts_13_hbdd._.js +0 -3
  296. package/.next/standalone/.next/server/chunks/ssr/lib_grok-projects_ts_0rc-d0o._.js +0 -3
  297. package/.next/standalone/.next/server/chunks/ssr/lib_ori-projects_ts_04tofqo._.js +0 -3
  298. package/.next/standalone/.next/server/chunks/ssr/lib_qwen-projects_ts_0w3i9o7._.js +0 -3
  299. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_1ao30b1.js +0 -3
  300. package/.next/standalone/.next/static/chunks/0hzmxwb2pvk5p.js +0 -6
  301. package/.next/standalone/.next/static/chunks/0j884dwnwtyrt.js +0 -1
  302. package/.next/standalone/.next/static/chunks/0mr9dg3bnloa1.js +0 -1
  303. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +0 -1
  304. package/.next/standalone/.next/static/chunks/3gf1jg-5m-u1t.css +0 -2
  305. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +0 -1
  306. package/.next/standalone/.next/static/chunks/3ws7eljkq25wg.js +0 -69
  307. package/.next/standalone/.next/static/chunks/3zkg2s2vzxc3d.js +0 -1
  308. package/.next/standalone/.qwen/settings.json +0 -251
  309. package/.next/standalone/assets/logos/cline.png +0 -0
  310. package/.next/standalone/assets/logos/grok-dark.svg +0 -1
  311. package/.next/standalone/assets/logos/grok-light.svg +0 -1
  312. package/.next/standalone/assets/logos/ori-dark.svg +0 -1
  313. package/.next/standalone/assets/logos/ori-light.svg +0 -1
  314. package/.next/standalone/assets/logos/qwen.svg +0 -1
  315. package/.next/standalone/lib/cline-projects.ts +0 -155
  316. package/.next/standalone/lib/cline-sessions.ts +0 -268
  317. package/.next/standalone/lib/grok-projects.ts +0 -123
  318. package/.next/standalone/lib/grok-sessions.ts +0 -355
  319. package/.next/standalone/lib/ori-projects.ts +0 -141
  320. package/.next/standalone/lib/ori-sessions.ts +0 -221
  321. package/.next/standalone/lib/qwen-projects.ts +0 -106
  322. package/.next/standalone/lib/qwen-sessions.ts +0 -313
  323. package/lib/cline-projects.ts +0 -155
  324. package/lib/cline-sessions.ts +0 -268
  325. package/lib/grok-projects.ts +0 -123
  326. package/lib/grok-sessions.ts +0 -355
  327. package/lib/ori-projects.ts +0 -141
  328. package/lib/ori-sessions.ts +0 -221
  329. package/lib/qwen-projects.ts +0 -106
  330. package/lib/qwen-sessions.ts +0 -313
  331. package/src/audit/cli-adapters/cline.ts +0 -50
  332. package/src/audit/cli-adapters/grok.ts +0 -70
  333. package/src/audit/cli-adapters/ori.ts +0 -55
  334. package/src/audit/cli-adapters/qwen.ts +0 -71
  335. package/src/hooks/batch-expand.ts +0 -380
  336. package/src/hooks/batch-fanout.ts +0 -142
  337. package/src/hooks/risk-patterns.ts +0 -26
  338. /package/.next/standalone/.next/static/{qJx3mbOQV-5ElrcRb4Sp7 → aKNv4Kmv98Xpwns0Xa-C-}/_buildManifest.js +0 -0
  339. /package/.next/standalone/.next/static/{qJx3mbOQV-5ElrcRb4Sp7 → aKNv4Kmv98Xpwns0Xa-C-}/_clientMiddlewareManifest.js +0 -0
  340. /package/.next/standalone/.next/static/{qJx3mbOQV-5ElrcRb4Sp7 → aKNv4Kmv98Xpwns0Xa-C-}/_ssgManifest.js +0 -0
package/src/audit/cli.ts CHANGED
@@ -35,6 +35,10 @@ import { getInstanceId } from "../../lib/telemetry-id";
35
35
  import { sanitizeErrorMessage } from "../../lib/telemetry-sanitize";
36
36
  import { openWhenReady } from "./open-browser";
37
37
  import { describeOutcome, reportHarm } from "./report-harm";
38
+ import { notifyDesktop } from "./desktop-notify";
39
+ import { macNotifierInstalled, pruneMacNotifyQueue, queueMacNotification } from "./macos-notifier";
40
+ import { markLeakNoticeDelivered } from "./leak-notice";
41
+ import { readConfig } from "../hooks/fp-config";
38
42
  import { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM, helpScreen, helpOptsFor } from "../hooks/tui";
39
43
  import { version } from "../../package.json";
40
44
 
@@ -106,6 +110,12 @@ export function helpText(): string {
106
110
  // is not a flag anybody can read or copy.
107
111
  ["--email <address>", "With --schedule, skips the sign-in prompt."],
108
112
  ["--no-schedule", "Stop the timer. Leaves you signed in."],
113
+ // Its own pair of flags rather than a clause on --schedule: whether
114
+ // this machine scans and whether it may interrupt you are separate
115
+ // decisions, and nobody silencing a banner should have to re-state
116
+ // their schedule to do it.
117
+ ["--notify", "Show a desktop notification when a scan finds a credential. On by default."],
118
+ ["--no-notify", "Stop those notifications. Scans and the digest are unaffected."],
109
119
  ["--status", "Whether scheduling is on, where reports go, the daemon's state, and when the next scan is due."],
110
120
  ["-h, --help", "Show this help."],
111
121
  ],
@@ -334,6 +344,115 @@ function heldByLine(held: AuditLockInfo | null): string {
334
344
  return `another audit is already running (pid ${held.pid}, started by ${held.source} ${ageS}s ago)`;
335
345
  }
336
346
 
347
+ /**
348
+ * Raise a desktop banner for credentials this scan found and nothing has
349
+ * announced yet.
350
+ *
351
+ * Scheduled runs only, and the reason is the same one that keeps `reportHarm`
352
+ * here: an interactive `failproofai audit` prints its findings to a person who
353
+ * is already looking at them, so a banner on top is noise. The scheduled run is
354
+ * the one with nobody watching, which is exactly when a notification is the
355
+ * only thing that reaches anyone.
356
+ *
357
+ * Every failure is swallowed on purpose. This is an announcement about a
358
+ * completed scan, not part of the scan: a machine with no desktop, a locked
359
+ * session, a bus that hung — none of those make the audit itself a failure, and
360
+ * turning a good scan into exit 1 would make the scheduler back off from
361
+ * running the thing that actually matters.
362
+ */
363
+ async function announceLeaksOnDesktop(result: AuditResult): Promise<void> {
364
+ try {
365
+ await announceLeaksOrThrow(result);
366
+ } catch (err) {
367
+ // The catch is not decoration. `notifyDesktop` documents that it never
368
+ // throws, but it is doing socket I/O, and so are the config read and the
369
+ // marker write beside it — depending on three modules' promises to keep a
370
+ // completed scan from reporting failure is a dependency this does not need
371
+ // to have.
372
+ process.stderr.write(
373
+ `failproofai: could not announce a leak on the desktop: ` +
374
+ `${err instanceof Error ? err.message : String(err)}\n`,
375
+ );
376
+ }
377
+ }
378
+
379
+ async function announceLeaksOrThrow(result: AuditResult): Promise<void> {
380
+ const ids = result.newLeakIds ?? [];
381
+ if (ids.length === 0) return;
382
+ // Read at fire time, not at scan time: the switch answers "should this
383
+ // interrupt me", and it is the user's most recent answer that counts.
384
+ if (!readConfig().audit.notify) return;
385
+
386
+ // Claimed BEFORE the call goes out, and in the desktop channel only. Before,
387
+ // because a process that dies mid-notify would otherwise re-announce the same
388
+ // finding on every scheduled run forever; desktop-only, because a banner the
389
+ // user may never have seen must not also silence the in-session notice.
390
+ const claimed = markLeakNoticeDelivered(ids, undefined, "desktop");
391
+ if (claimed.length === 0) return;
392
+
393
+ const n = claimed.length === 1 ? "a credential" : `${claimed.length} credentials`;
394
+ const title = "failproofai found a leaked credential";
395
+ // Fixed text with a count interpolated — the same rule as the in-CLI notice,
396
+ // for the same reason. A finding's own text comes from a repository this
397
+ // machine cloned, and notification bodies render markup on several Linux
398
+ // desktops.
399
+ //
400
+ // The second sentence is the only place most users are ever offered the
401
+ // digest. The scan is local and needs no account, so nothing else in the
402
+ // product has a reason to ask for an address — which is exactly why the
403
+ // audit's findings have historically reached nobody. A banner someone is
404
+ // already reading, about a key of their own, is the one moment the offer is
405
+ // worth anything.
406
+ //
407
+ // No action buttons, deliberately. `Notify` supports them, but a server
408
+ // delivers the click back as an `ActionInvoked` signal to the sender — and
409
+ // this process exits as soon as the scan finishes, so the button would be
410
+ // dead. Two plain commands the user can copy beat one button that does
411
+ // nothing.
412
+ const body =
413
+ `${n} in your agent transcripts. Run failproofai audit to see them — ` +
414
+ "or failproofai audit --schedule to get them by email.";
415
+
416
+ // The two platforms need opposite things, and the split is not cosmetic. On
417
+ // Linux this process can reach the session bus itself. On macOS it cannot
418
+ // reach Notification Center at all — it is a child of a LaunchDaemon, outside
419
+ // the GUI session — so it hands the message to an agent that lives inside
420
+ // one. See `macos-notifier.ts` for why that agent exists.
421
+ if (process.platform === "darwin") {
422
+ pruneMacNotifyQueue();
423
+ if (!queueMacNotification(claimed[0], title, body)) {
424
+ process.stderr.write(`failproofai: found ${n} but could not queue a notification\n`);
425
+ } else if (!macNotifierInstalled()) {
426
+ // Queued, but nothing will collect it: setup either never ran or could
427
+ // not compile the applet. Worth a line, because the symptom otherwise is
428
+ // simply silence.
429
+ process.stderr.write(
430
+ `failproofai: found ${n}; the notifier is not installed, so nothing will show it ` +
431
+ `(run \`failproofai config\`)\n`,
432
+ );
433
+ }
434
+ return;
435
+ }
436
+
437
+ const outcome = await notifyDesktop(
438
+ title,
439
+ body,
440
+ // A stable id would let a repeat scan replace the previous bubble instead
441
+ // of stacking. We do not have one yet — the server assigns it and we would
442
+ // have to persist it — so a fresh notification is correct here, and it is
443
+ // bounded: at most one per distinct credential, ever.
444
+ 0,
445
+ );
446
+ if (!outcome.ok) {
447
+ // stderr, so it lands in the journal beside the run it belongs to. Not a
448
+ // failure of the audit — but "we found a key and could not tell you" is
449
+ // exactly the sentence someone debugging this needs to find.
450
+ process.stderr.write(
451
+ `failproofai: found ${n} but could not raise a desktop notification (${outcome.reason})\n`,
452
+ );
453
+ }
454
+ }
455
+
337
456
  // ── Headless (scheduled) audit ───────────────────────────────────────────────
338
457
 
339
458
  /**
@@ -404,6 +523,11 @@ export async function runScheduledAudit(): Promise<number> {
404
523
  `${num(result.transcripts.scanned)} sessions, ${num(result.totals.hits)} hits\n`,
405
524
  );
406
525
 
526
+ // Tell whoever owns this machine, on the machine, before anything is sent
527
+ // anywhere. Local first: this is the channel that works with no account, no
528
+ // email and no network, which describes most users.
529
+ await announceLeaksOnDesktop(result);
530
+
407
531
  // Report harmful findings upstream, if the user switched emailed reports on.
408
532
  //
409
533
  // AFTER the dashboard cache is written and AFTER the success line, because
@@ -552,6 +676,16 @@ export async function runAuditCli(args: string[]): Promise<void> {
552
676
  process.exit(0);
553
677
  }
554
678
 
679
+ for (const [flag, enable] of [["--notify", true], ["--no-notify", false]] as const) {
680
+ if (args.includes(flag)) {
681
+ const extra = args.find((a) => a !== flag);
682
+ if (extra) die(`\`audit ${flag}\` takes no other arguments (got: ${extra}).`);
683
+ const { runNotifyToggle } = await import("./schedule-cli");
684
+ runNotifyToggle(enable);
685
+ process.exit(0);
686
+ }
687
+ }
688
+
555
689
  if (args.includes("--no-schedule")) {
556
690
  const extra = args.find((a) => a !== "--no-schedule");
557
691
  if (extra) die(`\`audit --no-schedule\` takes no other arguments (got: ${extra}).`);
@@ -0,0 +1,420 @@
1
+ /**
2
+ * A desktop notification on Linux, from a process with no desktop.
3
+ *
4
+ * The scheduled audit runs as a child of a SYSTEM-scope service. Its
5
+ * environment was measured on a real machine and carries no
6
+ * `DBUS_SESSION_BUS_ADDRESS`, no `DISPLAY`, no `WAYLAND_DISPLAY` and no
7
+ * `XDG_RUNTIME_DIR` — the daemon starts at boot, before anyone logs in, which
8
+ * is the whole reason it survives logout.
9
+ *
10
+ * That turns out not to be a permission problem. `/run/user/<uid>/bus` exists
11
+ * whenever the user is logged in, the daemon already runs as that uid, and
12
+ * D-Bus's EXTERNAL auth is `SO_PEERCRED` — the kernel vouches for the uid, so
13
+ * there is no cookie, no session membership and no environment to inherit. The
14
+ * address just has to be CONSTRUCTED rather than read. Proven on a real bus
15
+ * with a fully scrubbed environment: `AUTH … OK` → accepted.
16
+ *
17
+ * ## Awaiting the reply is the entire correctness story
18
+ *
19
+ * Measured: a call sent with NO_REPLY_EXPECTED against a bus with no
20
+ * notification server returns exit 0, empty stdout, empty stderr — the
21
+ * notification silently evaporates. The same call awaiting its reply returns a
22
+ * named error. Ship the fire-and-forget variant and you get exactly the failure
23
+ * this feature exists to avoid: believing the user was told.
24
+ *
25
+ * With the reply awaited every failure is loud and fast (10-16ms measured):
26
+ * logged out → ENOENT on connect, stale socket → ECONNREFUSED, no desktop →
27
+ * `org.freedesktop.DBus.Error.ServiceUnknown`.
28
+ *
29
+ * One failure is NOT detectable from here and must not be papered over: on a
30
+ * locked screen the shell still owns the name and still returns an id, while
31
+ * the user sees nothing. `Notify` succeeding means the server accepted it,
32
+ * never that a human saw it — which is why the queue, not the toast, is the
33
+ * record of a finding.
34
+ *
35
+ * No dependency: the wire protocol is written by hand rather than pulling a
36
+ * D-Bus client into a package that installs into other people's machines.
37
+ */
38
+ import { connect, type Socket } from "node:net";
39
+
40
+ /** Milliseconds allowed for the whole exchange. Generous — a live bus answers
41
+ * in ~10ms, and this runs off the hook path entirely. */
42
+ const TIMEOUT_MS = 2_000;
43
+
44
+ export type NotifyOutcome =
45
+ | { ok: true; id: number }
46
+ | { ok: false; reason: "no-session" | "no-server" | "refused" | "timeout" | "error"; detail: string };
47
+
48
+ /**
49
+ * A D-Bus marshaller that knows where it is.
50
+ *
51
+ * Every alignment rule in the wire format is stated relative to the START OF
52
+ * THE MESSAGE, which is why this tracks an offset instead of padding buffers in
53
+ * isolation. The first version of this file did the latter — each helper padded
54
+ * its own result to the boundary it needed — and it produced a header-fields
55
+ * array two bytes too long, because the padding after the LAST element belongs
56
+ * to the message rather than to the array. Real `dbus-daemon` dropped the
57
+ * connection; the hand-written test server did not, because it mirrored the
58
+ * same assumption the encoder made. The byte diff against a real client's
59
+ * `Hello` is what settled it (0x70 vs 0x6e), and it is why the tests below now
60
+ * run against a real bus.
61
+ */
62
+ class Marshal {
63
+ private chunks: Buffer[] = [];
64
+ private len = 0;
65
+
66
+ /** Pad to the next `n`-byte boundary, counted from the start of the message. */
67
+ align(n: number): void {
68
+ const pad = (n - (this.len % n)) % n;
69
+ if (pad) this.raw(Buffer.alloc(pad));
70
+ }
71
+ raw(b: Buffer): void {
72
+ this.chunks.push(b);
73
+ this.len += b.length;
74
+ }
75
+ byte(v: number): void {
76
+ this.raw(Buffer.from([v]));
77
+ }
78
+ u32(v: number): void {
79
+ this.align(4);
80
+ const b = Buffer.alloc(4);
81
+ b.writeUInt32LE(v, 0);
82
+ this.raw(b);
83
+ }
84
+ i32(v: number): void {
85
+ this.align(4);
86
+ const b = Buffer.alloc(4);
87
+ b.writeInt32LE(v, 0);
88
+ this.raw(b);
89
+ }
90
+ /** STRING / OBJECT_PATH: 4-aligned length, bytes, NUL. */
91
+ str(v: string): void {
92
+ const b = Buffer.from(v, "utf8");
93
+ this.u32(b.length);
94
+ this.raw(b);
95
+ this.byte(0);
96
+ }
97
+ /** SIGNATURE: a single length BYTE, bytes, NUL. No alignment. */
98
+ sig(v: string): void {
99
+ const b = Buffer.from(v, "utf8");
100
+ this.byte(b.length);
101
+ this.raw(b);
102
+ this.byte(0);
103
+ }
104
+ get length(): number {
105
+ return this.len;
106
+ }
107
+ buffer(): Buffer {
108
+ return Buffer.concat(this.chunks);
109
+ }
110
+ }
111
+
112
+ /** One header field: a STRUCT of (BYTE code, VARIANT value), 8-aligned. */
113
+ type HeaderField = readonly [code: number, signature: string, value: string];
114
+
115
+ /**
116
+ * Encode the header-fields array.
117
+ *
118
+ * Returns the declared length SEPARATELY from the bytes, because they are not
119
+ * the same number: the length stops at the end of the last element's content,
120
+ * while the message then pads to 8 before the body. Conflating them is the bug
121
+ * described on `Marshal`.
122
+ */
123
+ function headerFields(fields: readonly HeaderField[]): { data: Buffer; declaredLength: number } {
124
+ // The array's data starts at message offset 16, which is already 8-aligned,
125
+ // so offsets inside this marshaller line up with the real message.
126
+ const m = new Marshal();
127
+ for (const [code, signature, value] of fields) {
128
+ m.align(8); // each STRUCT element starts on an 8-byte boundary
129
+ m.byte(code);
130
+ m.sig(signature);
131
+ // Every header field we send is a string-like: OBJECT_PATH, STRING or
132
+ // SIGNATURE. Nothing here needs a numeric variant.
133
+ if (signature === "g") m.sig(value);
134
+ else m.str(value);
135
+ }
136
+ // No trailing padding was added, so the buffer length IS the declared length.
137
+ return { data: m.buffer(), declaredLength: m.length };
138
+ }
139
+
140
+ /** A METHOD_CALL: fixed header, header fields, 8-byte pad, then the body. */
141
+ function methodCall(serial: number, fields: readonly HeaderField[], body: Buffer): Buffer {
142
+ const { data, declaredLength } = headerFields(fields);
143
+ const head = new Marshal();
144
+ head.raw(Buffer.from([0x6c, 1, 0, 1])); // little-endian, METHOD_CALL, no flags, proto 1
145
+ head.u32(body.length);
146
+ head.u32(serial);
147
+ head.u32(declaredLength);
148
+ head.raw(data);
149
+ head.align(8); // the body always starts on an 8-byte boundary
150
+ return Buffer.concat([head.buffer(), body]);
151
+ }
152
+
153
+ /**
154
+ * The arguments of `org.freedesktop.Notifications.Notify`.
155
+ *
156
+ * Signature `susssasa{sv}i`: app_name, replaces_id, app_icon, summary, body,
157
+ * actions, hints, expire_timeout.
158
+ */
159
+ function notifyBody(app: string, replacesId: number, summary: string, body: string): Buffer {
160
+ const m = new Marshal();
161
+ m.str(app);
162
+ m.u32(replacesId);
163
+ m.str(""); // app_icon — none; the app name is what identifies us
164
+ m.str(summary);
165
+ m.str(body);
166
+ m.u32(0); // actions: `as`, empty. Element alignment 4, already met.
167
+ m.u32(0); // hints: `a{sv}`, empty...
168
+ m.align(8); // ...and an array pads to its ELEMENT alignment even when
169
+ // empty. DICT_ENTRY is 8, so this padding is required and
170
+ // counts toward the body length.
171
+ m.i32(-1); // expire_timeout: -1 lets the server decide
172
+ return m.buffer();
173
+ }
174
+
175
+ /** `org.freedesktop.DBus.Hello` — mandatory before any other method call. */
176
+ const HELLO_FIELDS: readonly HeaderField[] = [
177
+ [1, "o", "/org/freedesktop/DBus"],
178
+ [6, "s", "org.freedesktop.DBus"],
179
+ [2, "s", "org.freedesktop.DBus"],
180
+ [3, "s", "Hello"],
181
+ ];
182
+
183
+ const NOTIFY_FIELDS: readonly HeaderField[] = [
184
+ [1, "o", "/org/freedesktop/Notifications"],
185
+ [6, "s", "org.freedesktop.Notifications"],
186
+ [2, "s", "org.freedesktop.Notifications"],
187
+ [3, "s", "Notify"],
188
+ [8, "g", "susssasa{sv}i"],
189
+ ];
190
+
191
+ /** A parsed inbound message: only what the state machine dispatches on. */
192
+ interface Incoming {
193
+ type: number;
194
+ replySerial: number | null;
195
+ errorName: string | null;
196
+ bodyStart: number;
197
+ bodyLength: number;
198
+ total: number;
199
+ }
200
+
201
+ /**
202
+ * Read one whole message out of `buf`, or null when it has not all arrived.
203
+ *
204
+ * A framed reader rather than "assume one message per chunk", because a real
205
+ * bus does not oblige: it answers `Hello` with a METHOD_RETURN and then emits a
206
+ * `NameAcquired` SIGNAL, and the two can land in one chunk or two. The earlier
207
+ * version consumed whichever chunk came first and treated the NEXT one as its
208
+ * reply — which, for a SIGNAL, is neither an error nor a return, so it reported
209
+ * the notification as delivered with a garbage id.
210
+ */
211
+ function readMessage(buf: Buffer): Incoming | null {
212
+ if (buf.length < 16) return null;
213
+ const bodyLength = buf.readUInt32LE(4);
214
+ const fieldsLength = buf.readUInt32LE(12);
215
+ const bodyStart = 16 + fieldsLength + ((8 - (fieldsLength % 8)) % 8);
216
+ const total = bodyStart + bodyLength;
217
+ if (buf.length < total) return null;
218
+
219
+ let replySerial: number | null = null;
220
+ let errorName: string | null = null;
221
+ // Walk the header fields far enough to find REPLY_SERIAL (5) and ERROR_NAME
222
+ // (4). Anything unparseable stops the walk rather than throwing: a header we
223
+ // cannot read is a message we ignore, not a crash on the audit's exit path.
224
+ let i = 16;
225
+ const end = 16 + fieldsLength;
226
+ while (i < end) {
227
+ i += (8 - ((i - 16) % 8)) % 8; // struct alignment, relative to the array start
228
+ if (i + 4 > end) break;
229
+ const code = buf[i];
230
+ const sigLen = buf[i + 1];
231
+ const sig = buf.toString("latin1", i + 2, i + 2 + sigLen);
232
+ i += 2 + sigLen + 1;
233
+ if (sig === "u") {
234
+ i += (4 - ((i - 16) % 4)) % 4;
235
+ if (i + 4 > end) break;
236
+ if (code === 5) replySerial = buf.readUInt32LE(i);
237
+ i += 4;
238
+ } else if (sig === "s" || sig === "o") {
239
+ i += (4 - ((i - 16) % 4)) % 4;
240
+ if (i + 4 > end) break;
241
+ const len = buf.readUInt32LE(i);
242
+ if (i + 4 + len > end) break;
243
+ if (code === 4) errorName = buf.toString("utf8", i + 4, i + 4 + len);
244
+ i += 4 + len + 1;
245
+ } else if (sig === "g") {
246
+ const len = buf[i];
247
+ i += 1 + len + 1;
248
+ } else {
249
+ break; // a type we do not send and cannot size; stop rather than guess
250
+ }
251
+ }
252
+ return { type: buf[1], replySerial, errorName, bodyStart, bodyLength, total };
253
+ }
254
+
255
+ /**
256
+ * Where this process's session bus lives.
257
+ *
258
+ * Two callers with opposite environments, one rule. A human running
259
+ * `failproofai audit` in their terminal HAS `DBUS_SESSION_BUS_ADDRESS`, and on
260
+ * an unusual setup it is the only correct answer — so it wins when it is there
261
+ * and names a socket we can open. The scheduled audit, a child of a system
262
+ * service started before login, has nothing: for it the address is CONSTRUCTED
263
+ * from the uid, which works because D-Bus EXTERNAL auth is `SO_PEERCRED` and
264
+ * the kernel supplies the identity the environment could not.
265
+ *
266
+ * Only the `unix:path=` form is honored. The spec allows `unix:abstract=`,
267
+ * `tcp:` and semicolon-separated alternatives; a bus we cannot dial is better
268
+ * reported as "no session" than dialed wrong, and the fallback below already
269
+ * covers every desktop that ships systemd.
270
+ */
271
+ export function resolveBusPath(env: Readonly<Record<string, string | undefined>>): string | null {
272
+ const declared = env.DBUS_SESSION_BUS_ADDRESS;
273
+ if (declared) {
274
+ for (const part of declared.split(";")) {
275
+ const m = /^unix:(?:.*,)?path=([^,]+)/.exec(part.trim());
276
+ if (m) return m[1];
277
+ }
278
+ }
279
+ const uid = typeof process.getuid === "function" ? process.getuid() : -1;
280
+ return uid >= 0 ? `/run/user/${uid}/bus` : null;
281
+ }
282
+
283
+ /**
284
+ * Post a notification, awaiting the reply.
285
+ *
286
+ * `replacesId` reuses an earlier notification's id so repeated scans update one
287
+ * bubble instead of stacking a fresh alert every time — the difference between
288
+ * a reminder and a nag.
289
+ */
290
+ export function notifyDesktop(
291
+ summary: string,
292
+ body: string,
293
+ replacesId = 0,
294
+ opts: { socketPath?: string } = {},
295
+ ): Promise<NotifyOutcome> {
296
+ return new Promise((resolvePromise) => {
297
+ if (process.platform !== "linux" && !opts.socketPath) {
298
+ resolvePromise({ ok: false, reason: "error", detail: "linux only" });
299
+ return;
300
+ }
301
+ const uid = typeof process.getuid === "function" ? process.getuid() : -1;
302
+ const path = opts.socketPath ?? resolveBusPath(process.env as Record<string, string | undefined>);
303
+ if (!path || uid < 0) {
304
+ resolvePromise({ ok: false, reason: "no-session", detail: "no bus address" });
305
+ return;
306
+ }
307
+
308
+ let settled = false;
309
+ const done = (o: NotifyOutcome) => {
310
+ if (settled) return;
311
+ settled = true;
312
+ clearTimeout(timer);
313
+ try { sock.destroy(); } catch { /* already gone */ }
314
+ resolvePromise(o);
315
+ };
316
+ const timer = setTimeout(() => done({ ok: false, reason: "timeout", detail: `${TIMEOUT_MS}ms` }), TIMEOUT_MS);
317
+
318
+ let sock: Socket;
319
+ try {
320
+ sock = connect(path);
321
+ } catch (err) {
322
+ done({ ok: false, reason: "no-session", detail: String(err) });
323
+ return;
324
+ }
325
+
326
+ let stage: "auth" | "hello" | "notify" = "auth";
327
+ let buf = Buffer.alloc(0);
328
+
329
+ sock.on("error", (err: NodeJS.ErrnoException) => {
330
+ // ENOENT: nobody is logged in, so /run/user/<uid> does not exist.
331
+ // ECONNREFUSED: a stale socket left behind by a dead bus.
332
+ done({
333
+ ok: false,
334
+ reason: err.code === "ENOENT" || err.code === "ECONNREFUSED" ? "no-session" : "error",
335
+ detail: err.code ?? String(err),
336
+ });
337
+ });
338
+
339
+ // A bus that hangs up mid-exchange is the signature of a message it could
340
+ // not parse. Without this the socket simply goes quiet and the whole thing
341
+ // reports a 2-second timeout — which points at a slow desktop rather than
342
+ // at us, and is exactly how the header-length bug survived its first tests.
343
+ sock.on("close", () => {
344
+ done({ ok: false, reason: "refused", detail: `bus closed the connection during ${stage}` });
345
+ });
346
+
347
+ sock.on("connect", () => {
348
+ // SASL EXTERNAL: the kernel already told the bus our uid over SO_PEERCRED,
349
+ // so the "credential" is just that uid in hex and no secret is exchanged.
350
+ const hexUid = Buffer.from(String(uid), "utf8").toString("hex");
351
+ sock.write(Buffer.concat([Buffer.from([0]), Buffer.from(`AUTH EXTERNAL ${hexUid}\r\n`, "utf8")]));
352
+ });
353
+
354
+ sock.on("data", (chunk: Buffer) => {
355
+ buf = Buffer.concat([buf, chunk]);
356
+
357
+ if (stage === "auth") {
358
+ const text = buf.toString("latin1");
359
+ const nl = text.indexOf("\r\n");
360
+ if (nl === -1) return;
361
+ if (!text.startsWith("OK")) {
362
+ done({ ok: false, reason: "refused", detail: text.slice(0, nl).trim().slice(0, 80) });
363
+ return;
364
+ }
365
+ // Anything after the OK line is already binary protocol — keep it.
366
+ buf = buf.subarray(nl + 2);
367
+ stage = "hello";
368
+ sock.write(Buffer.from("BEGIN\r\n", "utf8"));
369
+ sock.write(methodCall(1, HELLO_FIELDS, Buffer.alloc(0)));
370
+ // Fall through: the reply may already be in `buf`.
371
+ }
372
+
373
+ // Consume whole messages only. The bus answers Hello and then emits a
374
+ // NameAcquired SIGNAL; both can arrive in one chunk, and neither is
375
+ // guaranteed to arrive whole.
376
+ for (;;) {
377
+ const msg = readMessage(buf);
378
+ if (!msg) return;
379
+ const frame = buf.subarray(0, msg.total);
380
+ buf = buf.subarray(msg.total);
381
+
382
+ // 1 = METHOD_CALL, 2 = METHOD_RETURN, 3 = ERROR, 4 = SIGNAL.
383
+ if (msg.type === 4 || msg.type === 1) continue; // NameAcquired and friends
384
+
385
+ if (stage === "hello") {
386
+ if (msg.type === 3) {
387
+ done({ ok: false, reason: "refused", detail: msg.errorName ?? "Hello failed" });
388
+ return;
389
+ }
390
+ if (msg.replySerial !== 1) continue;
391
+ stage = "notify";
392
+ sock.write(methodCall(2, NOTIFY_FIELDS, notifyBody("failproofai", replacesId, summary, body)));
393
+ continue;
394
+ }
395
+
396
+ // Only OUR call's reply settles this. Matching on serial rather than on
397
+ // "the next message" is what keeps an unrelated signal from being read
398
+ // as a successful delivery.
399
+ if (msg.replySerial !== 2) continue;
400
+
401
+ if (msg.type === 3) {
402
+ const name = msg.errorName ?? "";
403
+ // The common one: logged in, but nothing is drawing notifications.
404
+ const noServer = name.includes("ServiceUnknown") || name.includes("NameHasNoOwner");
405
+ done({ ok: false, reason: noServer ? "no-server" : "error", detail: name || "error" });
406
+ return;
407
+ }
408
+
409
+ // METHOD_RETURN. The body is a single UINT32 id — read it back so a
410
+ // future call could replace this bubble rather than stack a new one.
411
+ const id =
412
+ msg.bodyLength >= 4 && frame.length >= msg.bodyStart + 4
413
+ ? frame.readUInt32LE(msg.bodyStart)
414
+ : 0;
415
+ done({ ok: true, id });
416
+ return;
417
+ }
418
+ });
419
+ });
420
+ }