failproofai 1.0.0 → 1.0.1-beta.0

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 (238) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +0 -1
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/prerender-manifest.json +5 -5
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/routes-manifest.json +0 -6
  7. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js +4 -3
  9. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page.js +4 -3
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  24. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  25. package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -2
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js +3 -3
  28. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/status/route.js +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +4 -3
  32. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +2 -2
  34. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route.js +3 -2
  36. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -3
  38. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +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/audit/page/server-reference-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page.js +5 -3
  42. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/index.html +1 -1
  45. package/.next/standalone/.next/server/app/index.rsc +15 -15
  46. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  47. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  48. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js +4 -3
  51. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  54. package/.next/standalone/.next/server/app/policies/page.js +6 -5
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page.js +5 -4
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -4
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  67. package/.next/standalone/.next/server/app/projects/page.js +5 -4
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  71. package/.next/standalone/.next/server/app/settings/page.js +6 -4
  72. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  73. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  74. package/.next/standalone/.next/server/app-paths-manifest.json +0 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__05ja_05._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0nne229._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ny9q5d._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1hstmft._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p8poxd._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0jx6cp6._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0f-x2b0._.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1f290jp._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0hwvl7e._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1g2ji1t._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1ctjdzl._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03eykvd._.js +5 -0
  95. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07175vb._.js → [root-of-the-server]__07oh8b5._.js} +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cxe_2_._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g2rg69._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +4 -0
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0m98kzy._.js +4 -0
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0sjzdjg._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0v5_91z._.js +4 -0
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-a3135._.js +4 -0
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1hvwiw8._.js → [root-of-the-server]__11z_299._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apc90t._.js → [root-of-the-server]__15c_z97._.js} +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1wmno1q._.js → [root-of-the-server]__17d_ffl._.js} +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +4 -0
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1e5g_ur._.js +4 -0
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1hmqefd._.js +4 -0
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +6 -0
  113. package/.next/standalone/.next/server/chunks/ssr/_04j57fb._.js +3 -0
  114. package/.next/standalone/.next/server/chunks/ssr/_05enntk._.js +3 -0
  115. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +3 -0
  116. package/.next/standalone/.next/server/chunks/ssr/_0hom1s1._.js +3 -0
  117. package/.next/standalone/.next/server/chunks/ssr/_1nq-35m._.js +23 -0
  118. package/.next/standalone/.next/server/chunks/ssr/_1q7j05x._.js +3 -0
  119. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +3 -0
  120. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  121. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  123. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +3 -0
  124. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_1n3w9lb._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +4 -0
  127. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +3 -0
  128. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  129. package/.next/standalone/.next/server/pages/404.html +1 -1
  130. package/.next/standalone/.next/server/pages/500.html +1 -1
  131. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  132. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  133. package/.next/standalone/.next/static/chunks/00q6cfzuzpc85.js +1 -0
  134. package/.next/standalone/.next/static/chunks/{0zm3_8jpqitxy.js → 054eg7y_cjyf9.js} +22 -22
  135. package/.next/standalone/.next/static/chunks/{2ouq44jv_0x04.js → 0mjufa_0xxz75.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +1 -0
  137. package/.next/standalone/.next/static/chunks/{0zi8zjav5a4rq.js → 1-oz4co31nvc5.js} +1 -1
  138. package/.next/standalone/.next/static/chunks/13--2yvqek_s1.js +1 -0
  139. package/.next/standalone/.next/static/chunks/136ldjmzxhush.js +1 -0
  140. package/.next/standalone/.next/static/chunks/18fqy3doo2ial.js +6 -0
  141. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  142. package/.next/standalone/.next/static/chunks/{31k3yxpy57_fy.js → 1o5sxp_mdaoci.js} +1 -1
  143. package/.next/standalone/.next/static/chunks/29-iqnp9v_axt.css +1 -0
  144. package/.next/standalone/.next/static/chunks/{2676v-lyzgo91.js → 2es4z1ei90vsv.js} +1 -1
  145. package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +1 -0
  146. package/.next/standalone/.next/static/chunks/3peix2vxzj7eh.js +1 -0
  147. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +1 -0
  148. package/.next/standalone/.next/static/chunks/{0km-jn9o5xyqs.css → 3wq9x0c-7ld5d.css} +1 -1
  149. package/.next/standalone/app/actions/get-scheduled-audit.ts +65 -6
  150. package/.next/standalone/app/actions/update-scheduled-audit.ts +90 -17
  151. package/.next/standalone/app/api/auth/status/route.ts +7 -17
  152. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +2 -6
  153. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +101 -281
  154. package/.next/standalone/app/audit/_components/empty-state.tsx +2 -2
  155. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  156. package/.next/standalone/app/audit/audit-styles.css +150 -26
  157. package/.next/standalone/app/globals.css +39 -1
  158. package/.next/standalone/app/settings/page.tsx +38 -21
  159. package/.next/standalone/app/settings/settings-client.tsx +583 -392
  160. package/.next/standalone/app/settings/settings.css +354 -0
  161. package/.next/standalone/components/navbar.tsx +33 -4
  162. package/.next/standalone/lib/auth/api-server-client.ts +51 -29
  163. package/.next/standalone/lib/auth/auth-store.ts +44 -59
  164. package/.next/standalone/package.json +12 -10
  165. package/.next/standalone/server.js +1 -1
  166. package/bin/failproofai.mjs +6 -0
  167. package/bin/failproofaid-shim.mjs +0 -0
  168. package/dist/cli.mjs +1889 -426
  169. package/dist/worker.mjs +27 -5
  170. package/lib/auth/api-server-client.ts +51 -29
  171. package/lib/auth/auth-store.ts +44 -59
  172. package/package.json +12 -10
  173. package/scripts/docs-audit.ts +493 -0
  174. package/scripts/translate-docs/cli.ts +16 -2
  175. package/scripts/translate-docs/mdx-translator.ts +6 -1
  176. package/scripts/translate-docs/readme-translator.ts +4 -2
  177. package/src/audit/cli-login.ts +330 -0
  178. package/src/audit/cli.ts +124 -6
  179. package/src/audit/dashboard-cache.ts +44 -8
  180. package/src/audit/harm-report.ts +260 -0
  181. package/src/audit/machine-store.ts +120 -0
  182. package/src/audit/redact-example.ts +312 -0
  183. package/src/audit/report-harm.ts +167 -0
  184. package/src/audit/schedule-cli.ts +352 -0
  185. package/src/hooks/builtin-policies.ts +26 -0
  186. package/src/hooks/daemon-service.ts +76 -0
  187. package/src/hooks/fp-config.ts +96 -5
  188. package/src/hooks/fp-home.ts +118 -8
  189. package/src/hooks/fp-reset.ts +79 -9
  190. package/src/hooks/migrations.ts +241 -4
  191. package/src/hooks/tui.ts +61 -3
  192. package/.next/standalone/.next/server/app/api/auth/reminder/route/app-paths-manifest.json +0 -3
  193. package/.next/standalone/.next/server/app/api/auth/reminder/route/build-manifest.json +0 -12
  194. package/.next/standalone/.next/server/app/api/auth/reminder/route/server-reference-manifest.json +0 -4
  195. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +0 -9
  196. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.map +0 -5
  197. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +0 -1
  198. package/.next/standalone/.next/server/app/api/auth/reminder/route_client-reference-manifest.js +0 -3
  199. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-xggru._.js +0 -3
  200. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00ds05_._.js +0 -3
  201. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +0 -3
  202. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1dxagv_._.js +0 -3
  203. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fgqj1_._.js +0 -3
  204. package/.next/standalone/.next/server/chunks/_084toou._.js +0 -3
  205. package/.next/standalone/.next/server/chunks/_0g4t8c9._.js +0 -3
  206. package/.next/standalone/.next/server/chunks/_0km521a._.js +0 -3
  207. package/.next/standalone/.next/server/chunks/_0t8kefc._.js +0 -3
  208. package/.next/standalone/.next/server/chunks/_16dwkfn._.js +0 -3
  209. package/.next/standalone/.next/server/chunks/_1phm9ui._.js +0 -3
  210. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_auth_reminder_route_actions_1kjgxf8.js +0 -3
  211. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dk8r_l._.js +0 -3
  212. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kviq2m._.js +0 -3
  213. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ogyhu0._.js +0 -3
  214. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-rfu32._.js +0 -5
  215. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +0 -5
  216. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1t62w37._.js +0 -3
  217. package/.next/standalone/.next/server/chunks/ssr/_032wo7w._.js +0 -3
  218. package/.next/standalone/.next/server/chunks/ssr/_08840em._.js +0 -3
  219. package/.next/standalone/.next/server/chunks/ssr/_0gl2ujp._.js +0 -3
  220. package/.next/standalone/.next/server/chunks/ssr/_0j0ggkc._.js +0 -3
  221. package/.next/standalone/.next/server/chunks/ssr/_0kbv5-q._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/ssr/_0r1jgvp._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/ssr/_0t5xq1b._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/_0w_be0g._.js +0 -3
  225. package/.next/standalone/.next/server/chunks/ssr/_15gba46._.js +0 -5
  226. package/.next/standalone/.next/server/chunks/ssr/_1qyhb9r._.js +0 -23
  227. package/.next/standalone/.next/server/chunks/ssr/_1vel2ey._.js +0 -3
  228. package/.next/standalone/.next/static/chunks/0wd503tv801gy.js +0 -1
  229. package/.next/standalone/.next/static/chunks/1hfocb0sdl85c.js +0 -1
  230. package/.next/standalone/.next/static/chunks/1v1gqvpjk62jm.js +0 -1
  231. package/.next/standalone/.next/static/chunks/2kopjq_or743g.js +0 -6
  232. package/.next/standalone/.next/static/chunks/2uvhw57mf9lg_.css +0 -2
  233. package/.next/standalone/.next/static/chunks/2ynl0-6aqng_b.js +0 -1
  234. package/.next/standalone/.next/static/chunks/3xvtgz6arztp_.js +0 -1
  235. package/.next/standalone/app/api/auth/reminder/route.ts +0 -213
  236. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_buildManifest.js +0 -0
  237. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_clientMiddlewareManifest.js +0 -0
  238. /package/.next/standalone/.next/static/{Aulh3DRWwRfwh2POCvZR_ → jSKB7CQQSNTX-uSbY2bUs}/_ssgManifest.js +0 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * `failproofai audit --schedule [days]` / `--no-schedule` / `--status`.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Until now the only way to turn scheduled audits on was the dashboard's
7
+ * settings page — a browser. `failproofaid` is a SYSTEM service:
8
+ * `WantedBy=multi-user.target`, starts at boot, needs no login, survives
9
+ * logout. That design exists for headless boxes, detached tmux, cron and CI
10
+ * runners, and not one of those can open a settings page. The feature was
11
+ * built for machines that had no way to switch it on.
12
+ *
13
+ * ## Parity is structural, not a promise
14
+ *
15
+ * Every write here goes through the same `updateConfig` the dashboard's server
16
+ * actions call, and the session goes through the same `auth-store`. There is
17
+ * one `config.json`, one `audit/session.json`, and one writer function for
18
+ * each — so "the CLI and the dashboard are always in sync" is a consequence of
19
+ * the shape rather than something to keep true by hand. Two files, or two
20
+ * writers, is where that promise starts needing tests to defend it.
21
+ *
22
+ * Two doc comments in `app/actions/` used to claim `failproofai config` already
23
+ * wrote these keys. It never did — the wizard calls `updateConfig` zero times.
24
+ * Those comments are corrected in this change rather than left describing a
25
+ * command that did not exist.
26
+ */
27
+ import { readConfig, updateConfig } from "../hooks/fp-config";
28
+ import { daemonServiceStatus, isDaemonSupportedPlatform } from "../hooks/daemon-service";
29
+ import { readAuth } from "../../lib/auth/auth-store";
30
+ import { readAuditSchedule } from "./audit-schedule";
31
+ import { readDashboardCacheMeta } from "./dashboard-cache";
32
+ import { readMachineIdentity } from "./machine-store";
33
+ import { ensureSignedIn, invalidEmail, LoginError } from "./cli-login";
34
+ import {
35
+ ANSI_BOLD,
36
+ ANSI_DIM,
37
+ ANSI_RESET,
38
+ brandAnsi,
39
+ colorsEnabled,
40
+ outro,
41
+ step,
42
+ } from "../hooks/tui";
43
+
44
+ /** Mirrors `fp-config`'s own bounds so the error can name them before writing. */
45
+ const MIN_DAYS = 1;
46
+ const MAX_DAYS = 90;
47
+
48
+ export class ScheduleCliError extends Error {}
49
+
50
+ /**
51
+ * Colour and the spine answer to ONE gate, checked at call time.
52
+ *
53
+ * They used to disagree: these helpers emitted ANSI unconditionally while the
54
+ * frame asked `colorsEnabled`, so a piped `--status` came out as escape codes
55
+ * with no structure — the worst of both. `colorsEnabled` is false off a TTY and
56
+ * under `NO_COLOR`, which is exactly when a readout should be plain text.
57
+ */
58
+ const styled = () => colorsEnabled(process.stdout);
59
+ const wrap = (open: string, s: string) => (styled() ? `${open}${s}${ANSI_RESET}` : s);
60
+ const pink = (s: string) => wrap(brandAnsi("pink"), s);
61
+ const green = (s: string) => wrap(brandAnsi("guide"), s);
62
+ const dim = (s: string) => wrap(ANSI_DIM, s);
63
+ const bold = (s: string) => wrap(ANSI_BOLD, s);
64
+
65
+ /**
66
+ * The readout's left margin.
67
+ *
68
+ * Deliberately NOT the `│` spine the sign-in uses. A spine means "a flow is
69
+ * happening, with a beginning and an end"; `--status` is a snapshot of a
70
+ * machine, and hanging one off a frame that never opened reads as an unfinished
71
+ * wizard. Alignment does the work here instead.
72
+ */
73
+ const rail = () => " ";
74
+
75
+ /**
76
+ * One labelled row of the `--status` readout.
77
+ *
78
+ * A fixed label column so the values line up into a second column that can be
79
+ * read straight down — the whole point of this command is answering "what is
80
+ * this machine doing" at a glance, and a ragged left edge makes four facts read
81
+ * as four sentences.
82
+ */
83
+ const LABEL_WIDTH = 15;
84
+ function row(label: string, value: string, note?: string): string {
85
+ const gap = " ".repeat(Math.max(1, LABEL_WIDTH - label.length));
86
+ return `${rail()} ${dim(label)}${gap}${value}${note ? ` ${dim(note)}` : ""}`;
87
+ }
88
+
89
+ /**
90
+ * Turn scheduled audits on, signing in first if needed.
91
+ *
92
+ * Scheduling and mailing are ONE decision — the reason to put a scan on a timer
93
+ * is to be told what it found — so this requires a session, exactly as the
94
+ * dashboard's `setAutoAuditAction` does. A timer set with nobody to tell is a
95
+ * switch that reads as on and produces nothing, discoverable only by noticing
96
+ * that no digest ever arrives.
97
+ *
98
+ * The interval is written and then RE-READ, so what is printed is what the
99
+ * config actually kept — `readIntervalDays` owns the 1..90 clamp and a second
100
+ * copy of those bounds here would be one more thing to drift.
101
+ */
102
+ export async function runScheduleOn(
103
+ daysArg: string | undefined,
104
+ emailArg?: string,
105
+ ): Promise<void> {
106
+ let days: number | undefined;
107
+ if (daysArg !== undefined) {
108
+ const parsed = Number(daysArg);
109
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
110
+ throw new ScheduleCliError(
111
+ `\`--schedule\` takes a whole number of days (got: ${daysArg}).`,
112
+ );
113
+ }
114
+ if (parsed < MIN_DAYS || parsed > MAX_DAYS) {
115
+ throw new ScheduleCliError(
116
+ `\`--schedule\` must be between ${MIN_DAYS} and ${MAX_DAYS} days (got: ${parsed}).`,
117
+ );
118
+ }
119
+ days = parsed;
120
+ }
121
+
122
+ // Checked here, beside the day count and before anything is drawn or sent: a
123
+ // typo'd flag should read as a usage error, not as a sign-in that opened a
124
+ // frame and then gave up.
125
+ if (emailArg !== undefined) {
126
+ const bad = invalidEmail(emailArg);
127
+ if (bad) throw new ScheduleCliError(bad);
128
+ }
129
+
130
+ const { user, prompted } = await ensureSignedIn(emailArg);
131
+
132
+ const next = updateConfig({
133
+ // Stamped in the SAME call that sets `auto`, never separately: this records
134
+ // that a person completed a sign-in and read the disclosure printed below,
135
+ // and it is what `reportHarm` gates sending on. A machine that inherited
136
+ // `auto` from a release where it meant "scan locally" has no stamp and
137
+ // sends nothing until it comes through here.
138
+ audit: {
139
+ auto: true,
140
+ reportsConsentedAt: Date.now(),
141
+ ...(days !== undefined ? { intervalDays: days } : {}),
142
+ },
143
+ });
144
+ const interval = next.audit.intervalDays;
145
+
146
+ // Two rows rather than one long one: at 80 columns the combined sentence
147
+ // wrapped, and a wrapped summary loses the spine on its second row.
148
+ // The third row enumerates what leaves the machine, and it is not optional.
149
+ // This is the ONLY opt-in path on the headless boxes the whole feature was
150
+ // built for — the settings panel says "sends: counts, redacted examples, and
151
+ // this machine's name" and argues in its own comment that a checkable list
152
+ // beats a stronger claim, and that reasoning applies here at least as much.
153
+ // The list is the real payload from `report-harm.ts`: machine id, hostname,
154
+ // platform, the window bounds and the redacted examples.
155
+ const summary = [
156
+ `every ${interval} day${interval === 1 ? "" : "s"} · reports to ${user.email}`,
157
+ "you only hear from it when a scan finds something harmful",
158
+ "each report sends: finding counts, redacted example commands, this machine's name",
159
+ ];
160
+
161
+ if (prompted) {
162
+ // A sign-in just drew the frame, so the result continues it and the `└`
163
+ // closes both at once — rather than the frame ending and a loose line
164
+ // appearing underneath.
165
+ step("scheduled audits are on", summary);
166
+ } else {
167
+ // Nothing was asked, so nothing was a flow: a spine here would open a frame
168
+ // that has no beginning.
169
+ process.stdout.write(
170
+ `\n${green("✓")} ${bold("scheduled audits are on")}\n` +
171
+ summary.map((r) => ` ${dim(r)}\n`).join(""),
172
+ );
173
+ }
174
+
175
+ // The switch is config; whether anything RUNS is the daemon. Saying "on"
176
+ // without checking would be the same "on but silent" state the settings panel
177
+ // exists to make visible.
178
+ warnIfDaemonWontRun();
179
+
180
+ if (prompted) {
181
+ outro("failproofai audit --status · when the next scan is due");
182
+ } else {
183
+ process.stdout.write(dim(`\n failproofai audit --status when the next scan is due\n\n`));
184
+ }
185
+ }
186
+
187
+ export function runScheduleOff(): void {
188
+ const before = readConfig().audit.auto;
189
+ const next = updateConfig({ audit: { auto: false } });
190
+ if (next.audit.auto) {
191
+ process.stdout.write("Could not turn scheduled audits off.\n");
192
+ return;
193
+ }
194
+ if (!before) {
195
+ process.stdout.write(dim("\nScheduled audits were already off.\n\n"));
196
+ return;
197
+ }
198
+ process.stdout.write(
199
+ `\n${green("✓")} ${bold("scheduled audits are off")}\n` +
200
+ ` ${dim("nothing runs on a timer and nothing is sent")}\n` +
201
+ ` ${dim("your session is untouched — sign out from the dashboard for that")}\n\n`,
202
+ );
203
+ }
204
+
205
+ /**
206
+ * What this machine is actually doing.
207
+ *
208
+ * The one command with no equivalent anywhere else: on a headless box there was
209
+ * previously no way to ask whether scheduling was on, when the last scan ran, or
210
+ * whether the daemon was even up. Every value is read from the same places the
211
+ * dashboard reads them.
212
+ */
213
+ export function runScheduleStatus(): void {
214
+ const config = readConfig();
215
+ const auth = readAuth();
216
+ const sched = readAuditSchedule();
217
+ const meta = readDashboardCacheMeta();
218
+ const machine = readMachineIdentity();
219
+ const daemon = daemonServiceStatus();
220
+
221
+ const on = config.audit.auto;
222
+ const out: string[] = [""];
223
+
224
+ // The state first and alone, in the accent that matches it — everything below
225
+ // is detail about a machine that is either doing this or not, and reading the
226
+ // detail first is reading the answer to a question nobody asked yet.
227
+ out.push(
228
+ `${rail()} ${bold("scheduled audit")} ${on ? green("on") : dim("off")}` +
229
+ (on ? dim(` every ${config.audit.intervalDays} days`) : ""),
230
+ );
231
+ out.push(rail());
232
+
233
+ // A session whose refresh window has closed cannot mint another access token,
234
+ // so it is a destination in name only. Showing the address for one would tell
235
+ // somebody their digests are going somewhere they are not.
236
+ const live = auth && auth.refresh_expires_at * 1000 > Date.now() ? auth : null;
237
+ out.push(row("reports to", live ? live.user.email : dim("— signed out")));
238
+ if (on && !live) {
239
+ // The state the reporter surfaces as "signed-out". Named here for the same
240
+ // reason the settings panel names it: the scans keep running, so silence
241
+ // about the digests would look like the feature failing.
242
+ out.push(row("", pink("scans continue; digests are paused until you sign in")));
243
+ } else if (on && config.audit.reportsConsentedAt === undefined) {
244
+ // Signed in, scheduled, and still not sending: this machine set `audit.auto`
245
+ // when it only meant "scan locally", so nothing has consented to the digest
246
+ // leaving the box. Without this row the status screen would show a healthy
247
+ // schedule and a live address and still mail nothing, with no explanation
248
+ // anywhere the user can see.
249
+ out.push(
250
+ row("", pink("scans continue; digests need a fresh opt-in — run `--schedule` to turn them on")),
251
+ );
252
+ }
253
+
254
+ out.push(row("daemon", describeDaemon(daemon)));
255
+
256
+ if (sched?.nextDueAtMs != null && on) {
257
+ out.push(row("next scan", untilPhrase(sched.nextDueAtMs)));
258
+ }
259
+ if (sched?.lastRunAtMs != null) {
260
+ const exit = sched.lastExitCode;
261
+ out.push(
262
+ row(
263
+ "last scheduled",
264
+ agoPhrase(sched.lastRunAtMs),
265
+ exit != null && exit !== 0 && exit !== 75 ? pink(`exit ${exit}`) : undefined,
266
+ ),
267
+ );
268
+ }
269
+ out.push(
270
+ row("last result", meta?.cachedAt ? agoPhrase(Date.parse(meta.cachedAt)) : dim("none yet")),
271
+ );
272
+ if (machine?.last_reported_at) {
273
+ out.push(row("last reported", agoPhrase(Date.parse(machine.last_reported_at))));
274
+ }
275
+ out.push("");
276
+
277
+ process.stdout.write(out.join("\n"));
278
+ }
279
+
280
+ function describeDaemon(status: ReturnType<typeof daemonServiceStatus>): string {
281
+ switch (status) {
282
+ case "running":
283
+ return green("running");
284
+ case "stopped":
285
+ return pink("stopped — run `failproofai config` to repair it");
286
+ case "not-installed":
287
+ return pink("not installed — run `failproofai config`");
288
+ case "condition-failed":
289
+ return pink("installed but its binary is missing — run `failproofai config`");
290
+ default:
291
+ return isDaemonSupportedPlatform()
292
+ ? dim(String(status))
293
+ : dim("unavailable on this platform");
294
+ }
295
+ }
296
+
297
+ /** Printed after turning scheduling on, where the answer changes what to do. */
298
+ function warnIfDaemonWontRun(): void {
299
+ const status = daemonServiceStatus();
300
+ if (status === "running") return;
301
+ // "unknown" is not "broken", and on macOS it is the ORDINARY reading.
302
+ // `daemonServiceStatus` needs `sudo -n` to interrogate a LaunchDaemon, and a
303
+ // Mac with no cached sudo credential — the overwhelmingly common state —
304
+ // answers "unknown" for a service that is running perfectly. Treating every
305
+ // non-`running` value as a fault told those users "nothing will run on the
306
+ // timer yet" in the same breath as confirming their schedule was on. The
307
+ // dashboard already special-cases it; this is the same call.
308
+ if (status === "unknown") return;
309
+ if (!isDaemonSupportedPlatform()) {
310
+ process.stderr.write(
311
+ `\n ${pink("!")} The background service is not available on this platform,\n` +
312
+ ` so nothing will run on the timer here. \`failproofai audit\` still works.\n`,
313
+ );
314
+ return;
315
+ }
316
+ process.stderr.write(
317
+ `\n ${pink("!")} The background service is ${status.replace("-", " ")}, ` +
318
+ `so nothing will run on the timer yet.\n` +
319
+ ` Run \`failproofai config\` to install or repair it.\n`,
320
+ );
321
+ }
322
+
323
+ function untilPhrase(ms: number): string {
324
+ const diff = ms - Date.now();
325
+ if (diff <= 0) return "due now";
326
+ const d = Math.floor(diff / 86_400_000);
327
+ const h = Math.floor((diff % 86_400_000) / 3_600_000);
328
+ if (d > 0) return `in ${d}d${h > 0 ? ` ${h}h` : ""}`;
329
+ if (h > 0) return `in ${h}h`;
330
+ return `in ${Math.max(1, Math.floor(diff / 60_000))}m`;
331
+ }
332
+
333
+ function agoPhrase(ms: number): string {
334
+ if (!Number.isFinite(ms)) return "unknown";
335
+ const diff = Date.now() - ms;
336
+ if (diff < 0) return "just now";
337
+ const d = Math.floor(diff / 86_400_000);
338
+ const h = Math.floor(diff / 3_600_000);
339
+ const m = Math.floor(diff / 60_000);
340
+ if (d > 0) return `${d}d ago`;
341
+ if (h > 0) return `${h}h ago`;
342
+ if (m > 0) return `${m}m ago`;
343
+ return "just now";
344
+ }
345
+
346
+ /** Turn a `LoginError` into the CLI's own error type, keeping its message. */
347
+ export function asScheduleError(err: unknown): never {
348
+ if (err instanceof LoginError || err instanceof ScheduleCliError) {
349
+ throw new ScheduleCliError(err.message);
350
+ }
351
+ throw err;
352
+ }
@@ -140,6 +140,32 @@ const PRIVATE_KEY_RE = /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/;
140
140
  // sanitizeBearerTokens
