failproofai 1.0.0-beta.2 → 1.0.0-beta.4

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 (234) 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 +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  39. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  41. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/index.html +1 -1
  44. package/.next/standalone/.next/server/app/index.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  48. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  56. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  57. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  60. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  71. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  72. package/.next/standalone/.next/server/app/projects/page.js +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  89. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  90. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  91. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  106. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_1q9xxca.js} +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  116. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  117. package/.next/standalone/.next/server/pages/404.html +1 -1
  118. package/.next/standalone/.next/server/pages/500.html +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  121. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
  124. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
  128. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  129. package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
  130. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  132. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  135. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  137. package/.next/standalone/Cargo.lock +1992 -198
  138. package/.next/standalone/Cargo.toml +1 -1
  139. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  140. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  141. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  142. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  143. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  144. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  145. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  146. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  147. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  148. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  149. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  150. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  151. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  152. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  153. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  154. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  155. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  156. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  157. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  158. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  159. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  160. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  161. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  162. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  163. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  164. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  165. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  166. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  167. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  168. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  169. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  170. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  171. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  172. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  173. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  174. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  175. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  176. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  177. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  178. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  179. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  180. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  181. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  182. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  183. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  184. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  185. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  186. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  187. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  188. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  189. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  190. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  191. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  192. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  193. package/.next/standalone/package.json +15 -9
  194. package/.next/standalone/server.js +1 -1
  195. package/bin/failproofai.mjs +129 -0
  196. package/dist/cli.mjs +2255 -779
  197. package/dist/worker.mjs +365 -52
  198. package/package.json +15 -9
  199. package/scripts/build-daemon-packages.mjs +210 -0
  200. package/scripts/daemon-platforms.mjs +42 -0
  201. package/scripts/prune-standalone.mjs +6 -0
  202. package/scripts/publish-aliases.mjs +62 -41
  203. package/src/hooks/builtin-policies.ts +139 -0
  204. package/src/hooks/cloud-connection.ts +205 -0
  205. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  206. package/src/hooks/cloud-enrollment.ts +173 -0
  207. package/src/hooks/cloud-managed-policies.ts +138 -0
  208. package/src/hooks/collector-config.ts +226 -0
  209. package/src/hooks/configure-wizard.ts +221 -5
  210. package/src/hooks/custom-hooks-loader.ts +69 -25
  211. package/src/hooks/daemon-client.ts +18 -0
  212. package/src/hooks/daemon-download.ts +137 -27
  213. package/src/hooks/daemon-service.ts +23 -8
  214. package/src/hooks/handler.ts +144 -6
  215. package/src/hooks/hook-activity-store.ts +50 -0
  216. package/src/hooks/loader-utils.ts +6 -4
  217. package/src/hooks/policy-types.ts +35 -0
  218. package/src/hooks/session-pause-cli.ts +157 -0
  219. package/src/hooks/session-pause.ts +236 -0
  220. package/src/hooks/tui.ts +115 -0
  221. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  225. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  226. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  227. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  228. package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
  229. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  230. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  231. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  232. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
  233. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
  234. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
