failproofai 0.0.14-beta.0 → 0.0.14-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/.next/standalone/.codex/hooks.json +6 -6
  2. package/.next/standalone/.cursor/hooks.json +6 -6
  3. package/.next/standalone/.factory/hooks.json +9 -9
  4. package/.next/standalone/.next/BUILD_ID +1 -1
  5. package/.next/standalone/.next/build-manifest.json +5 -5
  6. package/.next/standalone/.next/prerender-manifest.json +3 -3
  7. package/.next/standalone/.next/required-server-files.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  9. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  11. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  14. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  16. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  20. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  24. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  25. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  26. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  27. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  29. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  30. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page.js.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 +2 -2
  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 +2 -2
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  56. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0--lkk6._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-219ec._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__04_h-a5._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08w4wmd._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g6bbuw._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q0qzx1._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q2nbsl._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0rv7m0k._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0xuaoik._.js +3 -3
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__16le-kd._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ffkmds._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1legmza._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mrihkj._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qb590j._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1shcqgr._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1xuedea._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  95. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00cz9as._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__015_i4t._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__030k0c6._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03lv-pe._.js +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__042d3cd._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__05r_17v._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09q1168._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07qfsfa._.js → [root-of-the-server]__09v9cwb._.js} +2 -2
  106. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e2l3c1._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h12me5._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hci7t3._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hdsupo._.js +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p84xee._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qeuy9c._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s89hoe._.js +2 -2
  113. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0twi4fj._.js +3 -0
  114. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ynf7tx._.js +2 -2
  115. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11rtg6s._.js +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17ai7sy._.js +2 -2
  119. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1dky4g0._.js +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1nq7ivq._.js +1 -1
  121. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tkjqka._.js +2 -2
  122. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__212nf49._.js +1 -1
  123. package/.next/standalone/.next/server/chunks/ssr/_05whahf._.js +1 -1
  124. package/.next/standalone/.next/server/chunks/ssr/_1kje4fm._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/app_087bt9w._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/app_1fvisnp._.js +1 -1
  127. package/.next/standalone/.next/server/chunks/ssr/app_209u41o._.js +1 -1
  128. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
  129. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  130. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  131. package/.next/standalone/.next/server/chunks/ssr/lib_0xkhw_v._.js +1 -1
  132. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0g5r64e.js → node_modules_html-to-image_es_index_06xsmte.js} +1 -1
  133. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0-uvagv.js +1 -1
  134. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_03c7gi5.js +1 -1
  135. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_09p-8om.js +1 -1
  136. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0v-kfiu.js +1 -1
  137. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0xrgzyz.js +1 -1
  138. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1806lsc.js +1 -1
  139. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1j6dd-e.js +1 -1
  140. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1sa65r-.js +1 -1
  141. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +1 -1
  142. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  143. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  144. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  145. package/.next/standalone/.next/server/pages/404.html +1 -1
  146. package/.next/standalone/.next/server/pages/500.html +1 -1
  147. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  148. package/.next/standalone/.next/server/server-reference-manifest.json +10 -10
  149. package/.next/standalone/.next/static/chunks/{13f1kmjea-0md.js → 02jcotghcxrbu.js} +1 -1
  150. package/.next/standalone/.next/static/chunks/{1374x9bv_s_vg.js → 0h-6ymo9v86b0.js} +2 -2
  151. package/.next/standalone/.next/static/chunks/0pndjhcxfu-0t.js +1 -0
  152. package/.next/standalone/.next/static/chunks/1-33f82_l32or.js +1 -0
  153. package/.next/standalone/.next/static/chunks/{003uz088wbmz7.js → 1bg_g3g359c_d.js} +1 -1
  154. package/.next/standalone/.next/static/chunks/{454dyizqik6t9.js → 1gg-8e9q_m__9.js} +2 -2
  155. package/.next/standalone/.next/static/chunks/{2ufuwhv03yvtb.js → 29zkom4s9de2d.js} +1 -1
  156. package/.next/standalone/.next/static/chunks/{17sdztgbcc0fi.js → 2llkdafnaejw_.js} +1 -1
  157. package/.next/standalone/.next/static/chunks/{0iinhn4417nk1.js → 3-ogr79xqbw0h.js} +1 -1
  158. package/.next/standalone/.next/static/chunks/36j05kb2x323i.css +2 -0
  159. package/.next/standalone/.next/static/chunks/{43weu8f59tlix.js → 388hgfqye67xa.js} +1 -1
  160. package/.next/standalone/.next/static/chunks/396764jgd7-9j.js +1 -0
  161. package/.next/standalone/.next/static/chunks/3w7g0n0iwwnbs.js +1 -0
  162. package/.next/standalone/.next/static/chunks/{turbopack-0vavqo2d-tlt3.js → turbopack-10hj8t5pf33tu.js} +1 -1
  163. package/.next/standalone/.opencode/plugins/failproofai.mjs +30 -11
  164. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +11 -2
  165. package/.next/standalone/app/components/copy-button.tsx +22 -6
  166. package/.next/standalone/app/components/global-error-listeners.tsx +27 -0
  167. package/.next/standalone/components/reach-developers.tsx +1 -1
  168. package/.next/standalone/docker-hook-sync/Dockerfile +8 -4
  169. package/.next/standalone/integration-suite/Dockerfile +47 -0
  170. package/.next/standalone/integration-suite/README.md +87 -0
  171. package/.next/standalone/integration-suite/canary-policies.mjs +45 -0
  172. package/.next/standalone/integration-suite/capture-tokens.sh +48 -0
  173. package/.next/standalone/integration-suite/ci-entrypoint.sh +164 -0
  174. package/.next/standalone/integration-suite/inject-tokens.sh +44 -0
  175. package/.next/standalone/integration-suite/install-clis.sh +91 -0
  176. package/.next/standalone/integration-suite/probe-cli.sh +208 -0
  177. package/.next/standalone/integration-suite/report.js +74 -0
  178. package/.next/standalone/integration-suite/run.sh +107 -0
  179. package/.next/standalone/lib/error-origin.ts +67 -0
  180. package/.next/standalone/lib/format-duration.ts +23 -9
  181. package/.next/standalone/lib/install-check.ts +231 -0
  182. package/.next/standalone/package.json +1 -3
  183. package/.next/standalone/server.js +1 -1
  184. package/README.md +1 -17
  185. package/bin/failproofai.mjs +11 -1
  186. package/dist/cli.mjs +818 -320
  187. package/lib/error-origin.ts +67 -0
  188. package/lib/format-duration.ts +23 -9
  189. package/lib/install-check.ts +231 -0
  190. package/package.json +1 -3
  191. package/scripts/dev-hook.mjs +292 -0
  192. package/scripts/install-diagnosis.mjs +5 -8
  193. package/scripts/install-telemetry.mjs +12 -4
  194. package/scripts/translate-docs/cli.ts +53 -1
  195. package/scripts/translate-docs/mdx-translator.ts +158 -18
  196. package/scripts/translate-docs/readme-translator.ts +36 -18
  197. package/scripts/translate-docs/translator.ts +180 -9
  198. package/scripts/translate-docs/types.ts +6 -0
  199. package/scripts/translate-docs/validate-translation.ts +132 -0
  200. package/scripts/validate-mdx.ts +92 -15
  201. package/src/audit/cli.ts +53 -15
  202. package/src/auth/cli.ts +14 -5
  203. package/src/hooks/builtin-policies.ts +168 -50
  204. package/src/hooks/configure-wizard.ts +238 -22
  205. package/src/hooks/custom-hooks-loader.ts +70 -4
  206. package/src/hooks/handler.ts +4 -1
  207. package/src/hooks/policy-types.ts +8 -0
  208. package/src/hooks/tui.ts +96 -29
  209. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ge8e7w._.js +0 -3
  210. package/.next/standalone/.next/static/chunks/0405htbxay9vd.js +0 -1
  211. package/.next/standalone/.next/static/chunks/2m_tbl39fcedn.js +0 -1
  212. package/.next/standalone/.next/static/chunks/2o3ql0n5owqsw.js +0 -1
  213. package/.next/standalone/.next/static/chunks/35-90m9py64d4.js +0 -1
  214. package/.next/standalone/.next/static/chunks/3r--yyjkp9_7b.css +0 -2
  215. package/scripts/postinstall.mjs +0 -242
  216. package/scripts/preuninstall.mjs +0 -131
  217. /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → YKxI22hC-F_nHp7MEKfb1}/_buildManifest.js +0 -0
  218. /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → YKxI22hC-F_nHp7MEKfb1}/_clientMiddlewareManifest.js +0 -0
  219. /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → YKxI22hC-F_nHp7MEKfb1}/_ssgManifest.js +0 -0