141
141
  const BEARER_TOKEN_RE = /Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]{20,}/i;
142
142
 
143
+ /**
144
+ * Every pattern the `sanitize-*` policies treat as a secret, as one list.
145
+ *
146
+ * Exported so the audit's harm reporter can redact against the SAME definition
147
+ * of "secret" that the engine blocks on, rather than growing a second pattern
148
+ * list beside this one. Two lists is the shape that eventually disagrees, and
149
+ * the direction it disagrees in here is a live credential leaving a machine.
150
+ *
151
+ * The `sanitize-*` FUNCTIONS cannot be reused for this — they are detectors that
152
+ * return a `deny` with a message, not transforms that return scrubbed text. The
153
+ * patterns are the reusable part, so the patterns are what is shared.
154
+ *
155
+ * Ordered most-specific first, which is load-bearing for the API keys: a
156
+ * generic `sk-[A-Za-z0-9]{20,}` placed before `sk-ant-…` would label an
157
+ * Anthropic key as an OpenAI one. (It does not currently MATCH one — the
158
+ * hyphens in `sk-ant-` break the character class — but the ordering is what
159
+ * makes that a design rather than a coincidence.)
160
+ */
161
+ export const SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
162
+ [PRIVATE_KEY_RE, "private key"],
163
+ [JWT_RE, "JWT"],
164
+ [BEARER_TOKEN_RE, "bearer token"],
165
+ [CONNECTION_STRING_RE, "database credentials"],
166
+ ...API_KEY_PATTERNS,
167
+ ];
168
+
143
169
  // warnDestructiveSql / warnSchemaAlteration