@@ -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");
@@ -1,20 +1,31 @@
1
1
  /**
2
- * Fetches the compiled `failproofaid` binary for this platform from the
3
- * GitHub Release that matches this CLI's own version.
2
+ * Gets the compiled `failproofaid` binary for this platform onto disk, from
3
+ * whichever of its two channels can supply it.
4
4
  *
5
- * Why a download rather than a dependency: the daemon is a 4-way
6
- * cross-compiled Rust binary, and shipping it through npm means four
7
- * per-platform packages that every install resolves, an npm org to own the
8
- * scope, and a second publish channel that can silently drift from the first
9
- * (it already did — the platform packages were declared as
10
- * `optionalDependencies` and never published, so every install of them 404'd).
11
- * The release assets have to exist for standalone installs regardless, so
12
- * pointing the CLI at the same artifacts leaves exactly one channel.
5
+ * **npm first.** The four binaries publish as
6
+ * `@failproofai/failproofaid-<os>-<arch>` platform packages, pinned as
7
+ * `optionalDependencies` of this package, so `npm install failproofai` has
8
+ * usually already brought down the one matching this machine — `os`/`cpu`
9
+ * make npm skip the other three. Copying that file costs no network at all,
10
+ * which is the only thing that works behind a proxy or air-gapped.
13
11
  *
14
- * The URL is *constructed*, never discovered — no API call, no `releases/latest`
15
- * redirect, no rate limit, and no chance of fetching a daemon built from
16
- * different source than the CLI talking to it: the tag is this package's own
17
- * version, so a CLI upgrade downloads its own matching binary.
12
+ * **The GitHub Release second.** `failproofaid-<os>-<arch>.gz` plus a
13
+ * `SHA256SUMS` manifest are attached to the release tagged with this CLI's own
14
+ * version. That channel has to exist regardless — it is how anyone installs
15
+ * the daemon standalone, and how an install that skipped optional dependencies
16
+ * still gets one.
17
+ *
18
+ * The pair failed once before in a way worth keeping in mind: the platform
19
+ * packages were declared as `optionalDependencies` and never published, so
20
+ * every install resolved four 404s (see CHANGELOG 1.0.0-beta.3). What makes
21
+ * them safe now is that `scripts/build-daemon-packages.mjs` publishes them
22
+ * *before* the root package that pins them, and fails the release if it
23
+ * cannot — the names always exist by the time anything depends on them.
24
+ *
25
+ * The download URL is *constructed*, never discovered — no API call, no
26
+ * `releases/latest` redirect, no rate limit, and no chance of fetching a daemon
27
+ * built from different source than the CLI talking to it: the tag is this
28
+ * package's own version, so a CLI upgrade fetches its own matching binary.
18
29
  *
19
30
  * Everything here fails closed and never throws. A daemon that cannot be
20
31
  * fetched leaves the machine on the in-process path it was already using;
@@ -22,9 +33,10 @@
22
33
  * this writes an executable that a service manager will then run at login.
23
34
  */
24
35
  import { createHash } from "node:crypto";
25
- import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
36
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
37
+ import { createRequire } from "node:module";
26
38
  import { homedir } from "node:os";
27
- import { resolve } from "node:path";
39
+ import { dirname, resolve } from "node:path";
28
40
  import { gunzipSync } from "node:zlib";
29
41
  import { version } from "../../package.json";
30
42
  import type { PlatformKey } from "./daemon-service";
@@ -82,6 +94,114 @@ export interface DaemonDownloadResult {
82
94
  error?: string;
83
95
  }
84
96
 
