failproofai 1.0.0-beta.2 → 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 (196) 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]__1psjybo._.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]__0nb0syg._.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_0oy_-36.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/{3s7myk_co0gbh.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/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.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/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.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 +2195 -770
  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 +221 -5
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/handler.ts +144 -6
  184. package/src/hooks/hook-activity-store.ts +50 -0
  185. package/src/hooks/loader-utils.ts +6 -4
  186. package/src/hooks/policy-types.ts +35 -0
  187. package/src/hooks/session-pause-cli.ts +157 -0
  188. package/src/hooks/session-pause.ts +236 -0
  189. package/src/hooks/tui.ts +115 -0
  190. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  192. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  194. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → 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,
@@ -47,6 +54,12 @@ import {
47
54
  setDaemonConfigured,
48
55
  } from "./daemon-service";
49
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";
50
63
 
51
64
  export interface WizardIO {
52
65
  stdin?: TTYIn;
@@ -365,7 +378,14 @@ export function reviewLines(state: {
365
378
  const assistantNames = clis.map((c) => getIntegration(c).displayName);
366
379
  lines.push(` Where : ${where}`);
367
380
  lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
368
- lines.push(` Policies : ${policies.length} enabled`);
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
+ );
369
389
  if (installDaemon && isDaemonSupportedPlatform()) {
370
390
  lines.push(
371
391
  ` Daemon : failproofaid, installed as a system service running as you`,
@@ -635,12 +655,23 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
635
655
  const presetChoices = buildPresetChoices(cwd, customEnabledBefore);
636
656
  const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
637
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.
638
670
  const presets = await multiSelect<string>({
639
671
  message: "What should we guard against?",
640
672
  choices: presetChoices,
641
- minSelected: 1,
642
673
  summaryNoun: "bundles",
643
- hint: "space toggles · combine presets · ↵ confirm",
674
+ hint: "space toggles · combine presets · ↵ confirm · none is fine",
644
675
  stdin,
645
676
  stdout,
646
677
  });
@@ -650,7 +681,166 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
650
681
  // locked-unchecked and must not write a disabling flag.
651
682
  const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined;
652
683
 
653
- // 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
654
844
  const decision = await selectOne<"apply" | "cancel">({
655
845
  message: "Ready to apply?",
656
846
  body: reviewLines({ scope, clis, policies, cwd, customEnabled, installDaemon: daemonWanted }),
@@ -717,6 +907,32 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
717
907
  });
718
908
  }
719
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
+
720
936
  await applied;
721
937
  // Only now — a completed apply — is the launcher considered "seen", so the
722
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");
@@ -41,6 +41,8 @@ import { resolveTranscriptPath } from "./resolve-transcript-path";
41
41
  import { getInstanceId } from "../../lib/telemetry-id";
42
42
  import { hookLogInfo, hookLogWarn } from "./hook-logger";
43
43
  import { readStdinPayload } from "./read-stdin";
44
+ import { readActiveCloudManagedPolicies, type CloudManagedPolicyArtifact } from "./cloud-managed-policies";
45
+ import { readActivePause, type ActivePause } from "./session-pause";
44
46
 
45
47
  /**
46
48
  * Canonicalize an event name to PascalCase. Codex sends snake_case event names
@@ -134,6 +136,37 @@ export interface EvaluateHookEventOptions {
134
136
  fallbackCwd?: string;
135
137
  }
136
138
 
139
+ /**
140
+ * Runs an observe-mode policy under the same timeout and error handling as an
141
+ * enforcing one, so what gets measured is what would actually have happened —
142
+ * including a policy that times out, which in enforce mode is an allow and must
143
+ * be recorded as one rather than as a would-deny.
144
+ */
145
+ async function runObserved(
146
+ hook: CustomHook,
147
+ ctx: Parameters<CustomHook["fn"]>[0],
148
+ hookName: string,
149
+ eventType: string,
150
+ cli: IntegrationType,
151
+ ): Promise<PolicyResult> {
152
+ try {
153
+ return await Promise.race([
154
+ hook.fn(ctx),
155
+ new Promise<PolicyResult>((_, reject) => setTimeout(() => reject(new Error("timeout")), 10_000)),
156
+ ]);
157
+ } catch (err) {
158
+ const msg = err instanceof Error ? err.message : String(err);
159
+ void trackHookEvent(getInstanceId(), "custom_hook_error", {
160
+ hook_name: hookName,
161
+ error_type: msg === "timeout" ? "timeout" : "exception",
162
+ event_type: eventType,
163
+ cli,
164
+ is_observe_mode: true,
165
+ });
166
+ return { decision: "allow" };
167
+ }
168
+ }
169
+
137
170
  /**
138
171
  * The core hook-evaluation logic, decoupled from process stdin/stdout so it
139
172
  * can be called repeatedly inside a long-lived process (the daemon's warm
@@ -209,6 +242,20 @@ export async function evaluateHookEvent(
209
242
  let config: HooksConfig;
210
243
  let customHooksList: CustomHook[] = [];
211
244
  let conventionHookNames = new Set<string>();
245
+ let activePause: ActivePause | null = null;
246
+ /** Registered policy name → where it came from. See the set() below. */
247
+ const policyAttribution = new Map<
248
+ string,
249
+ { source: "custom" | "convention" | "cloud"; cloudPolicyId?: string; cloudRevision?: number }
250
+ >();
251
+ let cloudGeneration: number | undefined;
252
+ /** What observe-mode policies WOULD have done, had they been enforcing. */
253
+ const observedResults: Array<{
254
+ policyId: string;
255
+ revision: number;
256
+ decision: "deny" | "instruct";
257
+ reason: string | null;
258
+ }> = [];
212
259
 
213
260
  if (opts?.forceDecision) {
214
261
  // Fail-closed: no config/custom-hook loading at all — a daemon that
@@ -228,25 +275,80 @@ export async function evaluateHookEvent(
228
275
  // Load enabled policies (merge across project/local/global scopes)
229
276
  config = readMergedHooksConfig(session.cwd);
230
277
  clearPolicies();
231
- registerBuiltinPolicies(config.enabledPolicies);
278
+
279
+ // A session pause suspends LOCAL policy only, for a bounded time. Cloud
280
+ // assignments are exempt below for the same reason `disabledCustomPolicies`
281
+ // already exempts them: a locally-issued command that could switch off a
282
+ // centrally assigned policy would make cloud enforcement decorative.
283
+ activePause = readActivePause(session.sessionId);
284
+ registerBuiltinPolicies(activePause ? [] : config.enabledPolicies);
285
+
286
+ // Cloud-managed policies are daemon-reconciled artifacts, but they use
287
+ // the same public JS policy API as local custom policies. Verify and add
288
+ // only the paths referenced by the atomically active generation.
289
+ const cloudManagedPolicies = readActiveCloudManagedPolicies();
290
+ // Recorded on every row once a machine is managed, whether or not a cloud
291
+ // policy decided this event: "what was deployed here at the time" is a
292
+ // separate question from "what decided", and only the former can tell a
293
+ // rollout that changed nothing from one that never reached the machine.
294
+ cloudGeneration = cloudManagedPolicies[0]?.generation;
295
+ const configuredCustomPaths = config.customPoliciesPaths ?? config.customPoliciesPath;
296
+ const allExplicitPaths =
297
+ cloudManagedPolicies.length === 0
298
+ ? configuredCustomPaths
299
+ : [
300
+ ...(typeof configuredCustomPaths === "string" ? [configuredCustomPaths] : configuredCustomPaths ?? []),
301
+ ...cloudManagedPolicies.map((policy) => policy.path),
302
+ ];
232
303
 
233
304
  // Load and register custom hooks (layer 2, after builtins)
234
- const loadResult = await loadAllCustomHooks(config.customPoliciesPaths ?? config.customPoliciesPath, {
305
+ const loadResult = await loadAllCustomHooks(allExplicitPaths, {
235
306
  sessionCwd: session.cwd,
236
307
  customPoliciesEnabled: config.customPoliciesEnabled,
308
+ ...(cloudManagedPolicies.length > 0 ? { cloudManagedPolicies } : {}),
237
309
  });
238
310
  customHooksList = loadResult.hooks;
239
311
  const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
240
312
  conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
241
313
 
242
314
  for (const hook of customHooksList) {
243
- const policyId = (hook as CustomHook & { __policyId?: string }).__policyId;
244
- if (policyId && disabledCustomPolicies.has(policyId)) continue;
315
+ const taggedHook = hook as CustomHook & {
316
+ __policyId?: string;
317
+ __cloudManaged?: CloudManagedPolicyArtifact;
318
+ };
319
+ const policyId = taggedHook.__policyId;
320
+ const cloudManaged = taggedHook.__cloudManaged;
321
+ // Local config cannot disable a centrally assigned policy merely by
322
+ // copying its generated ID into disabledCustomPolicies.
323
+ if (!cloudManaged && policyId && disabledCustomPolicies.has(policyId)) continue;
324
+ // Same rule for a session pause — it suspends local policy, never cloud.
325
+ if (!cloudManaged && activePause) continue;
245
326
  const hookName = hook.name;
246
327
  const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope;
247
328
  const isConvention = !!conventionScope;
248
- const prefix = isConvention ? `.failproofai-${conventionScope}` : "custom";
329
+ const prefix = cloudManaged
330
+ ? `cloud/${cloudManaged.id}@${cloudManaged.revision}`
331
+ : isConvention
332
+ ? `.failproofai-${conventionScope}`
333
+ : "custom";
334
+ // Observe mode: run it for real, record what it decided, then hand back
335
+ // an allow. Evaluating and discarding is the whole point — a rollout is
336
+ // measured against real traffic before it can break anyone's work, and
337
+ // a policy that did not actually run would measure nothing.
338
+ const observeOnly = cloudManaged?.effect === "observe";
249
339
  const fn: PolicyFunction = async (ctx): Promise<PolicyResult> => {
340
+ if (observeOnly) {
341
+ const shadow = await runObserved(hook, ctx, hookName, eventType, cli);
342
+ if (shadow.decision !== "allow") {
343
+ observedResults.push({
344
+ policyId: cloudManaged!.id,
345
+ revision: cloudManaged!.revision,
346
+ decision: shadow.decision,
347
+ reason: shadow.reason ?? null,
348
+ });
349
+ }
350
+ return { decision: "allow" };
351
+ }
250
352
  try {
251
353
  const result = await Promise.race([
252
354
  hook.fn(ctx),
@@ -268,8 +370,18 @@ export async function evaluateHookEvent(
268
370
  return { decision: "allow" };
269
371
  }
270
372
  };
373
+ const registeredName = `${prefix}/${hookName}`;
374
+ // Record where this policy came from as structured data, keyed by the
375
+ // exact name the evaluator will report back. The display name already
376
+ // encodes it ("cloud/org-guard@7/…"), but only as a string — so the one
377
+ // question cloud attribution has to answer, "which rollout produced
378
+ // this decision", could only be answered by re-parsing our own label.
379
+ policyAttribution.set(registeredName, {
380
+ source: cloudManaged ? "cloud" : isConvention ? "convention" : "custom",
381
+ ...(cloudManaged ? { cloudPolicyId: cloudManaged.id, cloudRevision: cloudManaged.revision } : {}),
382
+ });
271
383
  registerPolicy(
272
- `${prefix}/${hookName}`,
384
+ registeredName,
273
385
  hook.description ?? "",
274
386
  fn,
275
387
  hook.match ?? {},
@@ -335,6 +447,32 @@ export async function evaluateHookEvent(
335
447
  cwd: session.cwd,
336
448
  permissionMode: session.permissionMode,
337
449
  hookEventName: session.hookEventName,
450
+ // Attribution. A builtin is anything registered that is not in the map,
451
+ // so its absence is meaningful rather than missing — but only when a
452
+ // policy actually decided; a plain allow names nobody.
453
+ ...(result.policyName
454
+ ? (() => {
455
+ const attribution = policyAttribution.get(result.policyName);
456
+ return {
457
+ policySource: attribution?.source ?? ("builtin" as const),
458
+ ...(attribution?.cloudPolicyId ? { cloudPolicyId: attribution.cloudPolicyId } : {}),
459
+ ...(attribution?.cloudRevision !== undefined
460
+ ? { cloudRevision: attribution.cloudRevision }
461
+ : {}),
462
+ };
463
+ })()
464
+ : {}),
465
+ ...(cloudGeneration !== undefined ? { cloudGeneration } : {}),
466
+ // The point of observe mode is this record. Without it the rollout is
467
+ // unmeasurable and the row is indistinguishable from one where the policy
468
+ // never matched at all.
469
+ ...(observedResults.length > 0 ? { observed: observedResults } : {}),
470
+ // Without these, a row logged during a pause is indistinguishable from
471
+ // one where every policy ran and allowed — the log would assert a clean
472
+ // window over exactly the window that was not enforced.
473
+ ...(activePause
474
+ ? { pausedBy: activePause.setBy, pauseExpiresAt: activePause.expiresAt }
475
+ : {}),
338
476
  };
339
477
  try {
340
478
  persistHookActivity(activityEntry);