144
170
  const SQL_TOOL_RE = /\b(?:psql|mysql|sqlite3|pgcli|clickhouse-client)\b/;
145
171
  const DESTRUCTIVE_SQL_RE = /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\b)/i;
@@ -1797,3 +1797,79 @@ export function daemonServiceStatus(): DaemonServiceStatus {
1797
1797
  return "stopped";
1798
1798
  }
1799
1799
  }
1800
+
1801
+ /**
1802
+ * When the running daemon started, as epoch ms — the source for /settings'
1803
+ * "up 11d" sub-line. Null whenever the answer isn't knowable.
1804
+ *
1805
+ * **From the MONOTONIC stamp, not the printed date.** `ActiveEnterTimestamp`
1806
+ * renders in the host's locale and timezone abbreviation (`Fri 2026-08-14
1807
+ * 19:45:13 IST`), which `Date.parse` reads as invalid on most abbreviations and,
1808
+ * worse, silently mis-parses on the few it recognises — a settings page
1809
+ * claiming the daemon started three hours in the future is a worse failure than
1810
+ * one that says nothing. `ActiveEnterTimestampMonotonic` is microseconds since
1811
+ * boot, locale-free, and pairs with a reading of the SAME clock to give the
1812
+ * epoch time back.
1813
+ *
1814
+ * An EPOCH time rather than a duration, so the page keeps counting without
1815
+ * re-fetching: a duration computed on the server is wrong the moment it renders.
1816
+ *
1817
+ * Linux only for now. launchd exposes no equivalent, so macOS would need the
1818
+ * job's pid out of `launchctl print` and then `ps -o etime=` — a SECOND
1819
+ * privileged call on every settings render, since reading a LaunchDaemon in the
1820
+ * system domain needs elevation. The sub-line is not worth doubling the sudo
1821
+ * traffic of the page; the status itself still renders there.
1822
+ */
1823
+ export function daemonStartedAtMs(): number | null {
1824
+ if (process.platform !== "linux") return null;
1825
+ if (!existsSync(systemdUnitPath())) return null;
1826
+ try {
1827
+ const raw = execFileSync(
1828
+ "systemctl",
1829
+ ["show", systemdUnitName(), "-p", "ActiveEnterTimestampMonotonic", "--value"],
1830
+ { stdio: ["ignore", "pipe", "ignore"], timeout: SERVICE_CMD_TIMEOUT_MS },
1831
+ )
1832
+ .toString()
1833
+ .trim();
1834
+ // `process.hrtime.bigint()`, not `os.uptime()`. See the note on the
1835
+ // function below: the two count suspend differently, and mixing them is
1836
+ // what produced "up 30d" for a daemon started yesterday.
1837
+ return startedAtFromMonotonic(Number(raw), Math.floor(Number(process.hrtime.bigint()) / 1000), Date.now());
1838
+ } catch {
1839
+ return null;
1840
+ }
1841
+ }
1842
+
1843
+ /**
1844
+ * The arithmetic behind `daemonStartedAtMs`, split out so it can be tested
1845
+ * without a systemd on the machine running the tests.
1846
+ *
1847
+ * BOTH arguments must be readings of the SAME clock. systemd's
1848
+ * `ActiveEnterTimestampMonotonic` is `CLOCK_MONOTONIC`, which on Linux STOPS
1849
+ * while the machine is suspended; `os.uptime()` reads `/proc/uptime`, which
1850
+ * KEEPS COUNTING through suspend. Subtracting one from the other therefore adds
1851
+ * every second the laptop ever spent asleep to the daemon's apparent age — a
1852
+ * machine that suspends nightly read "up 30d" for a service started yesterday.
1853
+ * The old `< 0` guard caught only the impossible direction; this error is
1854
+ * always positive, so nothing rejected it.
1855
+ *
1856
+ * `process.hrtime.bigint()` is `CLOCK_MONOTONIC` on Linux (libuv's `uv_hrtime`),
1857
+ * the same clock systemd stamped with, so the subtraction is between two points
1858
+ * on one timeline.
1859
+ *
1860
+ * systemd writes 0 for a unit that has never been activated, and a stamp ahead
1861
+ * of the current reading cannot be true — both mean "no answer" rather than a
1862
+ * number, because a wrong uptime is indistinguishable from a right one to the
1863
+ * person reading it.
1864
+ */
1865
+ export function startedAtFromMonotonic(
1866
+ activeEnterMonotonicUs: number,
1867
+ monotonicNowUs: number,
1868
+ nowMs: number,
1869
+ ): number | null {
1870
+ if (!Number.isFinite(activeEnterMonotonicUs) || activeEnterMonotonicUs <= 0) return null;
1871
+ if (!Number.isFinite(monotonicNowUs) || monotonicNowUs <= 0) return null;
1872
+ const activeForMs = (monotonicNowUs - activeEnterMonotonicUs) / 1000;
1873
+ if (activeForMs < 0) return null;
1874
+ return Math.round(nowMs - activeForMs);
1875
+ }
@@ -102,6 +102,20 @@ export function detectLayout(): LayoutState {
102
102
  // went missing is the exact failure this module exists to prevent, and it
103
103
  // announced itself as a routine "reorganised your home" message.
104
104
  if (existsSync(configFile())) {
105
+ // `config.json` proves layout 3 OR LATER — it cannot tell them apart, since
106
+ // layout 4 changed nothing about it. What separates the two is solely WHERE
107
+ // the audit's files sit, so ask that directly: any of layout 3's three
108
+ // root-level positions still occupied means the 3 → 4 move has not run.
109
+ //
110
+ // When none of them exist the two layouts are IDENTICAL on disk (the step
111
+ // would move nothing), and "current" is the correct, non-destructive answer.
112
+ const layoutThreePositions = [
113
+ legacy.authJson(),
114
+ legacy.nextAudit(),
115
+ legacy.auditSchedule(),
116
+ ];
117
+ if (layoutThreePositions.some((p) => existsSync(p))) return { kind: "stale", found: 3 };
118
+
105
119
  // `inferred`: the layout is right but the MARKER is missing, and nothing
106
120
  // else rewrites it — so every later command re-derives it from a landmark,
107
121
  // and the daemon version recorded in that file is gone for good
@@ -112,7 +126,16 @@ export function detectLayout(): LayoutState {
112
126
 
113
127
  // `config.toml` and no `config.json` is genuinely layout 2, and a reset is
114
128
  // right: its files are the ones being replaced.
115
- if (existsSync(legacy.configToml())) return { kind: "stale", found: LAYOUT_VERSION - 1 };
129
+ //
130
+ // The literal 2, NOT `LAYOUT_VERSION - 1`. That expression was correct while
131
+ // current was 3 and became a data-loss bug the moment layout 4 landed: it
132
+ // reported a real layout-2 home as layout 3, so `planMigration` ran only the
133
+ // 3 → 4 step — which finds none of layout 3's files, moves nothing, and stamps
134
+ // the home as current. `config.toml` and `credentials.toml` would never be
135
+ // carried into JSON, orphaning the cloud token and `daemon.configured` on a
136
+ // machine that now reads as fully migrated. A landmark identifies ONE layout;
137
+ // it is never relative to whatever this build happens to speak.
138
+ if (existsSync(legacy.configToml())) return { kind: "stale", found: 2 };
116
139
 
117
140
  // Layout 1 if any of its landmarks are present, otherwise this is simply a
118
141
  // home that has not been set up yet.
@@ -187,12 +210,23 @@ export function readVersionFile(): VersionFile | null {
187
210
  }
188
211
  }
189
212
 
213
+ /**
214
+ * Stamp `VERSION`.
215
+ *
216
+ * `layout` defaults to {@link LAYOUT_VERSION} — every ordinary caller is saying
217
+ * "this home now speaks what this build speaks". It is honoured when passed
218
+ * ONLY so a failed migration can put the marker back where the home actually
219
+ * is: the signature has always accepted `layout` (it is part of `VersionFile`)
220
+ * and the body used to ignore it, so a caller asking for 3 silently got 4, and
221
+ * the one place that needs to ask is the one place where being wrong strands a
222
+ * half-migrated home as "current" forever. See `runMigrations`.
223
+ */
190
224
  export function writeVersionFile(
191
225
  v: Partial<VersionFile> & { /** Erase the daemon version rather than keeping it. */ clearDaemon?: boolean } = {},
192
226
  ): void {
193
227
  const existing = readVersionFile();
194
228
  const next: VersionFile = {
195
- layout: LAYOUT_VERSION,
229
+ layout: v.layout ?? LAYOUT_VERSION,
196
230
  cli: v.cli ?? cliVersion,
197
231
  // `undefined` means "leave whatever is there" — a CLI-only rewrite must not
198
232
  // drop a daemon version it never touched. Erasing it is therefore an
@@ -274,7 +308,22 @@ export interface FpConfig {
274
308
  };
275
309
  audit: {
276
310
  /**
277
- * Scan this machine's agent history on a schedule.
311
+ * Scan this machine on a schedule, and mail a digest when a scan finds
312
+ * something harmful.
313
+ *
314
+ * ONE switch, not two. An earlier revision split this into `auto` (scan
315
+ * locally, no account) and `email_enabled` (mail me, sign in), and the two
316
+ * could only ever disagree — a machine scanning on a timer with nothing to
317
+ * report it to is a feature that looks on and does nothing visible. The
318
+ * reason to put a scan on a timer is to be TOLD, so scheduling and mailing
319
+ * are the same decision and take the same switch.
320
+ *
321
+ * A signed-out machine with this on is therefore a real, expected state
322
+ * rather than a contradiction: it keeps scanning and keeps its local
323
+ * dashboard current, and the UI says "signed out" until somebody signs back
324
+ * in. Auth gates SETTING it up, never the machine's ongoing work — a
325
+ * refresh token expiring must not silently switch off a background feature
326
+ * somebody configured months ago.
278
327
  *
279
328
  * OFF by default, and the asymmetry with `telemetry.enabled` above is
280
329
  * deliberate: the audit reads the CONTENTS of every session transcript on
@@ -282,6 +331,28 @@ export interface FpConfig {
282
331
  * timer until somebody asks for it.
283
332
  */
284
333
  auto: boolean;
334
+ /**
335
+ * When this machine's owner agreed that a scheduled scan may send what it
336
+ * finds off the box, as epoch ms. Absent means they never did.
337
+ *
338
+ * This does NOT reintroduce the second switch the comment above rejects,
339
+ * and it is never drawn as one. `auto` is what a person sets; this is a
340
+ * record of the disclosure they were shown when they set it. The two cannot
341
+ * drift, because every path that turns `auto` on stamps this in the same
342
+ * call and nothing stamps it alone.
343
+ *
344
+ * It exists because `auto` CHANGED MEANING. Through 1.0.0 it meant "scan
345
+ * this machine on a timer" and nothing more — no account, no network, and
346
+ * the toggle that wrote it said as much in as many words. Harm digests gave
347
+ * the same stored bit a second job: uploading redacted transcript excerpts
348
+ * to the api-server and mailing them. Without a separate record, every
349
+ * machine that opted into the old meaning would have started sending on
350
+ * upgrade, having agreed to nothing of the kind, with the only notice a
351
+ * line in the journal. Gating on the stamp rather than the switch is what
352
+ * keeps that upgrade silent in the safe direction: those machines keep
353
+ * scanning locally and send nothing until somebody opts in again.
354
+ */
355
+ reportsConsentedAt?: number;
285
356
  /** Days between scheduled runs. Wall clock, so it survives suspend. */
286
357
  intervalDays: number;
287
358
  };
@@ -458,7 +529,17 @@ export function projectConfig(parsed: Record<string, unknown>): FpConfig {
458
529
  // scheduled scan on. Absent, misspelled, or `"yes"` all read as off,
459
530
  // because the failure direction here is a machine that starts reading
460
531
  // every transcript it can find on a timer nobody set.
461
- audit: { auto: audit.auto === true, intervalDays: readIntervalDays(audit.interval_days) },
532
+ audit: {
533
+ auto: audit.auto === true,
534
+ intervalDays: readIntervalDays(audit.interval_days),
535
+ // A finite number or nothing. A garbage value reads as absent, which is
536
+ // the direction that sends nothing.
537
+ reportsConsentedAt:
538
+ typeof audit.reports_consented_at === "number" &&
539
+ Number.isFinite(audit.reports_consented_at)
540
+ ? audit.reports_consented_at
541
+ : undefined,
542
+ },
462
543
  // Same shape as `audit.auto` above and for the same reason: only an
463
544
  // explicit `true` opts in. Anything else — absent, misspelled, `"yes"` —
464
545
  // reads as off, because the failure direction is a machine that starts
@@ -504,6 +585,7 @@ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [
504
585
  ["telemetry", "enabled"],
505
586
  ["audit", "auto"],
506
587
  ["audit", "interval_days"],
588
+ ["audit", "reports_consented_at"],
507
589
  ];
508
590
 
509
591
  const isPlainObject = (v: unknown): v is Record<string, unknown> =>
@@ -604,7 +686,16 @@ export function writeConfig(config: FpConfig, raw?: Record<string, unknown>): vo
604
686
  // nobody can see is the same as a switch that does not exist. Emitting both
605
687
  // keys unconditionally also makes "a user's setting survives a rewrite"
606
688
  // total rather than conditional.
607
- audit: { auto: config.audit.auto, interval_days: config.audit.intervalDays },
689
+ audit: {
690
+ auto: config.audit.auto,
691
+ interval_days: config.audit.intervalDays,
692
+ // Written only once there IS consent, so an untouched machine's config
693
+ // does not grow a key implying it was asked. It is in
694
+ // `OWNED_CONFIG_KEYS`, so omitting it here really removes it.
695
+ ...(config.audit.reportsConsentedAt === undefined
696
+ ? {}
697
+ : { reports_consented_at: config.audit.reportsConsentedAt }),
698
+ },
608
699
  };
609
700
  // Start from the previous bytes, strip the keys this build owns — so an
610
701
  // omission above really removes — then lay the projection on top. What is left