97
+ /** The npm package carrying the daemon binary for one platform. */
98
+ export function platformPackageName(key: PlatformKey): string {
99
+ return `@failproofai/failproofaid-${key}`;
100
+ }
101
+
102
+ /**
103
+ * Locates the binary inside the installed platform package, or null.
104
+ *
105
+ * Resolution is anchored at `FAILPROOFAI_PACKAGE_ROOT` (set by
106
+ * `bin/failproofai.mjs` and the bundled `dist/cli.mjs`) rather than at
107
+ * `import.meta.url`, which does not survive the CJS bundle. Node's algorithm
108
+ * walks up from there, which covers both real layouts: a global install nests
109
+ * its dependencies at `<root>/node_modules/@failproofai/…`, a local one hoists
110
+ * them to `<project>/node_modules/@failproofai/…`.
111
+ *
112
+ * The specifier is *computed*, deliberately. A literal would make the bundler
113
+ * try to resolve a package that is optional, platform-specific, and absent on
114
+ * three machines out of four at build time.
115
+ */
116
+ export function npmPlatformBinaryPath(key: PlatformKey): string | null {
117
+ const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
118
+ if (!packageRoot) return null;
119
+ const name = platformPackageName(key);
120
+
121
+ try {
122
+ const requireFromRoot = createRequire(resolve(packageRoot, "package.json"));
123
+ const found = platformPackageBinary(dirname(requireFromRoot.resolve(`${name}/package.json`)));
124
+ if (found) return found;
125
+ } catch {
126
+ // Not installed (the common case on a machine that skipped optional
127
+ // dependencies), or a resolver that cannot see it. The direct check below
128
+ // is the fallback, not a failure.
129
+ }
130
+
131
+ return platformPackageBinary(resolve(packageRoot, "node_modules", name));
132
+ }
133
+
134
+ /**
135
+ * The binary inside one candidate platform-package directory, if it is the
136
+ * right one.
137
+ *
138
+ * The version check is not belt-and-braces. npm pins the exact version, but a
139
+ * workspace holding two `failproofai` versions can hoist the other one's
140
+ * platform package to the top of the tree — and installing that binary under
141
+ * *this* version's filename would put a daemon built from different source
142
+ * behind a CLI that believes it matches, which is the single thing the
143
+ * versioned install path exists to prevent. A mismatch falls through to the
144
+ * download, whose URL is pinned to this version.
145
+ */
146
+ function platformPackageBinary(dir: string): string | null {
147
+ try {
148
+ const manifest = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf8")) as {
149
+ version?: string;
150
+ };
151
+ if (manifest.version !== version) return null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ const candidate = resolve(dir, "bin", "failproofaid");
156
+ return existsSync(candidate) ? candidate : null;
157
+ }
158
+
159
+ /**
160
+ * Writes the daemon binary to its versioned path.
161
+ *
162
+ * Shared by both channels so a binary lands identically however it arrived:
163
+ * staged in the same directory as the target (so the install is an atomic
164
+ * rename within one filesystem, never a half-written executable a service
165
+ * manager could pick up), mode 0755, pid-suffixed so two concurrent wizards
166
+ * stay off each other's file.
167
+ */
168
+ function installBinaryBytes(binary: Buffer): string {
169
+ const target = installedBinaryPath();
170
+ const tempPath = `${target}.${process.pid}.tmp`;
171
+ try {
172
+ mkdirSync(daemonBinaryDir(), { recursive: true });
173
+ writeFileSync(tempPath, binary);
174
+ chmodSync(tempPath, 0o755);
175
+ renameSync(tempPath, target);
176
+ return target;
177
+ } catch (err) {
178
+ rmSync(tempPath, { force: true });
179
+ throw err;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Installs the daemon from the npm platform package, if one is present.
185
+ *
186
+ * No checksum step, unlike the download: npm verified the tarball's integrity
187
+ * against the registry when it installed it, and this file never crossed the
188
+ * network on its own.
189
+ *
190
+ * Deliberately NOT gated on `FAILPROOFAI_NO_DOWNLOAD` — that variable exists
191
+ * so an air-gapped machine does not reach out, and this is a local copy. On
192
+ * exactly those machines it is the only channel that can work.
193
+ */
194
+ export function installFromNpmPackage(key: PlatformKey): DaemonDownloadResult {
195
+ const source = npmPlatformBinaryPath(key);
196
+ if (!source) return { error: `${platformPackageName(key)} is not installed` };
197
+ try {
198
+ return { path: installBinaryBytes(readFileSync(source)) };
199
+ } catch (err) {
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ return { error: `failed to install failproofaid from ${source}: ${message}` };
202
+ }
203
+ }
204
+
85
205
  async function fetchBytes(url: string): Promise<Buffer> {
86
206
  const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
87
207
  if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
@@ -118,10 +238,6 @@ export async function downloadFailproofaidBinary(key: PlatformKey): Promise<Daem
118
238
  }
119
239
 
120
240
  const assetName = `failproofaid-${key}.gz`;
121
- // Same directory as the target so the install is a rename within one
122
- // filesystem — atomic, and never a half-written binary a service manager
123
- // could pick up. The pid keeps two concurrent wizards off each other's file.
124
- const tempPath = `${target}.${process.pid}.tmp`;
125
241
 
126
242
  try {
127
243
  const [asset, manifest] = await Promise.all([
@@ -138,14 +254,8 @@ export async function downloadFailproofaidBinary(key: PlatformKey): Promise<Daem
138
254
  return { error: `checksum mismatch for ${assetName} (expected ${expected}, got ${actual})` };
139
255
  }
140
256
 
141
- const binary = gunzipSync(asset);
142
- mkdirSync(daemonBinaryDir(), { recursive: true });
143
- writeFileSync(tempPath, binary);
144
- chmodSync(tempPath, 0o755);
145
- renameSync(tempPath, target);
146
- return { path: target };
257
+ return { path: installBinaryBytes(gunzipSync(asset)) };
147
258
  } catch (err) {
148
- rmSync(tempPath, { force: true });
149
259
  const message = err instanceof Error ? err.message : String(err);
150
260
  return { error: `failed to download failproofaid v${version} for ${key}: ${message}` };
151
261
  }
@@ -22,7 +22,7 @@ import { resolve, dirname } from "node:path";
22
22
  import { execFileSync } from "node:child_process";
23
23
  import { hookLogWarn } from "./hook-logger";
24
24
  import { getConfigPathForScope } from "./hooks-config";
25
- import { downloadFailproofaidBinary, installedBinaryPath } from "./daemon-download";
25
+ import { downloadFailproofaidBinary, installFromNpmPackage, installedBinaryPath } from "./daemon-download";
26
26
 
27
27
  /**
28
28
  * Every `systemctl --user` / `launchctl` call is bounded. Both talk to a
@@ -145,13 +145,19 @@ export function resolveFailproofaidBinaryPath(): string | null {
145
145
  }
146
146
 
147
147
  /**
148
- * Resolves the binary, downloading it from this version's release if it is
149
- * not already on disk.
148
+ * Resolves the binary, installing one if it is not already on disk.
150
149
  *
151
- * Only the install path calls this. The npm package ships no binary — it is
152
- * one CLI tarball for every platform — so `failproofai config` choosing the
153
- * global scope is the moment a machine that opted into a daemon actually
154
- * acquires one.
150
+ * Only the install path calls this — `failproofai config` choosing the global
151
+ * scope is the moment a machine that opted into a daemon actually acquires
152
+ * one. Two channels, in this order:
153
+ *
154
+ * 1. The `@failproofai/failproofaid-<os>-<arch>` platform package, which a
155
+ * plain `npm install failproofai` already brought down as an optional
156
+ * dependency. No network, so it is also the only channel that works
157
+ * air-gapped or behind a proxy that blocks github.com.
158
+ * 2. The GitHub Release asset for this exact version, checksum-verified.
159
+ * Covers installs that skipped optional dependencies, older tarballs, and
160
+ * anyone installing the daemon standalone.
155
161
  */
156
162
  export async function ensureFailproofaidBinary(): Promise<{ path?: string; reason?: string }> {
157
163
  const existing = resolveFailproofaidBinaryPath();
@@ -162,9 +168,18 @@ export async function ensureFailproofaidBinary(): Promise<{ path?: string; reaso
162
168
  return { reason: `failproofaid has no prebuilt binary for ${process.platform}/${process.arch}` };
163
169
  }
164
170
 
171
+ const fromNpm = installFromNpmPackage(key);
172
+ if (fromNpm.path) return { path: fromNpm.path };
173
+
165
174
  const result = await downloadFailproofaidBinary(key);
166
175
  if (result.path) return { path: result.path };
167
- return { reason: result.error ?? "failproofaid binary could not be downloaded" };
176
+ // Both channels are worth reporting: "not installed" alone reads as a broken
177
+ // package, and the download error alone hides that npm could have supplied it.
178
+ return {
179
+ reason: result.error
180
+ ? `${result.error} (${fromNpm.error})`
181
+ : "failproofaid binary could not be installed",
182
+ };
168
183
  }
169
184
 
170
185
  /**
@@ -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);