failproofai 1.0.0-beta.1 → 1.0.0-beta.3

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 (197) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  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 +2 -2
  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/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  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/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  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/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +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-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  46. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  47. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  48. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  54. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  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/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  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/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  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/build-manifest.json +3 -3
  67. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  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/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  75. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ig795d._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1yo1gyz._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_14q3-7b.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  93. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  97. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  98. package/.next/standalone/.next/static/chunks/{1d71o1_tav721.js → 01h6lfjz1_oie.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{07ix3lb4gib0x.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{35584kn409ad1.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{191njgoik03fm.js → 13-obgukzg_ke.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  104. package/.next/standalone/.next/static/chunks/{2ovmv4esqalpb.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{30is-6r00sbd7.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{1b3a1scouf40o.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{24gs8ip6_spcb.js → 2uvyh8y0naofj.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  109. package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
  110. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  111. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  112. package/.next/standalone/Cargo.lock +1992 -198
  113. package/.next/standalone/Cargo.toml +1 -1
  114. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  115. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  116. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  117. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  118. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  119. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  120. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  121. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  122. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  123. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  124. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  125. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  126. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  127. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  128. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  129. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  130. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  131. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  132. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  133. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  134. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  135. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  136. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  137. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  138. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  139. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  140. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  141. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  142. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  143. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  144. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  145. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  146. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  147. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  148. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  149. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  150. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  151. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  152. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  153. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  154. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  155. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  156. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  157. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  158. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  159. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  160. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  161. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  162. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  163. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  164. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  165. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  166. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  167. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  168. package/.next/standalone/package.json +2 -2
  169. package/.next/standalone/server.js +1 -1
  170. package/bin/failproofai.mjs +129 -0
  171. package/dist/cli.mjs +2294 -786
  172. package/dist/worker.mjs +365 -52
  173. package/package.json +2 -2
  174. package/src/hooks/builtin-policies.ts +139 -0
  175. package/src/hooks/cloud-connection.ts +205 -0
  176. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  177. package/src/hooks/cloud-enrollment.ts +173 -0
  178. package/src/hooks/cloud-managed-policies.ts +138 -0
  179. package/src/hooks/collector-config.ts +226 -0
  180. package/src/hooks/configure-wizard.ts +308 -18
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/daemon-service.ts +125 -24
  184. package/src/hooks/handler.ts +144 -6
  185. package/src/hooks/hook-activity-store.ts +50 -0
  186. package/src/hooks/loader-utils.ts +6 -4
  187. package/src/hooks/policy-types.ts +35 -0
  188. package/src/hooks/session-pause-cli.ts +157 -0
  189. package/src/hooks/session-pause.ts +236 -0
  190. package/src/hooks/tui.ts +115 -0
  191. package/.next/standalone/.next/static/chunks/0fdx7ikab08-g.js +0 -6
  192. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  193. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  194. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  195. /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  197. /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
@@ -13,12 +13,13 @@
13
13
  * install/uninstall manager and the existing searchable policy picker.
14
14
  */
15
15
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
16
- import { homedir } from "node:os";
16
+ import { homedir, hostname } from "node:os";
17
17
  import { resolve, sep } from "node:path";
18
18
 
19
19
  import {
20
20
  selectOne,
21
21
  multiSelect,
22
+ promptText,
22
23
  intro,
23
24
  outro,
24
25
  summarize,
@@ -26,6 +27,12 @@ import {
26
27
  type TTYIn,
27
28
  type TTYOut,
28
29
  } from "./tui";
30
+ import {
31
+ DEFAULT_INGEST_URL,
32
+ validateIngestKey,
33
+ writeIngestCredential,
34
+ writeCollectorSettings,
35
+ } from "./collector-config";
29
36
  import {
30
37
  detectInstalledClis,
31
38
  getIntegration,
@@ -43,9 +50,16 @@ import {
43
50
  isDaemonSupportedPlatform,
44
51
  installDaemonService,
45
52
  daemonServiceFilePath,
53
+ primeElevation,
46
54
  setDaemonConfigured,
47
55
  } from "./daemon-service";
48
56
  import { hookLogWarn } from "./hook-logger";
57
+ import {
58
+ readCloudCredentials,
59
+ verifyCloudCredentials,
60
+ writeCloudCredentials,
61
+ } from "./cloud-enrollment";
62
+ import { cloudBaseFor, ingestUrlFor } from "./cloud-connection";
49
63
 
50
64
  export interface WizardIO {
51
65
  stdin?: TTYIn;
@@ -350,18 +364,31 @@ export function reviewLines(state: {
350
364
  cwd: string;
351
365
  /** The Custom checkbox. `undefined` = nothing to toggle, leave as-is. */
352
366
  customEnabled?: boolean;
367
+ /**
368
+ * Step 0's answer. The daemon is machine-level, so this is a decision the
369
+ * user made explicitly rather than something inferred from the scope —
370
+ * showing it any other way would promise a service the apply won't install.
371
+ */
372
+ installDaemon?: boolean;
353
373
  }): string[] {
354
- const { scope, clis, policies, cwd, customEnabled } = state;
374
+ const { scope, clis, policies, cwd, customEnabled, installDaemon } = state;
355
375
  const where =
356
376
  scope === "project" ? `This project (${homeify(cwd)})` : "Everywhere (global)";
357
377
  const lines: string[] = [];
358
378
  const assistantNames = clis.map((c) => getIntegration(c).displayName);
359
379
  lines.push(` Where : ${where}`);
360
380
  lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
361
- lines.push(` Policies : ${policies.length} enabled`);
362
- if (scope === "user" && isDaemonSupportedPlatform()) {
381
+ // Zero is a deliberate answer, not a failed step — say so, and say where to
382
+ // change it, so the review screen doesn't read like the wizard lost the
383
+ // selection. Hooks still install; only the builtin set is empty.
384
+ lines.push(
385
+ policies.length === 0
386
+ ? " Policies : none enabled (add later: failproofai policies --install)"
387
+ : ` Policies : ${policies.length} enabled`,
388
+ );
389
+ if (installDaemon && isDaemonSupportedPlatform()) {
363
390
  lines.push(
364
- ` Daemon : failproofaid will be installed/started as a background service`,
391
+ ` Daemon : failproofaid, installed as a system service running as you`,
365
392
  );
366
393
  }
367
394
 
@@ -389,9 +416,9 @@ export function reviewLines(state: {
389
416
  }
390
417
  }
391
418
  lines.push(` ${homeify(getConfigPathForScope(scope, cwd))} ${policies.length} policies`);
392
- if (scope === "user" && isDaemonSupportedPlatform()) {
419
+ if (installDaemon && isDaemonSupportedPlatform()) {
393
420
  const servicePath = daemonServiceFilePath();
394
- if (servicePath) lines.push(` ${homeify(servicePath)} failproofaid service`);
421
+ if (servicePath) lines.push(` ${homeify(servicePath)} failproofaid service (needs root)`);
395
422
  }
396
423
  return lines;
397
424
  }
@@ -500,6 +527,24 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
500
527
  return { applied: false };
501
528
  }
502
529
 
530
+ // Running the wizard itself under sudo configures the WRONG ACCOUNT, and
531
+ // does it silently: homedir() becomes /root, so the hooks land in root's
532
+ // settings, `daemonConfigured` is set for root, the daemon binary downloads
533
+ // to /root/.failproofai/bin, and the unit is generated with User=root —
534
+ // exactly the elevation the design exists to avoid. SUDO_USER is set only
535
+ // when a real user sudo'd here, which distinguishes this mistake from a
536
+ // legitimately root-only environment (a container that has no other user).
537
+ if (typeof process.getuid === "function" && process.getuid() === 0 && process.env.SUDO_USER) {
538
+ stdout.write(
539
+ `Run failproofai config as ${process.env.SUDO_USER}, not under sudo.\n` +
540
+ "Everything it configures is per-user — hooks, policies and the daemon's own\n" +
541
+ "account — so under sudo it would set all of that up for root instead of you.\n" +
542
+ "The one step that needs root (installing the service) asks for your password\n" +
543
+ "on its own.\n",
544
+ );
545
+ return { applied: false };
546
+ }
547
+
503
548
  // Fire-and-forget: never block the wizard's first paint on telemetry.
504
549
  void emit("configure_started", {});
505
550
  intro("let's set up your safety net", stdout);
@@ -509,6 +554,55 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
509
554
  return { applied: false };
510
555
  };
511
556
 
557
+ // 0 — The background daemon, before anything else.
558
+ //
559
+ // First because it is the only step that needs a password: asking here
560
+ // means sudo prompts on a clean terminal, before any question has drawn a
561
+ // screen, instead of firing from underneath a rendered TUI where the prompt
562
+ // is invisible and the typed password lands in a redrawn frame. `sudo -v`
563
+ // caches the credential for the rest of the run, so the actual install at
564
+ // apply time stays non-interactive.
565
+ //
566
+ // Machine-level, so it is deliberately NOT gated on the scope chosen in the
567
+ // next step: one daemon serves every project on this machine.
568
+ let daemonWanted = false;
569
+ let elevated = false;
570
+ if (isDaemonSupportedPlatform()) {
571
+ const choice = await selectOne<"install" | "skip">({
572
+ message: "Install the failproofaid background daemon?",
573
+ choices: [
574
+ {
575
+ label: "Yes — keep policy evaluation warm",
576
+ value: "install",
577
+ hint: "a system service, started at boot, running as you · needs your password once",
578
+ },
579
+ {
580
+ label: "Not now",
581
+ value: "skip",
582
+ hint: "policies still enforce, evaluated per hook call",
583
+ },
584
+ ],
585
+ stdin,
586
+ stdout,
587
+ });
588
+ if (choice === null) return cancel();
589
+ daemonWanted = choice === "install";
590
+
591
+ if (daemonWanted) {
592
+ stdout.write("\nInstalling the failproofaid service needs root. Your password stays with sudo.\n");
593
+ elevated = primeElevation();
594
+ if (!elevated) {
595
+ // Never fatal: the rest of setup is worth applying, and a machine with
596
+ // no daemon is exactly the machine every release before this one had.
597
+ stdout.write(
598
+ "Could not get sudo — carrying on without the daemon.\n" +
599
+ "Everything else still applies, and policies still enforce in-process.\n\n",
600
+ );
601
+ daemonWanted = false;
602
+ }
603
+ }
604
+ }
605
+
512
606
  // 1 — Where?
513
607
  const scope = await selectOne<HookScope>({
514
608
  message: "Where should this apply?",
@@ -561,12 +655,23 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
561
655
  const presetChoices = buildPresetChoices(cwd, customEnabledBefore);
562
656
  const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
563
657
 
658
+ // No minimum. Ticking nothing is a real answer — someone who only wants their
659
+ // own custom policies, or who intends to pick bundles later from the
660
+ // dashboard, was previously stuck on this step with no way forward and no
661
+ // explanation beyond "Select at least 1". An empty set is already supported
662
+ // end to end: `installHooksImpl` documents its explicit-array path as "may be
663
+ // empty", `replace: true` makes it the full enabled set, and `summarize([])`
664
+ // renders "none". Hooks still install, so enforcement can be switched on
665
+ // later without re-running setup.
666
+ //
667
+ // The assistants step above keeps its minimum deliberately: an empty CLI list
668
+ // does NOT mean "no assistants" there — `installHooksImpl` falls back to
669
+ // ["claude"], so letting it through would silently install for Claude.
564
670
  const presets = await multiSelect<string>({
565
671
  message: "What should we guard against?",
566
672
  choices: presetChoices,
567
- minSelected: 1,
568
673
  summaryNoun: "bundles",
569
- hint: "space toggles · combine presets · ↵ confirm",
674
+ hint: "space toggles · combine presets · ↵ confirm · none is fine",
570
675
  stdin,
571
676
  stdout,
572
677
  });
@@ -576,10 +681,169 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
576
681
  // locked-unchecked and must not write a disabling flag.
577
682
  const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined;
578
683
 
579
- // 4 — Review & apply
684
+ // 4 — Send data to AgentEye? Last, and only when a daemon was asked for,
685
+ // since the daemon is what runs the collector.
686
+ //
687
+ // Deliberately AFTER the enforcement questions rather than beside the daemon
688
+ // one: by this point the user has decided what to protect, so "and would you
689
+ // like to see it in a dashboard?" follows naturally, where asking up front
690
+ // interrupts setup with a question about a product they may not have.
691
+ //
692
+ // Defaults to NO, and the transcript choice is a SEPARATE question from the
693
+ // key, because a transcript carries prompts, file contents and whatever was
694
+ // pasted into a terminal. Configuring ingest must never silently send those.
695
+ let collector: { cred: { url: string; key: string }; sessions: boolean } | null = null;
696
+ if (daemonWanted) {
697
+ const send = await selectOne<"yes" | "no">({
698
+ message: "Send session and hook data to AgentEye?",
699
+ choices: [
700
+ {
701
+ label: "Not now",
702
+ value: "no",
703
+ hint: "nothing leaves this machine",
704
+ },
705
+ {
706
+ label: "Yes — connect to a dashboard",
707
+ value: "yes",
708
+ hint: "needs an events:add API key",
709
+ },
710
+ ],
711
+ stdin,
712
+ stdout,
713
+ });
714
+ if (send === null) return cancel();
715
+
716
+ if (send === "yes") {
717
+ // If this machine is ALREADY enrolled with Failproof Cloud, it has a URL
718
+ // and a token that usually work for both capabilities. Asking for them
719
+ // again is the seam that made connecting feel like two products: someone
720
+ // runs `--connect`, sees an empty dashboard, and has no reason to think a
721
+ // second credential exists. Offer what we already know, and let it be
722
+ // overridden.
723
+ const existing = readCloudCredentials();
724
+ let url: string | null = null;
725
+ let key: string | null = null;
726
+
727
+ if (existing) {
728
+ const reuse = await selectOne<"reuse" | "other">({
729
+ message: `Use this machine's existing connection to ${existing.url}?`,
730
+ choices: [
731
+ {
732
+ label: `Yes — reuse it`,
733
+ value: "reuse",
734
+ hint: `as ${existing.machineId}, same token`,
735
+ },
736
+ { label: "No — use a different endpoint and key", value: "other", hint: "" },
737
+ ],
738
+ stdin,
739
+ stdout,
740
+ });
741
+ if (reuse === null) return cancel();
742
+ if (reuse === "reuse") {
743
+ url = ingestUrlFor(existing.url);
744
+ key = existing.token;
745
+ }
746
+ }
747
+
748
+ if (url === null) {
749
+ const entered = await promptText({
750
+ message: "Failproof Cloud URL",
751
+ defaultValue: cloudBaseFor(DEFAULT_INGEST_URL),
752
+ hint: "Enter for the hosted endpoint",
753
+ validate: (v) => (/^https?:\/\//.test(v) ? null : "must be an http(s) URL"),
754
+ stdin,
755
+ stdout,
756
+ });
757
+ if (entered === null) return cancel();
758
+ // Asked for as a base and derived, so this and `--connect` take the
759
+ // same thing. Someone pasting the older `/events` endpoint still works.
760
+ url = ingestUrlFor(cloudBaseFor(entered));
761
+ }
762
+
763
+ if (key === null) {
764
+ key = await promptText({
765
+ message: "API key",
766
+ hint: "needs events:add",
767
+ // Masked: setup is routinely run while screen-sharing, and a pasted key
768
+ // would otherwise sit in the scrollback of every recording.
769
+ mask: true,
770
+ validate: (v) => (v.length >= 8 ? null : "that looks too short to be a key"),
771
+ stdin,
772
+ stdout,
773
+ });
774
+ if (key === null) return cancel();
775
+ }
776
+
777
+ // Check BEFORE writing anything. A typo'd key is otherwise only
778
+ // discovered later as a silent pile of 401s parked in failed/, which
779
+ // reads like a server problem rather than a typo.
780
+ stdout.write("\nChecking the key… ");
781
+ const check = await validateIngestKey({ url, key });
782
+ if (!check.ok) {
783
+ stdout.write(`\nThat did not work: ${check.reason}\n`);
784
+ const retry = await selectOne<"skip" | "anyway">({
785
+ message: "Carry on without sending data?",
786
+ choices: [
787
+ { label: "Yes, skip it", value: "skip", hint: "re-run failproofai config later" },
788
+ { label: "Save it anyway", value: "anyway", hint: "if you know the server is just down" },
789
+ ],
790
+ stdin,
791
+ stdout,
792
+ });
793
+ if (retry === null) return cancel();
794
+ if (retry === "skip") {
795
+ stdout.write("Skipping data collection. Everything else still applies.\n\n");
796
+ } else {
797
+ collector = { cred: { url, key }, sessions: false };
798
+ }
799
+ } else {
800
+ stdout.write("looks good.\n");
801
+ const sessions = await selectOne<"hooks" | "both">({
802
+ message: "What should it send?",
803
+ choices: [
804
+ {
805
+ label: "Policy decisions only",
806
+ value: "hooks",
807
+ hint: "which hooks fired and what they decided \u00b7 no file contents",
808
+ },
809
+ {
810
+ label: "Decisions and full session transcripts",
811
+ value: "both",
812
+ hint: "includes prompts, file contents and command output",
813
+ },
814
+ ],
815
+ stdin,
816
+ stdout,
817
+ });
818
+ if (sessions === null) return cancel();
819
+ collector = { cred: { url, key }, sessions: sessions === "both" };
820
+
821
+ // The other half of the same connection. If this machine is not
822
+ // enrolled for policy and the key turns out to carry `policies:pull`,
823
+ // enrol it here rather than making the user discover a second command.
824
+ // Silent when the key lacks the permission — an events-only key is a
825
+ // perfectly reasonable thing to have, and a warning about a capability
826
+ // nobody asked for is noise.
827
+ if (!readCloudCredentials()) {
828
+ const base = cloudBaseFor(url);
829
+ const machineId = hostname();
830
+ const enrol = await verifyCloudCredentials({ url: base, machineId, token: key });
831
+ if (enrol.ok) {
832
+ writeCloudCredentials({ url: base, machineId, token: key });
833
+ stdout.write(
834
+ `Also connected for cloud-managed policy (${enrol.policyCount} assigned, generation ${enrol.generation}).\n`,
835
+ );
836
+ }
837
+ }
838
+ }
839
+ }
840
+ }
841
+
842
+
843
+ // 5 — Review & apply
580
844
  const decision = await selectOne<"apply" | "cancel">({
581
845
  message: "Ready to apply?",
582
- body: reviewLines({ scope, clis, policies, cwd, customEnabled }),
846
+ body: reviewLines({ scope, clis, policies, cwd, customEnabled, installDaemon: daemonWanted }),
583
847
  choices: [
584
848
  { label: "Yes, apply now", value: "apply", hint: "write the config" },
585
849
  { label: "Cancel", value: "cancel", hint: "quit, no changes" },
@@ -615,14 +879,14 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
615
879
  );
616
880
  setCustomPoliciesEnabled(scope, cwd, customEnabled);
617
881
 
618
- // Daemon setup — unconditional on Linux/macOS whenever the global scope
619
- // was chosen (a daemon is per-machine, not per-project), no separate
620
- // opt-in step or command. A failure here (binary not found, no service
621
- // manager, ...) never fails the wizard — the rest of setup already
622
- // applied, and this machine simply stays on the in-process path exactly
623
- // as before, since `daemonConfigured` is only set on success.
882
+ // Daemon setup — driven by step 0's answer, with the sudo credential it
883
+ // already primed, so nothing here can stop to ask a question. A failure
884
+ // (binary download blocked, no service manager, a daemon that won't stay
885
+ // up) never fails the wizard: the rest of setup already applied, and this
886
+ // machine simply stays on the in-process path, since `daemonConfigured` is
887
+ // only set on success.
624
888
  let daemonInstalled = false;
625
- if (scope === "user" && isDaemonSupportedPlatform()) {
889
+ if (daemonWanted) {
626
890
  const daemonResult = await installDaemonService();
627
891
  if (daemonResult.installed) {
628
892
  setDaemonConfigured(true);
@@ -643,6 +907,32 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
643
907
  });
644
908
  }
645
909
 
910
+ // Collector config, written only when the daemon actually came up: the
911
+ // daemon is what runs the collector, so writing a credential for a service
912
+ // that failed to install would leave a key on disk doing nothing.
913
+ if (collector && daemonInstalled) {
914
+ try {
915
+ writeIngestCredential(collector.cred);
916
+ writeCollectorSettings({
917
+ sessions: collector.sessions,
918
+ hooks: true,
919
+ hooksVerbosity: "decisions",
920
+ redact: "minimal",
921
+ });
922
+ // Never the key, the URL, or the count — only that it happened and what
923
+ // shape of data the user agreed to send.
924
+ void emit("configure_collector", { sessions: collector.sessions });
925
+ } catch (err) {
926
+ // Non-fatal, like the daemon: the rest of setup is worth keeping, and a
927
+ // machine with no collector behaves exactly as every release before this.
928
+ hookLogWarn(
929
+ `collector configuration was not written: ${err instanceof Error ? err.message : String(err)}`,
930
+ );
931
+ }
932
+ } else if (collector && !daemonInstalled) {
933
+ hookLogWarn("skipped collector configuration because the daemon was not installed");
934
+ }
935
+
646
936
  await applied;
647
937
  // Only now — a completed apply — is the launcher considered "seen", so the
648
938
  // first-run bare invocation stops redirecting here and opens the dashboard.
@@ -12,29 +12,40 @@
12
12
  */
13
13
  import { resolve, isAbsolute, basename } from "node:path";
14
14
  import { existsSync, readdirSync } from "node:fs";
15
+ import { readFile } from "node:fs/promises";
15
16
  import { pathToFileURL } from "node:url";
16
17
  import { homedir } from "node:os";
18
+ import { createHash } from "node:crypto";
17
19
  import { hookLogWarn, hookLogError, hookLogInfo } from "./hook-logger";
18
- import { getCustomHooks, clearCustomHooks } from "./custom-hooks-registry";
20
+ import { customPolicies, getCustomHooks, clearCustomHooks } from "./custom-hooks-registry";
19
21
  import { findDistIndex, rewriteFileTree, TMP_SUFFIX, cleanupTmpFiles } from "./loader-utils";
20
22
  import { findProjectConfigDir } from "./hooks-config";
21
23
  import { trackHookEvent } from "./hook-telemetry";
22
24
  import { getInstanceId } from "../../lib/telemetry-id";
23
25
  import type { CustomHook } from "./policy-types";
26
+ import type { CloudManagedPolicyArtifact } from "./cloud-managed-policies";
24
27
 
25
28
  const LOADING_KEY = "__FAILPROOFAI_LOADING_HOOKS__";
26
29
 
27
30
  /** Regex matching convention policy filenames: *policies.{js,mjs,ts} */
28
31
  const CONVENTION_FILE_RE = /policies\.(js|mjs|ts)$/;
29
32
 
30
- /**
31
- * Monotonic counter used to cache-bust each dynamic import. Loading N files
32
- * creates N module instances rather than reusing one; every caller here is
33
- * either a one-shot CLI command or a short-lived hook process, so the
34
- * lifetime cost is bounded by the process, not by uptime.
35
- */
33
+ /** Monotonic suffix for physically distinct temporary module trees. Bun
34
+ * ignores URL query strings when caching imports, so a query-only cache
35
+ * buster silently drops custom policies after the first request in the warm
36
+ * daemon worker. A unique filename is honored by both Bun and Node. */
36
37
  let loadSequence = 0;
37
38
 
39
+ interface CachedPolicyModule {
40
+ fingerprint: string;
41
+ hooks: CustomHook[];
42
+ }
43
+
44
+ /** One retained module instance per source entry. Reusing its registered hook
45
+ * functions prevents an unbounded ESM module-cache leak in the resident worker.
46
+ * A source-tree change replaces this entry and imports one new module. */
47
+ const policyModuleCache = new Map<string, CachedPolicyModule>();
48
+
38
49
  /** Script extensions we could load, used to spot near-miss filenames. */
39
50
  const LOADABLE_EXT_RE = /\.(js|mjs|ts)$/;
40
51
 
@@ -101,23 +112,27 @@ async function loadSingleFile(
101
112
  const distIndex = await findDistIndex();
102
113
  const distUrl = distIndex ? pathToFileURL(distIndex).href : null;
103
114
 
104
- tmpFiles = await rewriteFileTree(absPath, distUrl, distIndex);
105
-
106
- const entryTmp = absPath + TMP_SUFFIX;
107
- // Cache-bust the specifier. NOTE this works under Node but NOT under Bun,
108
- // which caches by resolved path and ignores the query — and the shipped
109
- // binary runs under Bun. It is kept because it makes repeat loads behave
110
- // the same on both runtimes where it can, but nothing may DEPEND on a
111
- // repeat load re-executing; callers dedupe paths instead (see above).
112
- // The temp path is deterministic, so a second
113
- // load of the same file inside one process hit the ESM module cache, the
114
- // module body never re-ran, no `customPolicies.add` fired, and the caller
115
- // saw zero hooks — indistinguishable from a broken policy file. That is
116
- // exactly what `failproofai policies` rendered as "failed to load" for
117
- // every file when it walked a shared project/user directory twice.
118
- // A distinct query string makes it a distinct module to the loader.
119
- const fileUrl = `${pathToFileURL(entryTmp).href}?v=${++loadSequence}`;
115
+ const sequence = ++loadSequence;
116
+ const tmpSuffix = `${TMP_SUFFIX}.${process.pid}.${sequence}.mjs`;
117
+ tmpFiles = await rewriteFileTree(absPath, distUrl, distIndex, tmpSuffix);
118
+
119
+ const fingerprint = await fingerprintTemporaryTree(tmpFiles, tmpSuffix);
120
+ const cached = policyModuleCache.get(absPath);
121
+ if (cached?.fingerprint === fingerprint) {
122
+ for (const hook of cached.hooks) customPolicies.add({ ...hook });
123
+ return;
124
+ }
125
+
126
+ const entryTmp = absPath + tmpSuffix;
127
+ const fileUrl = pathToFileURL(entryTmp).href;
128
+ const hooksBefore = getCustomHooks().length;
120
129
  await import(/* webpackIgnore: true */ fileUrl);
130
+ policyModuleCache.set(absPath, {
131
+ fingerprint,
132
+ hooks: getCustomHooks()
133
+ .slice(hooksBefore)
134
+ .map((hook) => ({ ...hook })),
135
+ });
121
136
  } catch (err) {
122
137
  const msg = err instanceof Error ? err.message : String(err);
123
138
  const errorType = /Cannot find module|MODULE_NOT_FOUND|ENOENT/i.test(msg)
@@ -139,6 +154,18 @@ async function loadSingleFile(
139
154
  }
140
155
  }
141
156
 
157
+ async function fingerprintTemporaryTree(tmpFiles: string[], tmpSuffix: string): Promise<string> {
158
+ const hash = createHash("sha256");
159
+ for (const tmpFile of [...tmpFiles].sort()) {
160
+ // The generated filenames change per load. Normalize both the logical path
161
+ // and rewritten import specifiers so identical source graphs hash equally.
162
+ hash.update(tmpFile.replaceAll(tmpSuffix, TMP_SUFFIX));
163
+ const contents = await readFile(tmpFile, "utf8");
164
+ hash.update(contents.replaceAll(tmpSuffix, TMP_SUFFIX));
165
+ }
166
+ return hash.digest("hex");
167
+ }
168
+
142
169
  /**
143
170
  * Load a single explicit custom hooks file (legacy API).
144
171
  * Clears the registry, loads the file, returns registered hooks.
@@ -214,7 +241,11 @@ function warnSkippedPolicyFiles(dir: string, scope: "project" | "user"): void {
214
241
 
215
242
  export async function loadAllCustomHooks(
216
243
  customPoliciesPaths: string | string[] | undefined,
217
- opts?: { sessionCwd?: string; customPoliciesEnabled?: boolean },
244
+ opts?: {
245
+ sessionCwd?: string;
246
+ customPoliciesEnabled?: boolean;
247
+ cloudManagedPolicies?: CloudManagedPolicyArtifact[];
248
+ },
218
249
  ): Promise<LoadAllResult> {
219
250
  clearCustomHooks();
220
251
 
@@ -235,6 +266,9 @@ export async function loadAllCustomHooks(
235
266
  // twice per event. Seeded by the explicit path below and consulted by both
236
267
  // convention passes.
237
268
  const loadedPaths = new Set<string>();
269
+ const cloudManagedByPath = new Map(
270
+ (opts?.cloudManagedPolicies ?? []).map((policy) => [resolve(policy.path), policy]),
271
+ );
238
272
 
239
273
  // 1. Explicit custom policy paths. Accept a string for callers/configs using
240
274
  // the legacy singular form.
@@ -250,7 +284,17 @@ export async function loadAllCustomHooks(
250
284
  const hooksBefore = getCustomHooks().length;
251
285
  await loadSingleFile(absPath);
252
286
  for (const hook of getCustomHooks().slice(hooksBefore)) {
253
- (hook as CustomHook & { __policyId?: string }).__policyId = customPolicyId(absPath, hook.name);
287
+ const cloudManaged = cloudManagedByPath.get(absPath);
288
+ const tagged = hook as CustomHook & {
289
+ __policyId?: string;
290
+ __cloudManaged?: CloudManagedPolicyArtifact;
291
+ };
292
+ if (cloudManaged) {
293
+ tagged.__cloudManaged = cloudManaged;
294
+ tagged.__policyId = `cloud:${cloudManaged.id}@${cloudManaged.revision}:${hook.name}`;
295
+ } else {
296
+ tagged.__policyId = customPolicyId(absPath, hook.name);
297
+ }
254
298
  }
255
299
  }
256
300
  } else {
@@ -17,6 +17,7 @@ import { resolve } from "node:path";
17
17
  import { homedir } from "node:os";
18
18
  import type { IntegrationType } from "./types";
19
19
  import { readHooksConfig } from "./hooks-config";
20
+ import { existsSync } from "node:fs";
20
21
 
21
22
  const PROTOCOL_VERSION = 1;
22
23
 
@@ -66,6 +67,23 @@ export interface DaemonHookResponse {
66
67
  stderr: string;
67
68
  }
68
69
 
70
+ /**
71
+ * Whether a daemon is listening, regardless of how it was started.
72
+ *
73
+ * Distinct from `daemonServiceStatus()`, which asks the service manager. A
74
+ * daemon run by hand — during development, or from a container — is invisible
75
+ * to that check but is very much running and pulling policy, so reporting "not
76
+ * installed, so nothing will be pulled" at someone whose machine is actively
77
+ * enforcing is simply false.
78
+ */
79
+ export function daemonSocketPresent(): boolean {
80
+ try {
81
+ return existsSync(socketPath());
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
69
87
  function socketPath(): string {
70
88
  if (process.env.FAILPROOFAI_DAEMON_SOCKET) return process.env.FAILPROOFAI_DAEMON_SOCKET;
71
89
  return resolve(homedir(), ".failproofai", "run", "failproofaid.sock");