@@ -172,7 +172,8 @@ const CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh|dash|ksh|csh|tcsh|
172
172
  const PS_WEB_PIPE_RE = /(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm)\s+.*\|\s*(?:Invoke-Expression|iex)/i;
173
173
 
174
174
  // blockForcePush
175
- const FORCE_PUSH_RE = /(?:--force|-f\b)/;
175
+ const SHORT_FLAG_BUNDLE_RE = /^-[a-zA-Z]*f[a-zA-Z]*$/;
176
+ const SAFE_FORCE_PREFIXES = ["--force-with-lease", "--force-if-includes"] as const;
176
177
 
177
178
  // blockSecretsWrite
178
179
  const SECRET_FILE_RE = /\.(?:pem|key)$/;
@@ -587,36 +588,154 @@ function blockPushMaster(ctx: PolicyContext): PolicyResult {
587
588
  return allow();
588
589
  }
589
590
 
591
+ // -- block-rm-rf: deletion-target resolution --
592
+
593
+ /**
594
+ * Leading shell forms that resolve to a home directory: `~`, `~user`, `$HOME`,
595
+ * `${HOME}`. The lookahead keeps `$HOMEBREW_PREFIX` (a variable this policy
596
+ * cannot resolve) from being mistaken for `$HOME`.
597
+ */
598
+ const HOME_PREFIX_RE = /^(?:~[A-Za-z0-9_.-]*|\$HOME|\$\{HOME\})(?=$|\/)/;
599
+
600
+ /** `rm`, `/bin/rm`, `/usr/bin/rm`, … — the command word of a delete. */
601
+ const RM_CMD_RE = /^(?:\/\S*\/)?rm$/;
602
+
603
+ /** `find` expression that deletes: `-delete`, `-exec rm`, `-execdir rm`, `-ok rm`. */
604
+ // Same shape as RM_CMD_RE: `find` is just as dangerous when invoked by
605
+ // absolute path (`/usr/bin/find / -delete`), so match both forms.
606
+ const FIND_CMD_RE = /^(?:\/\S*\/)?find$/;
607
+ const FIND_EXEC_RE = /^-(?:exec|execdir|ok|okdir)$/;
608
+
609
+ /** find's global options, which precede its path operands (`find -L / -delete`). */
610
+ const FIND_GLOBAL_OPT_RE = /^-(?:[HLP]|D|O\d*)$/;
611
+
612
+ /** First token of find's expression — everything before it is a path operand. */
613
+ const FIND_EXPR_START_RE = /^(?:-|\\?[(!])/;
614
+
615
+ /**
616
+ * Roots that exist to hold throwaway data. A delete of the root itself is still
617
+ * catastrophic (`rm -rf /tmp` wipes every process's scratch space); a delete of
618
+ * something *inside* one is ordinary work.
619
+ */
620
+ const SCRATCH_ROOTS = ["/tmp", "/var/tmp"];
621
+
622
+ /**
623
+ * How many path segments below a root (`/` or a home directory) a delete has to
624
+ * reach before it stops being catastrophic. `/etc` (1) and `/home/chetan` (2)
625
+ * are system- and user-level directories; `/home/chetan/project` (3) is a
626
+ * specific thing the caller meant to delete.
627
+ */
628
+ const CATASTROPHIC_DEPTH = 2;
629
+
630
+ /** Expand the leading `~` / `$HOME` / `${HOME}` of a path to the real home directory. */
631
+ function expandHomePrefix(path: string): string {
632
+ const m = path.match(/^(?:~|\$HOME|\$\{HOME\})(?=$|\/)/);
633
+ return m ? homedir() + path.slice(m[0].length) : path;
634
+ }
635
+
636
+ /** Drop a trailing `/*` glob and any trailing slashes: `/tmp/foo/*` → `/tmp/foo`. */
637
+ function stripTrailingGlob(path: string): string {
638
+ return path.replace(/\/\*$/, "").replace(/\/+$/, "");
639
+ }
640
+
641
+ /**
642
+ * Would deleting this target be catastrophic?
643
+ *
644
+ * Rather than pattern-matching the raw command text, this resolves the token as
645
+ * far as the shell would: quotes are stripped, `~` / `$HOME` are recognised as a
646
+ * root in their own right, and the remaining path segments are counted. A target
647
+ * within {@link CATASTROPHIC_DEPTH} segments of either root (`/` or home) takes
648
+ * out the machine or the user's data.
649
+ *
650
+ * Fails safe: a token whose head is an expansion this policy cannot evaluate —
651
+ * command substitution (`$(…)`, backticks) or any variable other than `$HOME` —
652
+ * could expand to `/`, so it counts as catastrophic. Relative targets are not
653
+ * flagged: they resolve under the working directory, not under a root.
654
+ */
655
+ function isCatastrophicTarget(token: string): boolean {
656
+ const raw = token.replace(/^['"]|['"]$/g, "");
657
+ if (raw === "") return false;
658
+
659
+ const homePrefix = raw.match(HOME_PREFIX_RE);
660
+ // Unresolvable head: `$(echo /)`, `` `pwd` ``, `$TARGET_DIR`, …
661
+ if (!homePrefix && /^[$`]/.test(raw)) return true;
662
+
663
+ const belowRoot = homePrefix ? raw.slice(homePrefix[0].length) : raw.startsWith("/") ? raw : null;
664
+ if (belowRoot === null) return false;
665
+
666
+ const segments = stripTrailingGlob(belowRoot).split("/").filter(Boolean);
667
+ if (!homePrefix && SCRATCH_ROOTS.some((r) => `/${segments.join("/")}`.startsWith(`${r}/`))) return false;
668
+ return segments.length <= CATASTROPHIC_DEPTH;
669
+ }
670
+
590
671
  /**
591
- * Check whether all *recursive* rm path targets in a command are under an allowlisted path.
672
+ * The paths a single command segment would recursively delete, or `null` when the
673
+ * segment is not a recursive delete at all.
674
+ *
675
+ * Understands the two shapes that reach every file under a target: `rm` with both
676
+ * `-r` and `-f` (in any spelling or order), and `find`, which recurses by design,
677
+ * paired with `-delete` or an `-exec rm`.
678
+ */
679
+ function recursiveDeletionTargets(seg: string): string[] | null {
680
+ const tokens = parseArgvTokens(seg);
681
+
682
+ const findIdx = tokens.findIndex((t) => FIND_CMD_RE.test(t));
683
+ if (findIdx >= 0) {
684
+ const expr = tokens.slice(findIdx + 1);
685
+ const execIdx = expr.findIndex((t) => FIND_EXEC_RE.test(t));
686
+ const deletes = expr.includes("-delete") || (execIdx >= 0 && RM_CMD_RE.test(expr[execIdx + 1] ?? ""));
687
+ if (deletes) {
688
+ // find's path operands sit between the leading global options and the
689
+ // first expression token: `find -L /home/chetan -name '*.log' -delete`
690
+ let start = 0;
691
+ while (start < expr.length && FIND_GLOBAL_OPT_RE.test(expr[start])) {
692
+ start += expr[start] === "-D" ? 2 : 1;
693
+ }
694
+ const rest = expr.slice(start);
695
+ const end = rest.findIndex((t) => FIND_EXPR_START_RE.test(t));
696
+ return end < 0 ? rest : rest.slice(0, end);
697
+ }
698
+ }
699
+
700
+ const rmIdx = tokens.findIndex((t) => RM_CMD_RE.test(t));
701
+ if (rmIdx >= 0) {
702
+ const args = tokens.slice(rmIdx + 1);
703
+ const shortFlags = args.filter((t) => /^-[^-]/.test(t)).join("");
704
+ const longFlags = args.filter((t) => /^--/.test(t));
705
+ const recursive = /r/i.test(shortFlags) || longFlags.some((f) => /^--recursive$/i.test(f));
706
+ const force = /f/.test(shortFlags) || longFlags.some((f) => /^--force$/i.test(f));
707
+ if (recursive && force) return args.filter((t) => !t.startsWith("-"));
708
+ }
709
+
710
+ return null;
711
+ }
712
+
713
+ /** Split a command into the segments the shell would run as separate commands. */
714
+ function shellSegments(cmd: string): string[] {
715
+ return cmd.split(/&&|\|\||[|;\n]/).map((s) => s.trim()).filter((s) => s !== "");
716
+ }
717
+
718
+ /**
719
+ * Check whether all recursive-delete targets in a command are under an allowlisted path.
592
720
  * Splits on shell operators first so that `/tmp` appearing in an unrelated
593
721
  * sub-command (e.g. `echo /tmp && rm -rf /`) does not trigger a false allow.
594
722
  * Uses path-boundary comparison so `/tmp` does not cover `/tmp2`.
595
723
  * Non-recursive rm segments (no -r/-R flag) are skipped — they pose no catastrophic risk.
596
724
  * Quoted paths with spaces are handled via a segment-level regex fallback.
725
+ * Home-relative targets and allowPaths entries are expanded, so `~/scratch` is
726
+ * covered by an allowPaths entry of either `~/scratch` or the absolute home path.
597
727
  */
598
- function rmTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
728
+ function deletionTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
599
729
  if (allowPaths.length === 0) return false;
600
- const segments = cmd
601
- .split(/&&|\|\||[|;\n]/)
602
- .map((s) => s.trim())
603
- .filter((s) => /\brm\b/.test(s));
604
- if (segments.length === 0) return false;
605
- for (const seg of segments) {
606
- const tokens = parseArgvTokens(seg);
607
- const rmIdx = tokens.findIndex((t) => t === "rm");
608
- if (rmIdx < 0) continue;
609
- // Only validate recursive rm segments — non-recursive rm has no catastrophic-deletion risk
610
- const flagTokens = tokens.slice(rmIdx + 1).filter((t) => /^-[^-]/.test(t));
611
- const longFlagsInSeg = tokens.slice(rmIdx + 1).filter((t) => /^--/.test(t));
612
- if (!/r/i.test(flagTokens.join("")) && !longFlagsInSeg.some(f => /^--recursive$/i.test(f))) continue;
613
- const pathArgs = tokens.slice(rmIdx + 1).filter((t) => !t.startsWith("-"));
614
- for (const target of pathArgs) {
615
- const normalized = target.replace(/\/\*$/, "").replace(/\/+$/, "") || "/";
616
- const covered = allowPaths.some((p) => {
617
- const np = p.replace(/\/+$/, "") || "/";
618
- return normalized === np || normalized.startsWith(np + "/");
619
- });
730
+ const normalizedAllowPaths = allowPaths.map((p) => stripTrailingGlob(expandHomePrefix(p)) || "/");
731
+ let sawRecursiveDelete = false;
732
+ for (const seg of shellSegments(cmd)) {
733
+ const targets = recursiveDeletionTargets(seg);
734
+ if (targets === null) continue;
735
+ sawRecursiveDelete = true;
736
+ for (const target of targets) {
737
+ const normalized = stripTrailingGlob(expandHomePrefix(target)) || "/";
738
+ const covered = normalizedAllowPaths.some((np) => normalized === np || normalized.startsWith(np + "/"));
620
739
  if (!covered) {
621
740
  // Fallback: check the raw segment for quoted paths that contain spaces
622
741
  // (parseArgvTokens splits on whitespace, so "/tmp/my dir" becomes two tokens)
@@ -628,40 +747,23 @@ function rmTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
628
747
  }
629
748
  }
630
749
  }
631
- return true;
750
+ return sawRecursiveDelete;
632
751
  }
633
752
 
634
753
  function blockRmRf(ctx: PolicyContext): PolicyResult {
635
754
  if (ctx.toolName !== "Bash") return allow();
636
755
  const cmd = getCommand(ctx);
637
- const hasDestructivePath = parseArgvTokens(cmd).some((token) => {
638
- const normalized = token.replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
639
- return normalized === "/" || normalized === "~" || /^\/[A-Za-z_][\w.-]*$/.test(normalized);
640
- });
641
756
 
642
- // Combined flags in one token: rm -rf /, rm -fr /
643
- if (hasDestructivePath && (
644
- /rm\s+-[^\s]*r[^\s]*f[^\s]*/.test(cmd) ||
645
- /rm\s+-[^\s]*f[^\s]*r[^\s]*/.test(cmd)
646
- )) {
757
+ const hasCatastrophicTarget = shellSegments(cmd).some((seg) => {
758
+ const targets = recursiveDeletionTargets(seg);
759
+ return targets !== null && targets.some(isCatastrophicTarget);
760
+ });
761
+ if (hasCatastrophicTarget) {
647
762
  const allowPaths = ((ctx.params?.allowPaths ?? []) as string[]);
648
- if (rmTargetIsAllowed(cmd, allowPaths)) return allow();
763
+ if (deletionTargetIsAllowed(cmd, allowPaths)) return allow();
649
764
  return deny("Catastrophic deletion blocked");
650
765
  }
651
766
 
652
- // Separated flags: rm -r -f /, rm -f -r /, rm -r -v -f /, etc.
653
- if (hasDestructivePath && /\brm\b/.test(cmd)) {
654
- const tokens = parseArgvTokens(cmd);
655
- const shortFlags = tokens.filter((t) => /^-[^-]/.test(t)).join("");
656
- const longFlags = tokens.filter((t) => /^--/.test(t));
657
- const hasRecursive = /r/i.test(shortFlags) || longFlags.some(f => /^--recursive$/i.test(f));
658
- const hasForce = /f/.test(shortFlags) || longFlags.some(f => /^--force$/i.test(f));
659
- if (hasRecursive && hasForce) {
660
- const allowPaths = ((ctx.params?.allowPaths ?? []) as string[]);
661
- if (rmTargetIsAllowed(cmd, allowPaths)) return allow();
662
- return deny("Catastrophic deletion blocked");
663
- }
664
- }
665
767
  // PowerShell: Remove-Item -Recurse -Force on root/drive
666
768
  if (/Remove-Item\s+.*-Recurse.*-Force.*(?:[A-Z]:\\(?:\s|$)|\\\*)/i.test(cmd)) {
667
769
  return deny("Catastrophic deletion blocked");
@@ -675,13 +777,29 @@ function blockRmRf(ctx: PolicyContext): PolicyResult {
675
777
 
676
778
  function blockForcePush(ctx: PolicyContext): PolicyResult {
677
779
  if (ctx.toolName !== "Bash") return allow();
678
- const args = extractGitPushArgs(getCommand(ctx));
679
- if (args.some((a) => FORCE_PUSH_RE.test(a))) {
680
- return deny("Force-pushing is blocked");
780
+ for (const segment of extractGitPushArgs(getCommand(ctx))) {
781
+ let sawEndOfOptions = false;
782
+ for (const token of segment.split(/\s+/)) {
783
+ if (token === "--") {
784
+ sawEndOfOptions = true;
785
+ continue;
786
+ }
787
+ if (sawEndOfOptions) continue;
788
+ if (isForcePushFlag(token)) {
789
+ return deny("Force-pushing is blocked");
790
+ }
791
+ }
681
792
  }
682
793
  return allow();
683
794
  }
684
795
 
796
+ function isForcePushFlag(token: string): boolean {
797
+ if (token === "--force") return true;
798
+ if (SAFE_FORCE_PREFIXES.some((prefix) => token.startsWith(prefix))) return false;
799
+ if (token.startsWith("--force")) return true;
800
+ return SHORT_FLAG_BUNDLE_RE.test(token);
801
+ }
802
+
685
803
  function blockSecretsWrite(ctx: PolicyContext): PolicyResult {
686
804
  if (ctx.toolName !== "Write") return allow();
687
805
  const filePath = getFilePath(ctx);
@@ -12,19 +12,29 @@
12
12
  * whatever is already enabled, so unticking removes). Reuses the tested
13
13
  * install/uninstall manager and the existing searchable policy picker.
14
14
  */
15
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
17
  import { resolve, sep } from "node:path";
18
18
 
19
- import { selectOne, multiSelect, intro, outro, summarize, type TTYIn, type TTYOut } from "./tui";
19
+ import {
20
+ selectOne,
21
+ multiSelect,
22
+ intro,
23
+ outro,
24
+ summarize,
25
+ type MultiChoice,
26
+ type TTYIn,
27
+ type TTYOut,
28
+ } from "./tui";
20
29
  import {
21
30
  detectInstalledClis,
22
31
  getIntegration,
23
32
  } from "./integrations";
24
33
  import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types";
25
34
  import { installHooks } from "./manager";
26
- import { getConfigPathForScope } from "./hooks-config";
35
+ import { getConfigPathForScope, readHooksConfig } from "./hooks-config";
27
36
  import { POLICY_PRESETS, resolvePreset, resolveEverything } from "./policy-presets";
37
+ import { discoverPolicyFiles, findSkippedPolicyFiles } from "./custom-hooks-loader";
28
38
  import { trackHookEvent } from "./hook-telemetry";
29
39
  import { getInstanceId } from "../../lib/telemetry-id";
30
40
 
@@ -75,7 +85,13 @@ export function buildScopeChoices(cwd: string) {
75
85
  ];
76
86
  }
77
87
 
78
- export function buildAgentChoices(scope: HookScope, cwd: string) {
88
+ /** The CLIs that can actually be configured at `scope`. Hermes and OpenClaw
89
+ * are gateways with no project-level config, so they are user-scope only. */
90
+ export function clisSupportingScope(scope: HookScope): IntegrationType[] {
91
+ return INTEGRATION_TYPES.filter((id) => getIntegration(id).scopes.includes(scope));
92
+ }
93
+
94
+ export function buildAgentChoices(scope: HookScope, cwd: string): MultiChoice<IntegrationType>[] {
79
95
  const detected = new Set(detectInstalledClis());
80
96
  // Detected first, then the rest as "install ahead of time".
81
97
  const ordered = [
@@ -85,6 +101,25 @@ export function buildAgentChoices(scope: HookScope, cwd: string) {
85
101
  return ordered.map((id) => {
86
102
  const integration = getIntegration(id);
87
103
  const isDetected = detected.has(id);
104
+
105
+ // Not every CLI can be configured at every scope — Hermes and OpenClaw
106
+ // have no project config at all. Offering them anyway meant picking "Just
107
+ // this project" and applying died with `Scope "project" is not supported
108
+ // by Hermes`, after the user had answered every question. Show them as
109
+ // locked and unchecked with the reason, so the constraint is visible
110
+ // instead of being discovered as a crash.
111
+ const supported = integration.scopes.includes(scope);
112
+ if (!supported) {
113
+ return {
114
+ label: integration.displayName,
115
+ value: id,
116
+ checked: false,
117
+ locked: true,
118
+ section: "Global only · not configurable per-project",
119
+ hint: `supports ${integration.scopes.join(", ")} scope — rerun with "Everywhere I code"`,
120
+ };
121
+ }
122
+
88
123
  let installedHere = false;
89
124
  try {
90
125
  installedHere = integration.hooksInstalledInSettings(scope, cwd);
@@ -103,18 +138,62 @@ export function buildAgentChoices(scope: HookScope, cwd: string) {
103
138
 
104
139
  const EVERYTHING = "__everything__";
105
140
  const ALL_CLIS = "__all_clis__";
141
+ /** Sentinel for the locked "Custom" row — informational, never resolves to
142
+ * builtin policy names (custom policies load by convention, not by config). */
143
+ const CUSTOM = "__custom__";
106
144
 
107
145
  /** The themed preset bundles for the wizard's multi-select, plus an "Everything"
108
146
  * option that enables the full builtin policy set. */
109
- export function buildPresetChoices() {
110
- const choices: Array<{ label: string; value: string; hint: string }> = POLICY_PRESETS.map(
111
- (p) => ({ label: p.label, value: p.id, hint: p.description }),
112
- );
147
+ export function buildPresetChoices(cwd: string = process.cwd(), enabled = true) {
148
+ const choices: MultiChoice<string>[] = POLICY_PRESETS.map((p) => ({
149
+ label: p.label,
150
+ value: p.id,
151
+ hint: p.description,
152
+ }));
113
153
  choices.push({
114
154
  label: "Everything",
115
155
  value: EVERYTHING,
116
156
  hint: `all ${resolveEverything().length} policies`,
117
157
  });
158
+
159
+ // The Custom row is ALWAYS present, because it is the only place the feature
160
+ // is discoverable: a user who has never written a policy cannot learn the
161
+ // capability exists, and one who wrote a badly-named file cannot learn why
162
+ // nothing happened.
163
+ //
164
+ // When there are loadable files it is a REAL checkbox — unticking writes
165
+ // `customPoliciesEnabled: false`, which switches convention discovery off
166
+ // without renaming or deleting anything. With nothing to toggle (no files,
167
+ // or only skipped ones) it falls back to a locked status row.
168
+ const custom = describeCustomPolicies(cwd);
169
+ const skipped = custom.warnings.length;
170
+ const plural = (n: number) => `${n} file${n === 1 ? "" : "s"}`;
171
+ const skippedNote = skipped > 0 ? ` · ${skipped} skipped, see next screen` : "";
172
+
173
+ if (custom.fileCount > 0) {
174
+ choices.push({
175
+ label: "Custom",
176
+ value: CUSTOM,
177
+ checked: enabled,
178
+ // Deliberately NOT summaryExclude'd: this one is a real choice, and the
179
+ // step summary is the only place the user sees what they picked. Hiding
180
+ // it meant unticking Custom and every bundle showed "none", giving no
181
+ // way to tell the toggle had registered.
182
+ hint: `${plural(custom.fileCount)} in ${custom.scopes.join(" + ")}${skippedNote}`,
183
+ });
184
+ } else {
185
+ choices.push({
186
+ label: "Custom",
187
+ value: CUSTOM,
188
+ locked: true,
189
+ checked: false,
190
+ summaryExclude: true,
191
+ hint:
192
+ skipped > 0
193
+ ? `${plural(skipped)} found but NOT loaded — see next screen`
194
+ : "none yet · drop *-policies.mjs in .failproofai/policies/",
195
+ });
196
+ }
118
197
  return choices;
119
198
  }
120
199
 
@@ -124,14 +203,100 @@ export function buildPresetChoices() {
124
203
  * enables the full policy set and wins over any presets.
125
204
  */
126
205
  export function resolvePresetSelection(values: string[]): string[] {
127
- if (values.includes(EVERYTHING)) return resolveEverything();
128
- return [...new Set(values.flatMap((id) => resolvePreset(id)))];
206
+ // The Custom row is informational — custom policies are discovered from disk
207
+ // by the loader, never named in the enabled-policies config — so it must not
208
+ // reach resolvePreset(), which only knows builtin bundle ids.
209
+ const selected = values.filter((v) => v !== CUSTOM);
210
+ if (selected.includes(EVERYTHING)) return resolveEverything();
211
+ return [...new Set(selected.flatMap((id) => resolvePreset(id)))];
212
+ }
213
+
214
+ const DIM_NOTE = "(auto-loaded)";
215
+
216
+ /**
217
+ * Persist the Custom checkbox into the scope's config, after installHooks has
218
+ * written it (installHooks copies the previous config forward, so writing
219
+ * first would be overwritten).
220
+ *
221
+ * Writes the key only to turn discovery OFF, and removes it when turning back
222
+ * on, so the common case leaves no `customPoliciesEnabled: true` noise in the
223
+ * file and "absent means enabled" stays the single default. `undefined` means
224
+ * there was nothing to toggle — leave whatever is there alone.
225
+ */
226
+ export function setCustomPoliciesEnabled(
227
+ scope: HookScope,
228
+ cwd: string,
229
+ enabled: boolean | undefined,
230
+ ): void {
231
+ if (enabled === undefined) return;
232
+ const path = getConfigPathForScope(scope, cwd);
233
+ let config: Record<string, unknown> = {};
234
+ try {
235
+ if (existsSync(path)) config = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
236
+ } catch {
237
+ return; // a malformed config is the install path's problem, not ours
238
+ }
239
+ if (enabled) delete config.customPoliciesEnabled;
240
+ else config.customPoliciesEnabled = false;
241
+ try {
242
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
243
+ } catch {
244
+ /* best-effort: never fail a completed setup over this flag */
245
+ }
129
246
  }
130
247
 
131
- export function reviewLines(
132
- state: { scope: HookScope; clis: IntegrationType[]; policies: string[]; cwd: string },
133
- ): string[] {
134
- const { scope, clis, policies, cwd } = state;
248
+ /**
249
+ * Summarise the custom policy files on disk, for the review screen.
250
+ *
251
+ * Only lists files it deliberately does NOT load them. Loading executes
252
+ * user code, which is fine on the hook path but wrong in an interactive
253
+ * wizard the user hasn't confirmed yet.
254
+ *
255
+ * `warnings` covers the silent-skip trap: a file in the right directory whose
256
+ * name doesn't end in `policies.{js,mjs,ts}` is ignored entirely, with nothing
257
+ * on screen to say so. Surfacing it here is the difference between "my rule
258
+ * isn't working and I don't know why" and a one-line rename.
259
+ */
260
+ export function describeCustomPolicies(cwd: string): {
261
+ active: string[];
262
+ warnings: string[];
263
+ fileCount: number;
264
+ scopes: string[];
265
+ } {
266
+ const active: string[] = [];
267
+ const warnings: string[] = [];
268
+ const scopes: string[] = [];
269
+ let fileCount = 0;
270
+ const dirs: Array<{ dir: string; label: string }> = [
271
+ { dir: resolve(cwd, ".failproofai", "policies"), label: "project" },
272
+ { dir: resolve(homedir(), ".failproofai", "policies"), label: "global" },
273
+ ];
274
+ for (const { dir, label } of dirs) {
275
+ const found = discoverPolicyFiles(dir);
276
+ if (found.length > 0) {
277
+ active.push(`${found.length} file${found.length === 1 ? "" : "s"} (${label})`);
278
+ scopes.push(label);
279
+ fileCount += found.length;
280
+ }
281
+ for (const name of findSkippedPolicyFiles(dir)) {
282
+ warnings.push(
283
+ `! ${homeify(resolve(dir, name))} is NOT loaded — rename to ` +
284
+ `${name.replace(/\.(js|mjs|ts)$/, "-policies.$1")}`,
285
+ );
286
+ }
287
+ }
288
+ return { active, warnings, fileCount, scopes };
289
+ }
290
+
291
+ export function reviewLines(state: {
292
+ scope: HookScope;
293
+ clis: IntegrationType[];
294
+ policies: string[];
295
+ cwd: string;
296
+ /** The Custom checkbox. `undefined` = nothing to toggle, leave as-is. */
297
+ customEnabled?: boolean;
298
+ }): string[] {
299
+ const { scope, clis, policies, cwd, customEnabled } = state;
135
300
  const where =
136
301
  scope === "project" ? `This project (${homeify(cwd)})` : "Everywhere (global)";
137
302
  const lines: string[] = [];
@@ -139,6 +304,20 @@ export function reviewLines(
139
304
  lines.push(` Where : ${where}`);
140
305
  lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
141
306
  lines.push(` Policies : ${policies.length} enabled`);
307
+
308
+ // Reflect the Custom decision, not just what is on disk. Reporting
309
+ // "1 file (project) (auto-loaded)" after the user had just unticked the row
310
+ // stated the opposite of what was about to happen.
311
+ const custom = describeCustomPolicies(cwd);
312
+ if (custom.active.length > 0) {
313
+ lines.push(
314
+ ` Custom : ${custom.active.join(" · ")} ${
315
+ customEnabled === false ? "— DISABLED, will not load" : DIM_NOTE
316
+ }`,
317
+ );
318
+ }
319
+ for (const warning of custom.warnings) lines.push(` ${warning}`);
320
+
142
321
  lines.push("");
143
322
  lines.push(" This will update:");
144
323
  for (const cli of clis) {
@@ -279,7 +458,12 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
279
458
  {
280
459
  label: "Everything available",
281
460
  value: ALL_CLIS,
282
- hint: `protect all ${INTEGRATION_TYPES.length} supported CLIs`,
461
+ // Counts only what this scope can actually take — expanding to all 12
462
+ // under project scope is what crashed the apply on Hermes.
463
+ hint: `protect all ${clisSupportingScope(scope).length} CLIs configurable here`,
464
+ // A selector, not an assistant. Counting it gave "13 assistants" for
465
+ // the 12 supported CLIs, and listed "Everything available" among them.
466
+ summaryExclude: true,
283
467
  },
284
468
  ...buildAgentChoices(scope, cwd),
285
469
  ],
@@ -290,15 +474,28 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
290
474
  stdout,
291
475
  });
292
476
  if (clisSel === null) return cancel();
293
- const clis: IntegrationType[] = clisSel.includes(ALL_CLIS)
294
- ? [...INTEGRATION_TYPES]
295
- : (clisSel.filter((v) => v !== ALL_CLIS) as IntegrationType[]);
477
+ // Filter to what this scope supports in BOTH branches: "Everything
478
+ // available" must not expand to CLIs that cannot take this scope, and a
479
+ // locked row can't be ticked but belt-and-braces keeps the invariant local
480
+ // to the one place `clis` is built.
481
+ const supported = new Set(clisSupportingScope(scope));
482
+ const clis: IntegrationType[] = (
483
+ clisSel.includes(ALL_CLIS)
484
+ ? [...INTEGRATION_TYPES]
485
+ : (clisSel.filter((v) => v !== ALL_CLIS) as IntegrationType[])
486
+ ).filter((id) => supported.has(id));
296
487
 
297
488
  // 3 — Which policies? Multi-select of themed presets — additive, so the
298
489
  // enabled set is the union of every ticked bundle.
490
+ // Seed the Custom checkbox from whatever the config already says, so the
491
+ // wizard shows the current state rather than resetting it every run.
492
+ const customEnabledBefore = readHooksConfig().customPoliciesEnabled !== false;
493
+ const presetChoices = buildPresetChoices(cwd, customEnabledBefore);
494
+ const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
495
+
299
496
  const presets = await multiSelect<string>({
300
497
  message: "What should we guard against?",
301
- choices: buildPresetChoices(),
498
+ choices: presetChoices,
302
499
  minSelected: 1,
303
500
  summaryNoun: "bundles",
304
501
  hint: "space toggles · combine presets · ↵ confirm",
@@ -307,11 +504,14 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
307
504
  });
308
505
  if (presets === null) return cancel();
309
506
  const policies = resolvePresetSelection(presets);
507
+ // Only meaningful when there are files to switch off; with none, the row is
508
+ // locked-unchecked and must not write a disabling flag.
509
+ const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined;
310
510
 
311
511
  // 4 — Review & apply
312
512
  const decision = await selectOne<"apply" | "cancel">({
313
513
  message: "Ready to apply?",
314
- body: reviewLines({ scope, clis, policies, cwd }),
514
+ body: reviewLines({ scope, clis, policies, cwd, customEnabled }),
315
515
  choices: [
316
516
  { label: "Yes, apply now", value: "apply", hint: "write the config" },
317
517
  { label: "Cancel", value: "cancel", hint: "quit, no changes" },
@@ -345,12 +545,28 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
345
545
  clis,
346
546
  { replace: true, quiet: true },
347
547
  );
548
+ setCustomPoliciesEnabled(scope, cwd, customEnabled);
348
549
  await applied;
349
550
  // Only now — a completed apply — is the launcher considered "seen", so the
350
551
  // first-run bare invocation stops redirecting here and opens the dashboard.
351
552
  markLauncherSeen();
352
553
 
353
- const agentNames = clis.map((c) => getIntegration(c).displayName).join(", ");
354
- outro(`Setup complete ${policies.length} policies guarding ${agentNames}.`, { ok: true }, stdout);
554
+ // Keep this inside a standard 80-column terminal. `writeLines` truncates with
555
+ // a hard cut and no ellipsis, so an over-long line doesn't just lose its tail
556
+ // — it reads as broken output. Naming all ten CLIs took it to 182 characters;
557
+ // the count alone carries the same information, and the user picked them two
558
+ // screens ago.
559
+ const customNote =
560
+ customEnabled === true
561
+ ? " + your custom policies"
562
+ : customEnabled === false
563
+ ? " · custom policies DISABLED"
564
+ : "";
565
+ const assistants = `${clis.length} assistant${clis.length === 1 ? "" : "s"}`;
566
+ outro(
567
+ `Setup complete — ${policies.length} policies${customNote} · ${assistants}`,
568
+ { ok: true },
569
+ stdout,
570
+ );
355
571
  return { applied: true, scope, clis, policies };
356
572
  }