failproofai 1.0.7 → 1.0.8

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 (268) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  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/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +4 -4
  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 +6 -6
  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/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js +4 -4
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/run/route.js +8 -7
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  33. package/.next/standalone/.next/server/app/audit/page.js +9 -7
  34. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/index.html +1 -1
  37. package/.next/standalone/.next/server/app/index.rsc +15 -15
  38. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  39. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  40. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  41. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  42. package/.next/standalone/.next/server/app/page.js +6 -6
  43. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  46. package/.next/standalone/.next/server/app/policies/page.js +11 -10
  47. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page.js +8 -7
  51. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -6
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page.js +7 -6
  60. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +52 -8
  63. package/.next/standalone/.next/server/app/settings/page.js +12 -7
  64. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/chunks/{[externals]__1msfs-h._.js → [externals]__19_pzeq._.js} +1 -1
  67. package/.next/standalone/.next/server/chunks/{[externals]__1_bftcl._.js → [externals]__20kzpkf._.js} +1 -1
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +22 -0
  70. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +21 -21
  71. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +16 -16
  74. package/.next/standalone/.next/server/chunks/{_1c3k-8x._.js → _1q5i8mb._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/lib_16xa545._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +5 -0
  78. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +4 -2
  80. package/.next/standalone/.next/server/chunks/ssr/[externals]__0ohnuzs._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01bmjsj._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +4 -0
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__02npjtd._.js → [root-of-the-server]__056wjo4._.js} +3 -3
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__059yza8._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06pflha._.js +3 -0
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0eip4_k._.js +22 -0
  87. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0p-5p8u._.js → [root-of-the-server]__0n0xg95._.js} +3 -3
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qcb0mg._.js +3 -0
  89. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__013jr2b._.js → [root-of-the-server]__0rwtwpm._.js} +3 -3
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0yrsbd_._.js +3 -0
  91. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0da85px._.js → [root-of-the-server]__11mayhe._.js} +3 -3
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12e7nhs._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13d-wb6._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13t1zkw._.js +3 -0
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +4 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19evfi8._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1dinjii._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +4 -0
  99. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0cxe_2_._.js → [root-of-the-server]__1mf3zp6._.js} +3 -3
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ftmoxc._.js → [root-of-the-server]__1pprgri._.js} +3 -3
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1p2otjt._.js → [root-of-the-server]__1q4p5b8._.js} +3 -3
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qiz0e4._.js +3 -0
  103. package/.next/standalone/.next/server/chunks/ssr/{_166t73i._.js → _0-vcssj._.js} +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +3 -0
  105. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/ssr/_0uyu3jf._.js +3 -0
  108. package/.next/standalone/.next/server/chunks/ssr/_13kfn90._.js +23 -0
  109. package/.next/standalone/.next/server/chunks/ssr/{_1es2j7i._.js → _1gb0ifp._.js} +5 -5
  110. package/.next/standalone/.next/server/chunks/ssr/{_1_qswah._.js → _1q46vxx._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +13 -13
  113. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +3 -0
  114. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  117. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +3 -0
  118. package/.next/standalone/.next/server/chunks/ssr/{node_modules_next_dist_0drixxt._.js → node_modules_next_dist_0w6mzq5._.js} +4 -4
  119. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_18_d8l1._.js +151 -0
  120. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0-q0umm._.js +3 -0
  121. package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9d._.js +12 -0
  122. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0g194sy._.js +3 -0
  123. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +4 -2
  124. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1kx9e0d._.js +3 -0
  125. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +5 -0
  126. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  127. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  128. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +1 -1
  129. package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +3 -0
  130. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  131. package/.next/standalone/.next/server/pages/404.html +1 -1
  132. package/.next/standalone/.next/server/pages/500.html +1 -1
  133. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  134. package/.next/standalone/.next/server/server-reference-manifest.json +67 -23
  135. package/.next/standalone/.next/static/chunks/06dnzbolj00mc.js +6 -0
  136. package/.next/standalone/.next/static/chunks/0bhidk90e-07f.css +2 -0
  137. package/.next/standalone/.next/static/chunks/{2mdh397ghgnvv.js → 0m-9d6yn9hx4j.js} +1 -1
  138. package/.next/standalone/.next/static/chunks/{0__8a7m868fvf.js → 0wbjy0zo-m7is.js} +1 -1
  139. package/.next/standalone/.next/static/chunks/16f3fa-lx38hk.js +1 -0
  140. package/.next/standalone/.next/static/chunks/{0o6qlkgubtoex.js → 21uv-uusw329x.js} +1 -1
  141. package/.next/standalone/.next/static/chunks/2g2tki08kdhie.js +1 -0
  142. package/.next/standalone/.next/static/chunks/2qdpj67x6ifk_.js +69 -0
  143. package/.next/standalone/.next/static/chunks/2qv4hshejedtx.css +1 -0
  144. package/.next/standalone/.next/static/chunks/3-nbtkhg9y-1j.js +1 -0
  145. package/.next/standalone/.next/static/chunks/355km0ihuqo1p.js +1 -0
  146. package/.next/standalone/.next/static/chunks/{13i7-9is-vhys.js → 3c3qbmosdjl6w.js} +1 -1
  147. package/.next/standalone/PROBE-FOLLOWUP.md +186 -0
  148. package/.next/standalone/app/actions/get-jev-config.ts +604 -0
  149. package/.next/standalone/app/actions/pack-actions.ts +12 -0
  150. package/.next/standalone/app/actions/update-jev-config.ts +569 -0
  151. package/.next/standalone/app/components/jev-notices.tsx +96 -0
  152. package/.next/standalone/app/policies/hooks-client.tsx +14 -3
  153. package/.next/standalone/app/settings/jev-panel.tsx +630 -0
  154. package/.next/standalone/app/settings/page.tsx +20 -1
  155. package/.next/standalone/app/settings/settings-client.tsx +27 -1
  156. package/.next/standalone/app/settings/settings.css +79 -0
  157. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +22 -4
  158. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +14 -1
  159. package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +17 -3
  160. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +3 -0
  161. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +22 -0
  162. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +169 -0
  163. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +1 -1
  164. package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +65 -0
  165. package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +61 -0
  166. package/.next/standalone/package.json +10 -10
  167. package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
  168. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  169. package/.next/standalone/sdk/typescript/CHANGELOG.md +18 -1
  170. package/.next/standalone/sdk/typescript/integration/fixtures/nextjs/package-lock.json +0 -274
  171. package/.next/standalone/sdk/typescript/scripts/release.mjs +30 -0
  172. package/.next/standalone/server.js +1 -1
  173. package/README.md +3 -2
  174. package/bin/failproofai.mjs +148 -4
  175. package/dist/cli.mjs +18599 -9478
  176. package/dist/index.js +19 -1
  177. package/dist/worker.mjs +10500 -3773
  178. package/package.json +10 -10
  179. package/pi-extension/index.ts +11 -0
  180. package/scripts/build-policy-pack.mjs +53 -2
  181. package/src/audit/features.ts +3 -2
  182. package/src/hooks/builtin-policies.ts +195 -8
  183. package/src/hooks/cloud-connection.ts +190 -1
  184. package/src/hooks/cloud-enrollment-cli.ts +57 -8
  185. package/src/hooks/cloud-introspect.ts +6 -0
  186. package/src/hooks/cloud-managed-policies.ts +22 -0
  187. package/src/hooks/configure-wizard.ts +26 -6
  188. package/src/hooks/custom-hooks-loader.ts +98 -10
  189. package/src/hooks/custom-hooks-registry.ts +45 -1
  190. package/src/hooks/effective-reviewers.ts +243 -0
  191. package/src/hooks/first-run-gate.ts +5 -0
  192. package/src/hooks/flush-cli.ts +35 -8
  193. package/src/hooks/fp-config.ts +266 -1
  194. package/src/hooks/fp-home.ts +23 -0
  195. package/src/hooks/fp-reset.ts +22 -4
  196. package/src/hooks/handler.ts +303 -7
  197. package/src/hooks/hook-activity-store.ts +118 -6
  198. package/src/hooks/hook-telemetry.ts +41 -0
  199. package/src/hooks/jev-activity.ts +385 -0
  200. package/src/hooks/jev-cli.ts +2172 -0
  201. package/src/hooks/jev-cloud-connection.ts +392 -0
  202. package/src/hooks/loader-utils.ts +6 -0
  203. package/src/hooks/manager.ts +45 -7
  204. package/src/hooks/pack-cli.ts +568 -37
  205. package/src/hooks/pack-failclosed.ts +3 -0
  206. package/src/hooks/pack-manifest.ts +489 -7
  207. package/src/hooks/pack-store.ts +194 -11
  208. package/src/hooks/policy-authority.ts +386 -0
  209. package/src/hooks/policy-catalog.ts +206 -0
  210. package/src/hooks/policy-evaluator.ts +963 -796
  211. package/src/hooks/policy-registry.ts +27 -1
  212. package/src/hooks/policy-reviewability.ts +258 -0
  213. package/src/hooks/policy-types.ts +128 -0
  214. package/src/hooks/semantic/combine.ts +581 -0
  215. package/src/hooks/semantic/compile.ts +176 -0
  216. package/src/hooks/semantic/decide.ts +513 -0
  217. package/src/hooks/semantic/envelope.ts +1255 -0
  218. package/src/hooks/semantic/evaluator.ts +556 -0
  219. package/src/hooks/semantic/facts.ts +387 -0
  220. package/src/hooks/semantic/intent.ts +1190 -0
  221. package/src/hooks/semantic/jev-client.ts +1155 -0
  222. package/src/hooks/semantic/jev-config.ts +1142 -0
  223. package/src/hooks/semantic/jev-review.ts +389 -0
  224. package/src/hooks/semantic/jev-stats.ts +289 -0
  225. package/src/hooks/semantic/jev-throttle.ts +427 -0
  226. package/src/hooks/semantic/pack-policies.ts +294 -0
  227. package/src/hooks/semantic/policies.ts +597 -0
  228. package/src/hooks/semantic/precondition-names.ts +60 -0
  229. package/src/hooks/semantic/preconditions.ts +58 -0
  230. package/src/hooks/semantic/redact.ts +2937 -0
  231. package/src/hooks/semantic/session-root.ts +116 -0
  232. package/src/hooks/semantic/types.ts +162 -0
  233. package/src/hooks/semver-precedence.ts +128 -0
  234. package/src/hooks/tui.ts +4 -0
  235. package/src/hooks/types.ts +1 -1
  236. package/src/hooks/worker-server.ts +121 -27
  237. package/src/index.ts +6 -0
  238. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cuho4x._.js +0 -3
  239. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1_r2rbg._.js +0 -24
  240. package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +0 -3
  241. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-_ki57._.js +0 -4
  242. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +0 -4
  243. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bd3mje._.js +0 -3
  244. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cg-bgc._.js +0 -5
  245. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cpu_mj._.js +0 -3
  246. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u3w0ll._.js +0 -22
  247. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17d_ffl._.js +0 -3
  248. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19d9tgz._.js +0 -5
  249. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +0 -3
  250. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +0 -3
  251. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1phc187._.js +0 -3
  252. package/.next/standalone/.next/server/chunks/ssr/_06imw3p._.js +0 -5
  253. package/.next/standalone/.next/server/chunks/ssr/_0h_douw._.js +0 -3
  254. package/.next/standalone/.next/server/chunks/ssr/_1-i_gzc._.js +0 -23
  255. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +0 -3
  256. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +0 -3
  257. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +0 -3
  258. package/.next/standalone/.next/static/chunks/094xgi4owxaqf.js +0 -1
  259. package/.next/standalone/.next/static/chunks/1rz20_pz828f3.js +0 -6
  260. package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +0 -1
  261. package/.next/standalone/.next/static/chunks/2klitrtzpaoe0.js +0 -1
  262. package/.next/standalone/.next/static/chunks/2rshywgeqsyzk.css +0 -2
  263. package/.next/standalone/.next/static/chunks/3pzx4chkhko9k.js +0 -1
  264. package/.next/standalone/.next/static/chunks/3rh5o7e16irrm.js +0 -69
  265. package/.next/standalone/.next/static/chunks/43ufqrz8qo3h-.js +0 -1
  266. /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → 5yKM8NoTOpUI0MoQl8eo5}/_buildManifest.js +0 -0
  267. /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → 5yKM8NoTOpUI0MoQl8eo5}/_clientMiddlewareManifest.js +0 -0
  268. /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → 5yKM8NoTOpUI0MoQl8eo5}/_ssgManifest.js +0 -0
@@ -3,7 +3,7 @@
3
3
  * Returns exit code, stdout, and stderr for the hook handler.
4
4
  */
5
5
  import type { HookEventType, SessionMetadata } from "./types";
6
- import type { PolicyContext, HooksConfig } from "./policy-types";
6
+ import type { PolicyContext, HooksConfig, RegisteredPolicy } from "./policy-types";
7
7
  // Read from the layer that owns it rather than restated here — restating a
8
8
  // pack id is exactly how `failproofai/builtins` and `failproofai/core` both
9
9
  // outlived their rename. This is on the hook path, so the cost was measured
@@ -15,6 +15,22 @@ import { CORE_SOURCE } from "./pack-store";
15
15
  import { packPolicyParamKey, parsePackPolicyName } from "./pack-param-key";
16
16
  export { packPolicyParamKey } from "./pack-param-key";
17
17
  import { DEFAULT_POLICY_NAMESPACE, getPoliciesForEvent } from "./policy-registry";
18
+ // Type-only apart from the two pure functions: the semantic evaluator itself is
19
+ // loaded by handler.ts, lazily, and only when a Jev config exists.
20
+ import {
21
+ combineTwoTier,
22
+ regexOnly,
23
+ type FinalVerdict,
24
+ type JevActivityFields,
25
+ type JevReview,
26
+ type RegexVerdict,
27
+ type ShadowVerdict,
28
+ type TwoTierReview,
29
+ } from "./semantic/combine";
30
+ // The code, not a copy of the string: the log line below turns on it, and a
31
+ // rename should break the build here rather than quietly print "unavailable"
32
+ // for a call Jev actually answered.
33
+ import { JEV_REASON_REQUEST_CUT } from "./jev-activity";
18
34
  import { hookLogInfo, hookLogWarn } from "./hook-logger";
19
35
  import { trackHookEvent } from "./hook-telemetry";
20
36
  import { getInstanceId } from "../../lib/telemetry-id";
@@ -35,6 +51,20 @@ export interface EvaluationResult {
35
51
  policyNames?: string[];
36
52
  reason: string | null;
37
53
  decision: "allow" | "deny" | "instruct";
54
+ /**
55
+ * Present only when the two-tier (Jev) path ran — never on the regex-only
56
+ * path, so an unconfigured machine's results carry no extra key.
57
+ */
58
+ twoTier?: {
59
+ /** The hook-activity fields to persist for this call. */
60
+ activity: JevActivityFields;
61
+ /** True when Jev's own verdict is what `policyName` names. */
62
+ decidedByJev: boolean;
63
+ /** The pack a deciding Jev check came from; only with `decidedByJev`. */
64
+ jevOrigin?: { packId: string; packVersion?: string };
65
+ /** Shadow mode: Jev's own deny/instruct, recorded as a "would have" (see `ShadowVerdict`). */
66
+ shadowVerdict?: ShadowVerdict;
67
+ };
38
68
  }
39
69
 
40
70
 
@@ -125,22 +155,122 @@ function getConfigParamsFor(
125
155
  return config.policyParams[canonicalName.slice(defaultPrefix.length)];
126
156
  }
127
157
 
158
+ const HARD: { authority: "hard"; reviewedBy: string[] } = { authority: "hard", reviewedBy: [] };
159
+
160
+ /**
161
+ * Evaluate every policy registered for this event and turn the outcome into
162
+ * the calling CLI's response.
163
+ *
164
+ * Three stages, so the two engines can meet in the middle:
165
+ *
166
+ * 1. **collect** runs the regex policies in priority order and records each
167
+ * verdict. A HARD deny short-circuits exactly as the evaluator always has.
168
+ * Without `twoTier` every deny is hard, so this is the old loop.
169
+ * 2. **combine** decides. Without `twoTier` that is `regexOnly` — first deny,
170
+ * else every instruct, else every note — which is the old evaluator's
171
+ * answer. With it, `combineTwoTier` applies the §4 rules against Jev's
172
+ * review (see `semantic/combine.ts`).
173
+ * 3. **format** turns the verdict into the per-CLI response shape. It is the
174
+ * single source of truth for those shapes and does not know which engine
175
+ * decided.
176
+ *
177
+ * `twoTier` is passed by the handler only when a Jev config (BYOK) exists and
178
+ * the event is a gate; the Jev request is already in flight by then.
179
+ */
128
180
  export async function evaluatePolicies(
129
181
  eventType: HookEventType,
130
182
  payload: Record<string, unknown>,
131
183
  session?: SessionMetadata,
132
184
  config?: HooksConfig,
185
+ twoTier?: TwoTierReview,
133
186
  ): Promise<EvaluationResult> {
134
187
  const toolName = payload.tool_name as string | undefined;
135
- const toolInput = payload.tool_input as Record<string, unknown> | undefined;
136
188
 
137
189
  const policies = getPoliciesForEvent(eventType, toolName);
138
190
 
139
191
  hookLogInfo(`evaluating ${policies.length} policies for ${eventType}`);
140
192
 
141
- if (policies.length === 0) {
142
- return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
193
+ if (!twoTier) {
194
+ if (policies.length === 0) {
195
+ return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
196
+ }
197
+ const { verdicts } = await collectVerdicts(eventType, payload, session, config, policies);
198
+ return formatVerdict(eventType, session, toolName, regexOnly(verdicts));
199
+ }
200
+
201
+ let collected: Awaited<ReturnType<typeof collectVerdicts>>;
202
+ try {
203
+ collected = await collectVerdicts(eventType, payload, session, config, policies, twoTier);
204
+ } catch (err) {
205
+ twoTier.abort();
206
+ throw err;
207
+ }
208
+ let review: JevReview;
209
+ if (collected.hardDenied) {
210
+ // Final whatever Jev says, so stop paying for the answer.
211
+ twoTier.abort();
212
+ review = { kind: "not-consulted" };
213
+ } else {
214
+ // Nothing from here on reads the policy registry (the verdicts, their
215
+ // authority and every policy name are already captured), so the warm
216
+ // worker may start the next queued hook while this one waits on Jev.
217
+ twoTier.releaseRegistry?.();
218
+ review = await twoTier.review;
143
219
  }
220
+ const combined = combineTwoTier(collected.verdicts, review, twoTier.mode);
221
+ if (combined.cleared.length > 0) {
222
+ hookLogInfo(`jev ${twoTier.mode === "shadow" ? "would clear" : "cleared"}: ${combined.cleared.join(", ")}`);
223
+ }
224
+ if (combined.activity.evaluator === "jev-fallback") {
225
+ // Info, not warn: during an outage this is every gated call, and warn-level
226
+ // lines reach the hook's stderr — which is the deny text itself on some
227
+ // CLIs. The activity row (`evaluator: "jev-fallback"` + reason) and
228
+ // `failproofai jev status` are where a fallback is made visible.
229
+ //
230
+ // `request-cut` is the one reason here that is not an outage: Jev answered,
231
+ // its answer still counted toward the most-severe rule, it withdrew its
232
+ // clears, and it refused to let a call nobody could read in full come out
233
+ // as an allow (see `semantic/combine.ts`). Saying "unavailable" for it
234
+ // would send someone looking for a provider problem that is not there.
235
+ //
236
+ // `truncated` — the name a build before the rename wrote for that same
237
+ // reason — had a branch of its own here, and it was unreachable: this line
238
+ // only ever describes a row THIS process just produced, and `combine.ts`
239
+ // writes `JEV_REASON_REQUEST_CUT`. The readers that do meet old rows keep
240
+ // the old name (`ANSWERED_BUT_NOT_APPLIED_REASONS` and the code list in
241
+ // `jev-activity.ts`); a log line about a live call does not need it.
242
+ hookLogInfo(
243
+ combined.activity.jevFallbackReason === JEV_REASON_REQUEST_CUT
244
+ ? "jev could not be shown the whole call; it cleared nothing, and the call was not allowed on a partial read"
245
+ : `jev unavailable (${combined.activity.jevFallbackReason}); the regex result decided this call`,
246
+ );
247
+ }
248
+ return {
249
+ ...formatVerdict(eventType, session, toolName, combined.final, combined.decidedByJev),
250
+ twoTier: {
251
+ activity: combined.activity,
252
+ decidedByJev: combined.decidedByJev,
253
+ ...(combined.decidedByJev && review.kind === "answered" && review.origin ? { jevOrigin: review.origin } : {}),
254
+ ...(combined.shadowVerdict ? { shadowVerdict: combined.shadowVerdict } : {}),
255
+ },
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Run the policies in order and record each verdict. Stops at the first HARD
261
+ * deny; a reviewable deny (two-tier only) is recorded and evaluation goes on,
262
+ * because Jev may yet clear it and a later hard deny must still be found.
263
+ */
264
+ async function collectVerdicts(
265
+ eventType: HookEventType,
266
+ payload: Record<string, unknown>,
267
+ session: SessionMetadata | undefined,
268
+ config: HooksConfig | undefined,
269
+ policies: RegisteredPolicy[],
270
+ twoTier?: TwoTierReview,
271
+ ): Promise<{ verdicts: RegexVerdict[]; hardDenied: boolean }> {
272
+ const toolName = payload.tool_name as string | undefined;
273
+ const toolInput = payload.tool_input as Record<string, unknown> | undefined;
144
274
 
145
275
  const baseCtx: PolicyContext = {
146
276
  eventType,
@@ -151,11 +281,7 @@ export async function evaluatePolicies(
151
281
  cli: session?.cli,
152
282
  };
153
283
 
154
- // Track all instruct results (accumulated, does not short-circuit)
155
- const instructEntries: Array<{ policyName: string; reason: string }> = [];
156
-
157
- // Track informational messages from allow decisions (with policy attribution)
158
- const allowEntries: Array<{ policyName: string; reason: string }> = [];
284
+ const verdicts: RegexVerdict[] = [];
159
285
 
160
286
  for (const policy of policies) {
161
287
  // Inject params: merge policyParams[policy.name] over schema defaults.
@@ -216,513 +342,539 @@ export async function evaluatePolicies(
216
342
  getConfigParamsFor(config, policy.name)?.hint,
217
343
  );
218
344
  hookLogInfo(`deny by "${policy.name}": ${reason}`);
345
+ const { authority, reviewedBy } = twoTier ? twoTier.authorityOf(policy) : HARD;
346
+ verdicts.push({ policyName: policy.name, decision: "deny", reason, authority, reviewedBy });
347
+ if (authority === "hard") return { verdicts, hardDenied: true };
348
+ continue;
349
+ }
219
350
 
220
- // Pick a noun for the deny message that fits the event type. Tool events
221
- // get the tool name; non-tool events (UserPromptSubmit, SessionStart,
222
- // SessionEnd, Stop, …) use an event-appropriate label so we don't emit
223
- // the misleading "Blocked unknown tool by failproofai because: ...".
224
- let displayTool: string;
225
- if (ctx.toolName) {
226
- displayTool = ctx.toolName;
227
- } else if (eventType === "UserPromptSubmit") {
228
- displayTool = "prompt";
229
- } else if (eventType === "SessionStart") {
230
- displayTool = "session start";
231
- } else if (eventType === "SessionEnd") {
232
- displayTool = "session end";
233
- } else if (eventType === "Stop") {
234
- displayTool = "stop";
235
- } else {
236
- displayTool = "operation";
237
- }
238
- const blockedMessage = `Blocked ${displayTool} by failproofai because: ${reason}, as per the policy configured by the user`;
351
+ // Accumulate all instruct results (does not short-circuit — later policies can still deny)
352
+ if (result.decision === "instruct") {
353
+ const reason = appendHint(
354
+ result.reason ?? `Instruction from policy: ${policy.name}`,
355
+ getConfigParamsFor(config, policy.name)?.hint,
356
+ );
357
+ const { authority, reviewedBy } = twoTier ? twoTier.authorityOf(policy) : HARD;
358
+ verdicts.push({ policyName: policy.name, decision: "instruct", reason, authority, reviewedBy });
359
+ hookLogInfo(`instruct by "${policy.name}": ${reason}`);
360
+ }
239
361
 
240
- // Cursor's hook protocol expects a flat `{permission, user_message,
241
- // agent_message}` shape for any blocking decision, regardless of which
242
- // event triggered it. Branch ahead of the per-event handlers below so
243
- // PreToolUse / PostToolUse / PermissionRequest all flow through the
244
- // Cursor-shaped response.
245
- // Ref: https://cursor.com/docs/hooks (Stdout Response Format).
246
- if (session?.cli === "cursor") {
247
- // Cursor's `stop` / `subagentStop` hooks ignore `{permission: "deny"}`
248
- // — that shape is only honored on tool events. The only force-retry
249
- // channel for Stop/SubagentStop is `{followup_message}` on stdout
250
- // (exit 0); Cursor auto-submits the text as the next user message
251
- // (capped at `loop_limit`, default 5). Mirrors the Copilot Stop branch.
252
- // Without this branch, the 5 `require-*-before-stop` builtins were
253
- // observation-only on Cursor — the deny was logged but the agent
254
- // stopped cleanly. Ref: https://cursor.com/docs/hooks
255
- if (eventType === "Stop" || eventType === "SubagentStop") {
256
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
257
- return {
258
- exitCode: 0,
259
- stdout: JSON.stringify({ followup_message: reasonText }),
260
- stderr: "",
261
- policyName: policy.name,
262
- reason,
263
- decision: "deny",
264
- };
265
- }
266
- // `beforeSubmitPrompt` does NOT read `permission`. Its only block key
267
- // is `continue: false` (+ an optional `user_message` shown to the
268
- // user); an object carrying unknown keys validates and is dropped, so
269
- // the flat deny below was inert and every prompt went through. Verified
270
- // against cursor-agent 2026.07.16-899851b, 1931.index.js char 887883.
271
- if (eventType === "UserPromptSubmit") {
272
- return {
273
- exitCode: 0,
274
- stdout: JSON.stringify({ continue: false, user_message: blockedMessage }),
275
- stderr: "",
276
- policyName: policy.name,
277
- reason,
278
- decision: "deny",
279
- };
280
- }
281
- const response = {
282
- permission: "deny",
283
- user_message: blockedMessage,
284
- agent_message: blockedMessage,
285
- };
286
- return {
287
- exitCode: 0,
288
- stdout: JSON.stringify(response),
289
- stderr: "",
290
- policyName: policy.name,
291
- reason,
292
- decision: "deny",
293
- };
294
- }
362
+ // Accumulate informational messages from allow decisions
363
+ if (result.decision === "allow" && result.reason) {
364
+ verdicts.push({ policyName: policy.name, decision: "allow", reason: result.reason, ...HARD });
365
+ }
366
+ }
295
367
 
296
- // Pi's shim parses a flat `{permission, reason}` JSON shape from stdout
297
- // and translates `permission === "deny"` into a `{block: true, reason}`
298
- // return value from its `pi.on("tool_call", ...)` handler. Pi has no
299
- // event-specific decision wrappers, so all events flow through the
300
- // same flat shape — except Stop, where we emit the MANDATORY ACTION
301
- // wording so the shim can re-inject it as a system-prompt suffix on
302
- // the next before_agent_start (Pi cannot veto agent_end directly).
303
- // Mirrors the Cursor/Copilot/OpenCode Stop branches above.
304
- if (session?.cli === "pi") {
305
- if (eventType === "Stop") {
306
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
307
- return {
308
- exitCode: 0,
309
- stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
310
- stderr: "",
311
- policyName: policy.name,
312
- reason,
313
- decision: "deny",
314
- };
315
- }
316
- const response = {
317
- permission: "deny",
318
- reason: blockedMessage,
319
- };
320
- return {
321
- exitCode: 0,
322
- stdout: JSON.stringify(response),
323
- stderr: "",
324
- policyName: policy.name,
325
- reason,
326
- decision: "deny",
327
- };
328
- }
368
+ return { verdicts, hardDenied: false };
369
+ }
329
370
 
330
- // Hermes: the block contract is `{"decision":"block","reason"}` on stdout;
331
- // Hermes IGNORES exit codes, so the JSON is the only channel. A block on
332
- // PreToolUse stops the tool before it runs, regardless of the originating
333
- // platform (slack/telegram/cli/cron) or subagent.
334
- //
335
- // Only `pre_tool_call` and `pre_verify` are gated in upstream's
336
- // `_parse_response` (agent/shell_hooks.py:567-621); the shape is still
337
- // emitted for the other installed events, where it is read by nothing.
338
- // We do not install `pre_verify` (see the note in types.ts), so Hermes
339
- // has no canonical Stop event and no Stop branch is needed here.
340
- if (session?.cli === "hermes") {
341
- return {
342
- exitCode: 0,
343
- stdout: JSON.stringify({ decision: "block", reason: blockedMessage }),
344
- stderr: "",
345
- policyName: policy.name,
346
- reason,
347
- decision: "deny",
348
- };
349
- }
371
+ /** A verdict → the calling CLI's response. Which engine decided only changes whom a deny credits. */
372
+ function formatVerdict(
373
+ eventType: HookEventType,
374
+ session: SessionMetadata | undefined,
375
+ toolName: string | undefined,
376
+ final: FinalVerdict,
377
+ byJev = false,
378
+ ): EvaluationResult {
379
+ if (final.decision === "deny") {
380
+ const [decider] = final.entries;
381
+ return formatDeny(eventType, session, toolName, decider.policyName, decider.reason, byJev);
382
+ }
383
+ if (final.decision === "instruct") return formatInstruct(eventType, session, final.entries);
384
+ if (final.entries.length > 0) return formatAllow(eventType, session, final.entries);
385
+ return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
386
+ }
350
387
 
351
- // OpenClaw: the shipped openclaw-plugin parses a flat {permission, reason}
352
- // verdict and maps it per plugin-hook — before_tool_call → {block:true,
353
- // blockReason}; before_agent_run → {outcome:"block", reason};
354
- // before_agent_finalize (Stop) → {action:"revise", reason}. For Stop we
355
- // emit the MANDATORY ACTION wording so the revise loop carries the
356
- // directive. Observation hooks (after_tool_call / session_* /
357
- // subagent_ended / before_compaction) ignore stdout, so the flat deny is
358
- // logged but cannot veto — a documented limitation. Mirrors the Pi branch.
359
- if (session?.cli === "openclaw") {
360
- if (eventType === "Stop") {
361
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
362
- return {
363
- exitCode: 0,
364
- stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
365
- stderr: "",
366
- policyName: policy.name,
367
- reason,
368
- decision: "deny",
369
- };
370
- }
371
- return {
372
- exitCode: 0,
373
- stdout: JSON.stringify({ permission: "deny", reason: blockedMessage }),
374
- stderr: "",
375
- policyName: policy.name,
376
- reason,
377
- decision: "deny",
378
- };
379
- }
388
+ function formatDeny(
389
+ eventType: HookEventType,
390
+ session: SessionMetadata | undefined,
391
+ toolName: string | undefined,
392
+ policyName: string,
393
+ reason: string,
394
+ byJev = false,
395
+ ): EvaluationResult {
396
+ // Pick a noun for the deny message that fits the event type. Tool events
397
+ // get the tool name; non-tool events (UserPromptSubmit, SessionStart,
398
+ // SessionEnd, Stop, …) use an event-appropriate label so we don't emit
399
+ // the misleading "Blocked unknown tool by failproofai because: ...".
400
+ let displayTool: string;
401
+ if (toolName) {
402
+ displayTool = toolName;
403
+ } else if (eventType === "UserPromptSubmit") {
404
+ displayTool = "prompt";
405
+ } else if (eventType === "SessionStart") {
406
+ displayTool = "session start";
407
+ } else if (eventType === "SessionEnd") {
408
+ displayTool = "session end";
409
+ } else if (eventType === "Stop") {
410
+ displayTool = "stop";
411
+ } else {
412
+ displayTool = "operation";
413
+ }
414
+ // Jev's reason is its own sentences, ending in "."; no user configured the check.
415
+ const blockedMessage = byJev
416
+ ? `Blocked ${displayTool} by failproofai because: ${reason.replace(/[.\s]+$/, "")}, as flagged by Jev semantic review`
417
+ : `Blocked ${displayTool} by failproofai because: ${reason}, as per the policy configured by the user`;
380
418
 
381
- // OpenCode: `session.idle` is a notification-only bus event — by the
382
- // time the plugin handler fires, OpenCode has already gone idle and
383
- // throwing from the handler does not force-retry. The only working
384
- // channel is the shim's `client.session.prompt(...)` SDK call, which
385
- // submits a new user message that re-triggers the agent loop. The
386
- // shim already routes `hookSpecificOutput.additionalContext` through
387
- // that path (see buildOpenCodePluginShim's applyDecision), so we emit
388
- // the deny reason as additionalContext instead of exit-2. Mirrors the
389
- // Cursor `followup_message` and Copilot `{decision:"block"}` Stop
390
- // branches. SubagentStop is widened in for forward
391
- // compat — OpenCode doesn't yet expose subagent boundaries to plugins.
392
- if (session?.cli === "opencode") {
393
- if (eventType === "Stop" || eventType === "SubagentStop") {
394
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
395
- return {
396
- exitCode: 0,
397
- stdout: JSON.stringify({ hookSpecificOutput: { additionalContext: reasonText } }),
398
- stderr: "",
399
- policyName: policy.name,
400
- reason,
401
- decision: "deny",
402
- };
403
- }
404
- // Non-Stop opencode events keep the generic Claude shape — the
405
- // shim's applyDecision already handles permissionDecision: "deny"
406
- // for tool events.
407
- }
419
+ // Cursor's hook protocol expects a flat `{permission, user_message,
420
+ // agent_message}` shape for any blocking decision, regardless of which
421
+ // event triggered it. Branch ahead of the per-event handlers below so
422
+ // PreToolUse / PostToolUse / PermissionRequest all flow through the
423
+ // Cursor-shaped response.
424
+ // Ref: https://cursor.com/docs/hooks (Stdout Response Format).
425
+ if (session?.cli === "cursor") {
426
+ // Cursor's `stop` / `subagentStop` hooks ignore `{permission: "deny"}`
427
+ // — that shape is only honored on tool events. The only force-retry
428
+ // channel for Stop/SubagentStop is `{followup_message}` on stdout
429
+ // (exit 0); Cursor auto-submits the text as the next user message
430
+ // (capped at `loop_limit`, default 5). Mirrors the Copilot Stop branch.
431
+ // Without this branch, the 5 `require-*-before-stop` builtins were
432
+ // observation-only on Cursor — the deny was logged but the agent
433
+ // stopped cleanly. Ref: https://cursor.com/docs/hooks
434
+ if (eventType === "Stop" || eventType === "SubagentStop") {
435
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policyName}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
436
+ return {
437
+ exitCode: 0,
438
+ stdout: JSON.stringify({ followup_message: reasonText }),
439
+ stderr: "",
440
+ policyName: policyName,
441
+ reason,
442
+ decision: "deny",
443
+ };
444
+ }
445
+ // `beforeSubmitPrompt` does NOT read `permission`. Its only block key
446
+ // is `continue: false` (+ an optional `user_message` shown to the
447
+ // user); an object carrying unknown keys validates and is dropped, so
448
+ // the flat deny below was inert and every prompt went through. Verified
449
+ // against cursor-agent 2026.07.16-899851b, 1931.index.js char 887883.
450
+ if (eventType === "UserPromptSubmit") {
451
+ return {
452
+ exitCode: 0,
453
+ stdout: JSON.stringify({ continue: false, user_message: blockedMessage }),
454
+ stderr: "",
455
+ policyName: policyName,
456
+ reason,
457
+ decision: "deny",
458
+ };
459
+ }
460
+ const response = {
461
+ permission: "deny",
462
+ user_message: blockedMessage,
463
+ agent_message: blockedMessage,
464
+ };
465
+ return {
466
+ exitCode: 0,
467
+ stdout: JSON.stringify(response),
468
+ stderr: "",
469
+ policyName: policyName,
470
+ reason,
471
+ decision: "deny",
472
+ };
473
+ }
408
474
 
409
- // Factory droid: droid drives tool blocking off EXIT CODE 2 (it ignores a
410
- // JSON `{decision:…}` on tool events — verified live against droid
411
- // v0.171.0: `Hook returned exit code 2, throwing ToolExecutionControlError`).
412
- // The one exception is `Stop`, where droid does NOT honor exit-2
413
- // force-retry; there it reads `{decision:"block", reason}` on stdout at
414
- // exit 0 ("if decision is block, Droid does not stop"). So: Stop → JSON
415
- // block; every other event (PreToolUse / PostToolUse / UserPromptSubmit /
416
- // SubagentStop / …) → exit 2 + the blocked message on stderr.
417
- if (session?.cli === "factory") {
418
- if (eventType === "Stop") {
419
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
420
- return {
421
- exitCode: 0,
422
- stdout: JSON.stringify({ decision: "block", reason: reasonText }),
423
- stderr: "",
424
- policyName: policy.name,
425
- reason,
426
- decision: "deny",
427
- };
428
- }
429
- return {
430
- exitCode: 2,
431
- stdout: "",
432
- stderr: blockedMessage + "\n",
433
- policyName: policy.name,
434
- reason,
435
- decision: "deny",
436
- };
437
- }
475
+ // Pi's shim parses a flat `{permission, reason}` JSON shape from stdout
476
+ // and translates `permission === "deny"` into a `{block: true, reason}`
477
+ // return value from its `pi.on("tool_call", ...)` handler. Pi has no
478
+ // event-specific decision wrappers, so all events flow through the
479
+ // same flat shape — except Stop, where we emit the MANDATORY ACTION
480
+ // wording so the shim can re-inject it as a system-prompt suffix on
481
+ // the next before_agent_start (Pi cannot veto agent_end directly).
482
+ // Mirrors the Cursor/Copilot/OpenCode Stop branches above.
483
+ if (session?.cli === "pi") {
484
+ if (eventType === "Stop") {
485
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policyName}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
486
+ return {
487
+ exitCode: 0,
488
+ stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
489
+ stderr: "",
490
+ policyName: policyName,
491
+ reason,
492
+ decision: "deny",
493
+ };
494
+ }
495
+ const response = {
496
+ permission: "deny",
497
+ reason: blockedMessage,
498
+ };
499
+ return {
500
+ exitCode: 0,
501
+ stdout: JSON.stringify(response),
502
+ stderr: "",
503
+ policyName: policyName,
504
+ reason,
505
+ decision: "deny",
506
+ };
507
+ }
438
508
 
439
- // Devin CLI: a pure Claude-clone that honors `{decision:"block", reason}`
440
- // on stdout at exit 0 for EVERY event (verified live against devin
441
- // v3000.1.27 — the block overrode `--permission-mode dangerous`). On Stop
442
- // the reason carries the MANDATORY-ACTION force-retry wording; on other
443
- // events it's the plain blocked message. One branch covers all events.
444
- if (session?.cli === "devin") {
445
- const reasonText =
446
- eventType === "Stop"
447
- ? `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`
448
- : blockedMessage;
449
- return {
450
- exitCode: 0,
451
- stdout: JSON.stringify({ decision: "block", reason: reasonText }),
452
- stderr: "",
453
- policyName: policy.name,
454
- reason,
455
- decision: "deny",
456
- };
457
- }
509
+ // Hermes: the block contract is `{"decision":"block","reason"}` on stdout;
510
+ // Hermes IGNORES exit codes, so the JSON is the only channel. A block on
511
+ // PreToolUse stops the tool before it runs, regardless of the originating
512
+ // platform (slack/telegram/cli/cron) or subagent.
513
+ //
514
+ // Only `pre_tool_call` and `pre_verify` are gated in upstream's
515
+ // `_parse_response` (agent/shell_hooks.py:567-621); the shape is still
516
+ // emitted for the other installed events, where it is read by nothing.
517
+ // We do not install `pre_verify` (see the note in types.ts), so Hermes
518
+ // has no canonical Stop event and no Stop branch is needed here.
519
+ if (session?.cli === "hermes") {
520
+ return {
521
+ exitCode: 0,
522
+ stdout: JSON.stringify({ decision: "block", reason: blockedMessage }),
523
+ stderr: "",
524
+ policyName: policyName,
525
+ reason,
526
+ decision: "deny",
527
+ };
528
+ }
458
529
 
459
- // Antigravity CLI: its OWN response shapes (NOT Claude's) — verified live
460
- // against agy v1.1.2. Tool/prompt events honor `{decision:"deny", reason}`
461
- // on stdout at exit 0 (hard block). The Stop event has no exit-2 retry;
462
- // instead `{decision:"continue", reason}` re-enters the loop and injects
463
- // the reason as a system message — that is how the 5 require-*-before-stop
464
- // builtins enforce on Antigravity.
465
- if (session?.cli === "antigravity") {
466
- if (eventType === "Stop") {
467
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
468
- return {
469
- exitCode: 0,
470
- stdout: JSON.stringify({ decision: "continue", reason: reasonText }),
471
- stderr: "",
472
- policyName: policy.name,
473
- reason,
474
- decision: "deny",
475
- };
476
- }
477
- return {
478
- exitCode: 0,
479
- stdout: JSON.stringify({ decision: "deny", reason: blockedMessage }),
480
- stderr: "",
481
- policyName: policy.name,
482
- reason,
483
- decision: "deny",
484
- };
485
- }
530
+ // OpenClaw: the shipped openclaw-plugin parses a flat {permission, reason}
531
+ // verdict and maps it per plugin-hook — before_tool_call → {block:true,
532
+ // blockReason}; before_agent_run → {outcome:"block", reason};
533
+ // before_agent_finalize (Stop) → {action:"revise", reason}. For Stop we
534
+ // emit the MANDATORY ACTION wording so the revise loop carries the
535
+ // directive. Observation hooks (after_tool_call / session_* /
536
+ // subagent_ended / before_compaction) ignore stdout, so the flat deny is
537
+ // logged but cannot veto — a documented limitation. Mirrors the Pi branch.
538
+ if (session?.cli === "openclaw") {
539
+ if (eventType === "Stop") {
540
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policyName}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
541
+ return {
542
+ exitCode: 0,
543
+ stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
544
+ stderr: "",
545
+ policyName: policyName,
546
+ reason,
547
+ decision: "deny",
548
+ };
549
+ }
550
+ return {
551
+ exitCode: 0,
552
+ stdout: JSON.stringify({ permission: "deny", reason: blockedMessage }),
553
+ stderr: "",
554
+ policyName: policyName,
555
+ reason,
556
+ decision: "deny",
557
+ };
558
+ }
486
559
 
487
- // Goose: the deny contract is `{"decision":"block","reason"}` on stdout at
488
- // exit 0, honored on PreToolUse ONLY (shipped goose ≥ v1.37.0, PR
489
- // block/goose#9304; exit 2 also blocks but JSON carries the reason
490
- // cleanly). Goose has NO Stop event (the 5 require-*-before-stop builtins
491
- // never fire for it — see CLAUDE.md) and does NOT honor deny on
492
- // UserPromptSubmit/PostToolUse (observation) — a block emitted on those
493
- // events is ignored (fail-open), a documented limitation. PreToolUse fires
494
- // for the shell tool AND inside delegated subagents, so this one branch
495
- // covers the entire enforceable surface. Mirrors the Hermes branch (no
496
- // turn-end event to special-case). Verified live against goose v1.43.0.
497
- if (session?.cli === "goose") {
498
- return {
499
- exitCode: 0,
500
- stdout: JSON.stringify({ decision: "block", reason: blockedMessage }),
501
- stderr: "",
502
- policyName: policy.name,
503
- reason,
504
- decision: "deny",
505
- };
506
- }
560
+ // OpenCode: `session.idle` is a notification-only bus event — by the
561
+ // time the plugin handler fires, OpenCode has already gone idle and
562
+ // throwing from the handler does not force-retry. The only working
563
+ // channel is the shim's `client.session.prompt(...)` SDK call, which
564
+ // submits a new user message that re-triggers the agent loop. The
565
+ // shim already routes `hookSpecificOutput.additionalContext` through
566
+ // that path (see buildOpenCodePluginShim's applyDecision), so we emit
567
+ // the deny reason as additionalContext instead of exit-2. Mirrors the
568
+ // Cursor `followup_message` and Copilot `{decision:"block"}` Stop
569
+ // branches. SubagentStop is widened in for forward
570
+ // compat — OpenCode doesn't yet expose subagent boundaries to plugins.
571
+ if (session?.cli === "opencode") {
572
+ if (eventType === "Stop" || eventType === "SubagentStop") {
573
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policyName}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
574
+ return {
575
+ exitCode: 0,
576
+ stdout: JSON.stringify({ hookSpecificOutput: { additionalContext: reasonText } }),
577
+ stderr: "",
578
+ policyName: policyName,
579
+ reason,
580
+ decision: "deny",
581
+ };
582
+ }
583
+ // Non-Stop opencode events keep the generic Claude shape — the
584
+ // shim's applyDecision already handles permissionDecision: "deny"
585
+ // for tool events.
586
+ }
507
587
 
508
- if (eventType === "PreToolUse") {
509
- const response = {
510
- hookSpecificOutput: {
511
- hookEventName: eventType,
512
- permissionDecision: "deny",
513
- permissionDecisionReason: blockedMessage,
514
- },
515
- };
516
- return {
517
- exitCode: 0,
518
- stdout: JSON.stringify(response),
519
- stderr: "",
520
- policyName: policy.name,
521
- reason,
522
- decision: "deny",
523
- };
524
- }
588
+ // Factory droid: droid drives tool blocking off EXIT CODE 2 (it ignores a
589
+ // JSON `{decision:…}` on tool events — verified live against droid
590
+ // v0.171.0: `Hook returned exit code 2, throwing ToolExecutionControlError`).
591
+ // The one exception is `Stop`, where droid does NOT honor exit-2
592
+ // force-retry; there it reads `{decision:"block", reason}` on stdout at
593
+ // exit 0 ("if decision is block, Droid does not stop"). So: Stop → JSON
594
+ // block; every other event (PreToolUse / PostToolUse / UserPromptSubmit /
595
+ // SubagentStop / …) → exit 2 + the blocked message on stderr.
596
+ if (session?.cli === "factory") {
597
+ if (eventType === "Stop") {
598
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policyName}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
599
+ return {
600
+ exitCode: 0,
601
+ stdout: JSON.stringify({ decision: "block", reason: reasonText }),
602
+ stderr: "",
603
+ policyName: policyName,
604
+ reason,
605
+ decision: "deny",
606
+ };
607
+ }
608
+ return {
609
+ exitCode: 2,
610
+ stdout: "",
611
+ stderr: blockedMessage + "\n",
612
+ policyName: policyName,
613
+ reason,
614
+ decision: "deny",
615
+ };
616
+ }
525
617
 
526
- // Copilot reads two shapes we were not sending, so a deny on either of
527
- // these events was emitted, logged, counted as enforcement — and ignored.
528
- // Both verified against the shipped @github/copilot-linux-x64 bundle.
529
- // Note exit 2 is NEVER a deny channel on copilot for any event; it is
530
- // logged as `Hook command exited with code 2 (warning)`.
531
- if (session?.cli === "copilot") {
532
- // `userPromptSubmit` gates the turn, but only on {decision:"block"} at
533
- // exit 0 (consumer app.js@2823018). We were emitting exit 2 + stderr,
534
- // which copilot warns about and then submits the prompt anyway.
535
- if (eventType === "UserPromptSubmit") {
536
- return {
537
- exitCode: 0,
538
- stdout: JSON.stringify({ decision: "block", reason: blockedMessage }),
539
- stderr: "",
540
- policyName: policy.name,
541
- reason,
542
- decision: "deny",
543
- };
544
- }
545
- // Copilot consumes a FLAT {behavior, message} here (normalizer
546
- // CMn@179042 -> mapper h4t@2686538). The Codex-shaped nested
547
- // hookSpecificOutput.decision below normalizes to `{}` on copilot, so
548
- // the permission prompt proceeded as if no policy existed.
549
- if (eventType === "PermissionRequest") {
550
- return {
551
- exitCode: 0,
552
- stdout: JSON.stringify({ behavior: "deny", message: blockedMessage }),
553
- stderr: "",
554
- policyName: policy.name,
555
- reason,
556
- decision: "deny",
557
- };
558
- }
559
- }
618
+ // Devin CLI: a pure Claude-clone that honors `{decision:"block", reason}`
619
+ // on stdout at exit 0 for EVERY event (verified live against devin
620
+ // v3000.1.27 — the block overrode `--permission-mode dangerous`). On Stop
621
+ // the reason carries the MANDATORY-ACTION force-retry wording; on other
622
+ // events it's the plain blocked message. One branch covers all events.
623
+ if (session?.cli === "devin") {
624
+ const reasonText =
625
+ eventType === "Stop"
626
+ ? `MANDATORY ACTION REQUIRED from failproofai (policy: ${policyName}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`
627
+ : blockedMessage;
628
+ return {
629
+ exitCode: 0,
630
+ stdout: JSON.stringify({ decision: "block", reason: reasonText }),
631
+ stderr: "",
632
+ policyName: policyName,
633
+ reason,
634
+ decision: "deny",
635
+ };
636
+ }
560
637
 
561
- if (eventType === "PermissionRequest") {
562
- // Codex-only: hookSpecificOutput.decision.behavior = "allow" | "deny"
563
- // (per https://developers.openai.com/codex/hooks#permissionrequest).
564
- const response = {
565
- hookSpecificOutput: {
566
- hookEventName: eventType,
567
- decision: {
568
- behavior: "deny",
569
- message: `Blocked ${displayTool} by failproofai because: ${reason}, as per the policy configured by the user`,
570
- },
571
- },
572
- };
573
- return {
574
- exitCode: 0,
575
- stdout: JSON.stringify(response),
576
- stderr: "",
577
- policyName: policy.name,
578
- reason,
579
- decision: "deny",
580
- };
581
- }
638
+ // Antigravity CLI: its OWN response shapes (NOT Claude's) — verified live
639
+ // against agy v1.1.2. Tool/prompt events honor `{decision:"deny", reason}`
640
+ // on stdout at exit 0 (hard block). The Stop event has no exit-2 retry;
641
+ // instead `{decision:"continue", reason}` re-enters the loop and injects
642
+ // the reason as a system message — that is how the 5 require-*-before-stop
643
+ // builtins enforce on Antigravity.
644
+ if (session?.cli === "antigravity") {
645
+ if (eventType === "Stop") {
646
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policyName}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
647
+ return {
648
+ exitCode: 0,
649
+ stdout: JSON.stringify({ decision: "continue", reason: reasonText }),
650
+ stderr: "",
651
+ policyName: policyName,
652
+ reason,
653
+ decision: "deny",
654
+ };
655
+ }
656
+ return {
657
+ exitCode: 0,
658
+ stdout: JSON.stringify({ decision: "deny", reason: blockedMessage }),
659
+ stderr: "",
660
+ policyName: policyName,
661
+ reason,
662
+ decision: "deny",
663
+ };
664
+ }
582
665
 
583
- if (eventType === "PostToolUse") {
584
- // codex and copilot both gate PostToolUse on a TOP-LEVEL
585
- // {decision:"block", reason:<string>}. Neither reads the
586
- // hookSpecificOutput shape below, so every PostToolUse deny on them was
587
- // emitted, logged, counted as enforcement — and dropped on the floor.
588
- //
589
- // codex 0.147.0, live A/B probe (same prompt, same hook, only the shape
590
- // differs): with {decision:"block"} the run prints `hook: PostToolUse
591
- // Blocked`, routes the reason through codex_core::tools::router, and the
592
- // reason REPLACES the tool result — the model never sees the real
593
- // output. With hookSpecificOutput it prints `hook: PostToolUse
594
- // Completed` and the model reads the output verbatim. NOTE the codex
595
- // rows elsewhere cite output_parser.rs / hook_runtime.rs / tools
596
- // registry.rs line numbers that no longer exist in 0.147.0 (its hook
597
- // sources are hooks/src/{engine,events}/…); this row is re-grounded on
598
- // the live probe rather than those paths.
599
- //
600
- // copilot 1.0.78: BOTH postToolUse call sites in the shipped
601
- // @github/copilot-linux-x64 app.js gate on
602
- // vK = t => t?.decision === "block" && typeof t.reason === "string"
603
- // -> "Tool result blocked". vK fails closed on a missing or non-string
604
- // reason, so the reason must always be a non-empty string — do not
605
- // "simplify" this to {decision:"block"} alone.
606
- //
607
- // The tool has already RUN in both cases. This replaces the result the
608
- // model reads; it does not undo the side effect. That is the only
609
- // semantic available at PostToolUse, and it is precisely what an
610
- // output-scrubbing policy needs to keep a secret out of the context.
611
- if (session?.cli === "codex" || session?.cli === "copilot") {
612
- return {
613
- exitCode: 0,
614
- stdout: JSON.stringify({ decision: "block", reason: blockedMessage }),
615
- stderr: "",
616
- policyName: policy.name,
617
- reason,
618
- decision: "deny",
619
- };
620
- }
621
- const response = {
622
- hookSpecificOutput: {
623
- hookEventName: eventType,
624
- additionalContext: `Blocked ${displayTool} by failproofai because: ${reason}, as per the policy configured by the user`,
625
- },
626
- };
627
- return {
628
- exitCode: 0,
629
- stdout: JSON.stringify(response),
630
- stderr: "",
631
- policyName: policy.name,
632
- reason,
633
- decision: "deny",
634
- };
635
- }
666
+ // Goose: the deny contract is `{"decision":"block","reason"}` on stdout at
667
+ // exit 0, honored on PreToolUse ONLY (shipped goose ≥ v1.37.0, PR
668
+ // block/goose#9304; exit 2 also blocks but JSON carries the reason
669
+ // cleanly). Goose has NO Stop event (the 5 require-*-before-stop builtins
670
+ // never fire for it — see CLAUDE.md) and does NOT honor deny on
671
+ // UserPromptSubmit/PostToolUse (observation) — a block emitted on those
672
+ // events is ignored (fail-open), a documented limitation. PreToolUse fires
673
+ // for the shell tool AND inside delegated subagents, so this one branch
674
+ // covers the entire enforceable surface. Mirrors the Hermes branch (no
675
+ // turn-end event to special-case). Verified live against goose v1.43.0.
676
+ if (session?.cli === "goose") {
677
+ return {
678
+ exitCode: 0,
679
+ stdout: JSON.stringify({ decision: "block", reason: blockedMessage }),
680
+ stderr: "",
681
+ policyName: policyName,
682
+ reason,
683
+ decision: "deny",
684
+ };
685
+ }
636
686
 
637
- if (eventType === "Stop" || eventType === "SubagentStop") {
638
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
639
- // Copilot CLI: `agentStop` and `subagentStop` both honor
640
- // `{decision: "block", reason}` JSON on stdout — the reason becomes the
641
- // next-turn prompt and the agent (or subagent) retries. Exit-2 is logged
642
- // as `[WARNING] Hook warning: ...` (verified empirically against Copilot
643
- // CLI 1.0.41 events.jsonl) but does NOT trigger retry. We branch on both
644
- // event types so that custom policies matching SubagentStop also enforce
645
- // on Copilot subagent boundaries; the 5 builtin require-*-before-stop
646
- // policies still match Stop only by design — they are session-completion
647
- // gates (commit/push/PR/conflicts/CI), not subagent-return gates.
648
- // Ref: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-hooks-reference
649
- if (session?.cli === "copilot") {
650
- return {
651
- exitCode: 0,
652
- stdout: JSON.stringify({ decision: "block", reason: reasonText }),
653
- stderr: "",
654
- policyName: policy.name,
655
- reason,
656
- decision: "deny",
657
- };
658
- }
659
- return {
660
- exitCode: 2,
661
- stdout: "",
662
- stderr: reasonText,
663
- policyName: policy.name,
664
- reason,
665
- decision: "deny",
666
- };
667
- }
687
+ if (eventType === "PreToolUse") {
688
+ const response = {
689
+ hookSpecificOutput: {
690
+ hookEventName: eventType,
691
+ permissionDecision: "deny",
692
+ permissionDecisionReason: blockedMessage,
693
+ },
694
+ };
695
+ return {
696
+ exitCode: 0,
697
+ stdout: JSON.stringify(response),
698
+ stderr: "",
699
+ policyName: policyName,
700
+ reason,
701
+ decision: "deny",
702
+ };
703
+ }
668
704
 
669
- // Other event types (Cursor case already handled above): exit 2
705
+ // Copilot reads two shapes we were not sending, so a deny on either of
706
+ // these events was emitted, logged, counted as enforcement — and ignored.
707
+ // Both verified against the shipped @github/copilot-linux-x64 bundle.
708
+ // Note exit 2 is NEVER a deny channel on copilot for any event; it is
709
+ // logged as `Hook command exited with code 2 (warning)`.
710
+ if (session?.cli === "copilot") {
711
+ // `userPromptSubmit` gates the turn, but only on {decision:"block"} at
712
+ // exit 0 (consumer app.js@2823018). We were emitting exit 2 + stderr,
713
+ // which copilot warns about and then submits the prompt anyway.
714
+ if (eventType === "UserPromptSubmit") {
670
715
  return {
671
- exitCode: 2,
672
- stdout: "",
673
- stderr: reason,
674
- policyName: policy.name,
716
+ exitCode: 0,
717
+ stdout: JSON.stringify({ decision: "block", reason: blockedMessage }),
718
+ stderr: "",
719
+ policyName: policyName,
675
720
  reason,
676
721
  decision: "deny",
677
722
  };
678
723
  }
724
+ // Copilot consumes a FLAT {behavior, message} here (normalizer
725
+ // CMn@179042 -> mapper h4t@2686538). The Codex-shaped nested
726
+ // hookSpecificOutput.decision below normalizes to `{}` on copilot, so
727
+ // the permission prompt proceeded as if no policy existed.
728
+ if (eventType === "PermissionRequest") {
729
+ return {
730
+ exitCode: 0,
731
+ stdout: JSON.stringify({ behavior: "deny", message: blockedMessage }),
732
+ stderr: "",
733
+ policyName: policyName,
734
+ reason,
735
+ decision: "deny",
736
+ };
737
+ }
738
+ }
679
739
 
680
- // Accumulate all instruct results (does not short-circuit — later policies can still deny)
681
- if (result.decision === "instruct") {
682
- const reason = appendHint(
683
- result.reason ?? `Instruction from policy: ${policy.name}`,
684
- getConfigParamsFor(config, policy.name)?.hint,
685
- );
686
- instructEntries.push({ policyName: policy.name, reason });
687
- hookLogInfo(`instruct by "${policy.name}": ${reason}`);
740
+ if (eventType === "PermissionRequest") {
741
+ // Codex-only: hookSpecificOutput.decision.behavior = "allow" | "deny"
742
+ // (per https://developers.openai.com/codex/hooks#permissionrequest).
743
+ const response = {
744
+ hookSpecificOutput: {
745
+ hookEventName: eventType,
746
+ decision: {
747
+ behavior: "deny",
748
+ message: blockedMessage,
749
+ },
750
+ },
751
+ };
752
+ return {
753
+ exitCode: 0,
754
+ stdout: JSON.stringify(response),
755
+ stderr: "",
756
+ policyName: policyName,
757
+ reason,
758
+ decision: "deny",
759
+ };
760
+ }
761
+
762
+ if (eventType === "PostToolUse") {
763
+ // codex and copilot both gate PostToolUse on a TOP-LEVEL
764
+ // {decision:"block", reason:<string>}. Neither reads the
765
+ // hookSpecificOutput shape below, so every PostToolUse deny on them was
766
+ // emitted, logged, counted as enforcement — and dropped on the floor.
767
+ //
768
+ // codex 0.147.0, live A/B probe (same prompt, same hook, only the shape
769
+ // differs): with {decision:"block"} the run prints `hook: PostToolUse
770
+ // Blocked`, routes the reason through codex_core::tools::router, and the
771
+ // reason REPLACES the tool result — the model never sees the real
772
+ // output. With hookSpecificOutput it prints `hook: PostToolUse
773
+ // Completed` and the model reads the output verbatim. NOTE the codex
774
+ // rows elsewhere cite output_parser.rs / hook_runtime.rs / tools
775
+ // registry.rs line numbers that no longer exist in 0.147.0 (its hook
776
+ // sources are hooks/src/{engine,events}/…); this row is re-grounded on
777
+ // the live probe rather than those paths.
778
+ //
779
+ // copilot 1.0.78: BOTH postToolUse call sites in the shipped
780
+ // @github/copilot-linux-x64 app.js gate on
781
+ // vK = t => t?.decision === "block" && typeof t.reason === "string"
782
+ // -> "Tool result blocked". vK fails closed on a missing or non-string
783
+ // reason, so the reason must always be a non-empty string — do not
784
+ // "simplify" this to {decision:"block"} alone.
785
+ //
786
+ // The tool has already RUN in both cases. This replaces the result the
787
+ // model reads; it does not undo the side effect. That is the only
788
+ // semantic available at PostToolUse, and it is precisely what an
789
+ // output-scrubbing policy needs to keep a secret out of the context.
790
+ if (session?.cli === "codex" || session?.cli === "copilot") {
791
+ return {
792
+ exitCode: 0,
793
+ stdout: JSON.stringify({ decision: "block", reason: blockedMessage }),
794
+ stderr: "",
795
+ policyName: policyName,
796
+ reason,
797
+ decision: "deny",
798
+ };
688
799
  }
800
+ const response = {
801
+ hookSpecificOutput: {
802
+ hookEventName: eventType,
803
+ additionalContext: blockedMessage,
804
+ },
805
+ };
806
+ return {
807
+ exitCode: 0,
808
+ stdout: JSON.stringify(response),
809
+ stderr: "",
810
+ policyName: policyName,
811
+ reason,
812
+ decision: "deny",
813
+ };
814
+ }
689
815
 
690
- // Accumulate informational messages from allow decisions
691
- if (result.decision === "allow" && result.reason) {
692
- allowEntries.push({ policyName: policy.name, reason: result.reason });
816
+ if (eventType === "Stop" || eventType === "SubagentStop") {
817
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policyName}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
818
+ // Copilot CLI: `agentStop` and `subagentStop` both honor
819
+ // `{decision: "block", reason}` JSON on stdout — the reason becomes the
820
+ // next-turn prompt and the agent (or subagent) retries. Exit-2 is logged
821
+ // as `[WARNING] Hook warning: ...` (verified empirically against Copilot
822
+ // CLI 1.0.41 events.jsonl) but does NOT trigger retry. We branch on both
823
+ // event types so that custom policies matching SubagentStop also enforce
824
+ // on Copilot subagent boundaries; the 5 builtin require-*-before-stop
825
+ // policies still match Stop only by design — they are session-completion
826
+ // gates (commit/push/PR/conflicts/CI), not subagent-return gates.
827
+ // Ref: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-hooks-reference
828
+ if (session?.cli === "copilot") {
829
+ return {
830
+ exitCode: 0,
831
+ stdout: JSON.stringify({ decision: "block", reason: reasonText }),
832
+ stderr: "",
833
+ policyName: policyName,
834
+ reason,
835
+ decision: "deny",
836
+ };
693
837
  }
838
+ return {
839
+ exitCode: 2,
840
+ stdout: "",
841
+ stderr: reasonText,
842
+ policyName: policyName,
843
+ reason,
844
+ decision: "deny",
845
+ };
694
846
  }
695
847
 
696
- // No deny — check if we accumulated any instructs
697
- if (instructEntries.length > 0) {
698
- const combined = instructEntries.map((e) => e.reason).join("\n");
699
- const policyNames = instructEntries.map((e) => e.policyName);
848
+ // Other event types (Cursor case already handled above): exit 2
849
+ return {
850
+ exitCode: 2,
851
+ stdout: "",
852
+ stderr: reason,
853
+ policyName: policyName,
854
+ reason,
855
+ decision: "deny",
856
+ };
857
+ }
700
858
 
701
- // Cursor's hook protocol uses a flat `{permission, additional_context}`
702
- // shape for non-Stop and `{followup_message}` for Stop/SubagentStop.
703
- // Branch first so the rest of the function only handles Claude-shaped
704
- // responses. We match both Stop and SubagentStop so custom policies
705
- // subscribing to SubagentStop on Cursor get the same force-retry
706
- // semantics — mirrors the cli==="copilot" Stop|SubagentStop widening.
707
- // Ref: https://cursor.com/docs/hooks (Stdout Response Format).
708
- if (session?.cli === "cursor") {
709
- if (eventType === "Stop" || eventType === "SubagentStop") {
710
- const response = {
711
- followup_message: `Instruction from failproofai: ${combined}`,
712
- };
713
- return {
714
- exitCode: 0,
715
- stdout: JSON.stringify(response),
716
- stderr: "",
717
- policyName: policyNames[0],
718
- policyNames,
719
- reason: combined,
720
- decision: "instruct",
721
- };
722
- }
859
+ function formatInstruct(
860
+ eventType: HookEventType,
861
+ session: SessionMetadata | undefined,
862
+ instructEntries: ReadonlyArray<{ policyName: string; reason: string }>,
863
+ ): EvaluationResult {
864
+ const combined = instructEntries.map((e) => e.reason).join("\n");
865
+ const policyNames = instructEntries.map((e) => e.policyName);
866
+
867
+ // Cursor's hook protocol uses a flat `{permission, additional_context}`
868
+ // shape for non-Stop and `{followup_message}` for Stop/SubagentStop.
869
+ // Branch first so the rest of the function only handles Claude-shaped
870
+ // responses. We match both Stop and SubagentStop so custom policies
871
+ // subscribing to SubagentStop on Cursor get the same force-retry
872
+ // semantics — mirrors the cli==="copilot" Stop|SubagentStop widening.
873
+ // Ref: https://cursor.com/docs/hooks (Stdout Response Format).
874
+ if (session?.cli === "cursor") {
875
+ if (eventType === "Stop" || eventType === "SubagentStop") {
723
876
  const response = {
724
- permission: "allow",
725
- additional_context: `Instruction from failproofai: ${combined}`,
877
+ followup_message: `Instruction from failproofai: ${combined}`,
726
878
  };
727
879
  return {
728
880
  exitCode: 0,
@@ -734,37 +886,37 @@ export async function evaluatePolicies(
734
886
  decision: "instruct",
735
887
  };
736
888
  }
889
+ const response = {
890
+ permission: "allow",
891
+ additional_context: `Instruction from failproofai: ${combined}`,
892
+ };
893
+ return {
894
+ exitCode: 0,
895
+ stdout: JSON.stringify(response),
896
+ stderr: "",
897
+ policyName: policyNames[0],
898
+ policyNames,
899
+ reason: combined,
900
+ decision: "instruct",
901
+ };
902
+ }
737
903
 
738
- // Pi: instruct emits `{permission: "allow", reason}`. The shim won't
739
- // block (no `"deny"`); it surfaces `reason` to the user where possible
740
- // (Pi has no first-class `additional_context` channel in its tool-call
741
- // return shape, so we log it). Stop is the exception — we emit a
742
- // `permission: "deny"` with the MANDATORY ACTION wording so the shim
743
- // captures it for next-turn before_agent_start injection. Same handoff
744
- // contract as the deny branch above.
745
- if (session?.cli === "pi") {
746
- if (eventType === "Stop") {
747
- const policyAttribution = policyNames.length === 1
748
- ? `policy: ${policyNames[0]}`
749
- : `policies: ${policyNames.join(", ")}`;
750
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
751
- return {
752
- exitCode: 0,
753
- stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
754
- stderr: "",
755
- policyName: policyNames[0],
756
- policyNames,
757
- reason: combined,
758
- decision: "instruct",
759
- };
760
- }
761
- const response = {
762
- permission: "allow",
763
- reason: `Instruction from failproofai: ${combined}`,
764
- };
904
+ // Pi: instruct emits `{permission: "allow", reason}`. The shim won't
905
+ // block (no `"deny"`); it surfaces `reason` to the user where possible
906
+ // (Pi has no first-class `additional_context` channel in its tool-call
907
+ // return shape, so we log it). Stop is the exception — we emit a
908
+ // `permission: "deny"` with the MANDATORY ACTION wording so the shim
909
+ // captures it for next-turn before_agent_start injection. Same handoff
910
+ // contract as the deny branch above.
911
+ if (session?.cli === "pi") {
912
+ if (eventType === "Stop") {
913
+ const policyAttribution = policyNames.length === 1
914
+ ? `policy: ${policyNames[0]}`
915
+ : `policies: ${policyNames.join(", ")}`;
916
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
765
917
  return {
766
918
  exitCode: 0,
767
- stdout: JSON.stringify(response),
919
+ stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
768
920
  stderr: "",
769
921
  policyName: policyNames[0],
770
922
  policyNames,
@@ -772,149 +924,129 @@ export async function evaluatePolicies(
772
924
  decision: "instruct",
773
925
  };
774
926
  }
927
+ const response = {
928
+ permission: "allow",
929
+ reason: `Instruction from failproofai: ${combined}`,
930
+ };
931
+ return {
932
+ exitCode: 0,
933
+ stdout: JSON.stringify(response),
934
+ stderr: "",
935
+ policyName: policyNames[0],
936
+ policyNames,
937
+ reason: combined,
938
+ decision: "instruct",
939
+ };
940
+ }
941
+
942
+ // Hermes: no additional-context channel on any event (the only actionable
943
+ // response is `{"decision":"block"}`). So instruct degrades to allow +
944
+ // log — we emit a non-blocking `{decision:"allow", reason}` (Hermes lets
945
+ // the tool run) and surface the note on stderr for the operator's logs.
946
+ // Documented limitation; there is no Stop event to force-retry into.
947
+ if (session?.cli === "hermes") {
948
+ const stderrMsg = instructEntries
949
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
950
+ .join("\n");
951
+ return {
952
+ exitCode: 0,
953
+ stdout: JSON.stringify({
954
+ decision: "allow",
955
+ reason: `Instruction from failproofai: ${combined}`,
956
+ }),
957
+ stderr: stderrMsg + "\n",
958
+ policyName: policyNames[0],
959
+ policyNames,
960
+ reason: combined,
961
+ decision: "instruct",
962
+ };
963
+ }
775
964
 
776
- // Hermes: no additional-context channel on any event (the only actionable
777
- // response is `{"decision":"block"}`). So instruct degrades to allow +
778
- // log — we emit a non-blocking `{decision:"allow", reason}` (Hermes lets
779
- // the tool run) and surface the note on stderr for the operator's logs.
780
- // Documented limitation; there is no Stop event to force-retry into.
781
- if (session?.cli === "hermes") {
782
- const stderrMsg = instructEntries
783
- .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
784
- .join("\n");
965
+ // OpenClaw: Stop (before_agent_finalize) can force a revise, so we emit the
966
+ // MANDATORY ACTION wording as a flat deny — the shim maps it to
967
+ // {action:"revise", reason}. PreToolUse has no non-blocking context channel,
968
+ // but a rejected tool's blockReason is model-visible. Preserve `instruct`
969
+ // in the wire verdict so the shim can interrupt the first matching attempt,
970
+ // deliver the instruction through blockReason, and allow a retry. Other
971
+ // events still degrade to allow + stderr because their return channels
972
+ // cannot carry an instruction to the model.
973
+ if (session?.cli === "openclaw") {
974
+ if (eventType === "Stop") {
975
+ const policyAttribution = policyNames.length === 1
976
+ ? `policy: ${policyNames[0]}`
977
+ : `policies: ${policyNames.join(", ")}`;
978
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
785
979
  return {
786
980
  exitCode: 0,
787
- stdout: JSON.stringify({
788
- decision: "allow",
789
- reason: `Instruction from failproofai: ${combined}`,
790
- }),
791
- stderr: stderrMsg + "\n",
981
+ stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
982
+ stderr: "",
792
983
  policyName: policyNames[0],
793
984
  policyNames,
794
985
  reason: combined,
795
986
  decision: "instruct",
796
987
  };
797
988
  }
798
-
799
- // OpenClaw: Stop (before_agent_finalize) can force a revise, so we emit the
800
- // MANDATORY ACTION wording as a flat deny — the shim maps it to
801
- // {action:"revise", reason}. PreToolUse has no non-blocking context channel,
802
- // but a rejected tool's blockReason is model-visible. Preserve `instruct`
803
- // in the wire verdict so the shim can interrupt the first matching attempt,
804
- // deliver the instruction through blockReason, and allow a retry. Other
805
- // events still degrade to allow + stderr because their return channels
806
- // cannot carry an instruction to the model.
807
- if (session?.cli === "openclaw") {
808
- if (eventType === "Stop") {
809
- const policyAttribution = policyNames.length === 1
810
- ? `policy: ${policyNames[0]}`
811
- : `policies: ${policyNames.join(", ")}`;
812
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
813
- return {
814
- exitCode: 0,
815
- stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
816
- stderr: "",
817
- policyName: policyNames[0],
818
- policyNames,
819
- reason: combined,
820
- decision: "instruct",
821
- };
822
- }
823
- if (eventType === "PreToolUse") {
824
- return {
825
- exitCode: 0,
826
- stdout: JSON.stringify({
827
- permission: "instruct",
828
- reason: `Instruction from failproofai: ${combined}`,
829
- policyName: policyNames[0],
830
- policyNames,
831
- }),
832
- stderr: "",
833
- policyName: policyNames[0],
834
- policyNames,
835
- reason: combined,
836
- decision: "instruct",
837
- };
838
- }
839
- const stderrMsg = instructEntries
840
- .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
841
- .join("\n");
989
+ if (eventType === "PreToolUse") {
842
990
  return {
843
991
  exitCode: 0,
844
992
  stdout: JSON.stringify({
845
- permission: "allow",
993
+ permission: "instruct",
846
994
  reason: `Instruction from failproofai: ${combined}`,
995
+ policyName: policyNames[0],
996
+ policyNames,
847
997
  }),
848
- stderr: stderrMsg + "\n",
998
+ stderr: "",
849
999
  policyName: policyNames[0],
850
1000
  policyNames,
851
1001
  reason: combined,
852
1002
  decision: "instruct",
853
1003
  };
854
1004
  }
1005
+ const stderrMsg = instructEntries
1006
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1007
+ .join("\n");
1008
+ return {
1009
+ exitCode: 0,
1010
+ stdout: JSON.stringify({
1011
+ permission: "allow",
1012
+ reason: `Instruction from failproofai: ${combined}`,
1013
+ }),
1014
+ stderr: stderrMsg + "\n",
1015
+ policyName: policyNames[0],
1016
+ policyNames,
1017
+ reason: combined,
1018
+ decision: "instruct",
1019
+ };
1020
+ }
855
1021
 
856
- // OpenCode: same rationale as the deny branch above — emit
857
- // additionalContext so the shim submits a follow-up via
858
- // client.session.prompt instead of throwing into a dead handler.
859
- if (session?.cli === "opencode") {
860
- if (eventType === "Stop" || eventType === "SubagentStop") {
861
- const policyAttribution = policyNames.length === 1
862
- ? `policy: ${policyNames[0]}`
863
- : `policies: ${policyNames.join(", ")}`;
864
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
865
- return {
866
- exitCode: 0,
867
- stdout: JSON.stringify({ hookSpecificOutput: { additionalContext: reasonText } }),
868
- stderr: "",
869
- policyName: policyNames[0],
870
- policyNames,
871
- reason: combined,
872
- decision: "instruct",
873
- };
874
- }
875
- }
876
-
877
- // Factory droid: on Stop, emit the MANDATORY ACTION wording as a
878
- // `{decision:"block", reason}` on stdout (exit 0) — droid's only turn-end
879
- // force-retry channel. Every other event lacks an additional-context
880
- // channel (droid honors JSON only for the Stop block), so instruct degrades
881
- // to allow + stderr note, like Hermes.
882
- if (session?.cli === "factory") {
883
- if (eventType === "Stop") {
884
- const policyAttribution = policyNames.length === 1
885
- ? `policy: ${policyNames[0]}`
886
- : `policies: ${policyNames.join(", ")}`;
887
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
888
- return {
889
- exitCode: 0,
890
- stdout: JSON.stringify({ decision: "block", reason: reasonText }),
891
- stderr: "",
892
- policyName: policyNames[0],
893
- policyNames,
894
- reason: combined,
895
- decision: "instruct",
896
- };
897
- }
898
- const stderrMsg = instructEntries
899
- .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
900
- .join("\n");
1022
+ // OpenCode: same rationale as the deny branch above — emit
1023
+ // additionalContext so the shim submits a follow-up via
1024
+ // client.session.prompt instead of throwing into a dead handler.
1025
+ if (session?.cli === "opencode") {
1026
+ if (eventType === "Stop" || eventType === "SubagentStop") {
1027
+ const policyAttribution = policyNames.length === 1
1028
+ ? `policy: ${policyNames[0]}`
1029
+ : `policies: ${policyNames.join(", ")}`;
1030
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
901
1031
  return {
902
1032
  exitCode: 0,
903
- stdout: "",
904
- stderr: stderrMsg + "\n",
1033
+ stdout: JSON.stringify({ hookSpecificOutput: { additionalContext: reasonText } }),
1034
+ stderr: "",
905
1035
  policyName: policyNames[0],
906
1036
  policyNames,
907
1037
  reason: combined,
908
1038
  decision: "instruct",
909
1039
  };
910
1040
  }
1041
+ }
911
1042
 
912
- // Devin CLI: a pure Claude-clone. On Stop, emit the MANDATORY ACTION
913
- // wording as `{decision:"block", reason}` on stdout (exit 0) — Devin's
914
- // turn-end force-retry channel (its exit-2 is not a force-retry). Every
915
- // other event falls through to the generic Claude additionalContext path
916
- // below (Devin honors `hookSpecificOutput.additionalContext`).
917
- if (session?.cli === "devin" && eventType === "Stop") {
1043
+ // Factory droid: on Stop, emit the MANDATORY ACTION wording as a
1044
+ // `{decision:"block", reason}` on stdout (exit 0) — droid's only turn-end
1045
+ // force-retry channel. Every other event lacks an additional-context
1046
+ // channel (droid honors JSON only for the Stop block), so instruct degrades
1047
+ // to allow + stderr note, like Hermes.
1048
+ if (session?.cli === "factory") {
1049
+ if (eventType === "Stop") {
918
1050
  const policyAttribution = policyNames.length === 1
919
1051
  ? `policy: ${policyNames[0]}`
920
1052
  : `policies: ${policyNames.join(", ")}`;
@@ -929,123 +1061,85 @@ export async function evaluatePolicies(
929
1061
  decision: "instruct",
930
1062
  };
931
1063
  }
1064
+ const stderrMsg = instructEntries
1065
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1066
+ .join("\n");
1067
+ return {
1068
+ exitCode: 0,
1069
+ stdout: "",
1070
+ stderr: stderrMsg + "\n",
1071
+ policyName: policyNames[0],
1072
+ policyNames,
1073
+ reason: combined,
1074
+ decision: "instruct",
1075
+ };
1076
+ }
932
1077
 
933
- // Antigravity CLI: its OWN instruct shapes (verified live agy v1.1.2).
934
- // • UserPromptSubmit (canonical for PreInvocation) → `{injectSteps:[{
935
- // ephemeralMessage}]}` injects the instruction as a transient system
936
- // message before the model runs.
937
- // • Stop → `{decision:"continue", reason}` re-enters the loop with the
938
- // MANDATORY-ACTION directive (Antigravity's only turn-end channel).
939
- // • Every other event lacks an additional-context channel → degrade to
940
- // allow + stderr note, like Hermes.
941
- if (session?.cli === "antigravity") {
942
- if (eventType === "UserPromptSubmit") {
943
- return {
944
- exitCode: 0,
945
- stdout: JSON.stringify({
946
- injectSteps: [{ ephemeralMessage: `Instruction from failproofai: ${combined}` }],
947
- }),
948
- stderr: "",
949
- policyName: policyNames[0],
950
- policyNames,
951
- reason: combined,
952
- decision: "instruct",
953
- };
954
- }
955
- if (eventType === "Stop") {
956
- const policyAttribution = policyNames.length === 1
957
- ? `policy: ${policyNames[0]}`
958
- : `policies: ${policyNames.join(", ")}`;
959
- const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
960
- return {
961
- exitCode: 0,
962
- stdout: JSON.stringify({ decision: "continue", reason: reasonText }),
963
- stderr: "",
964
- policyName: policyNames[0],
965
- policyNames,
966
- reason: combined,
967
- decision: "instruct",
968
- };
969
- }
970
- const stderrMsg = instructEntries
971
- .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
972
- .join("\n");
973
- return {
974
- exitCode: 0,
975
- stdout: "",
976
- stderr: stderrMsg + "\n",
977
- policyName: policyNames[0],
978
- policyNames,
979
- reason: combined,
980
- decision: "instruct",
981
- };
982
- }
1078
+ // Devin CLI: a pure Claude-clone. On Stop, emit the MANDATORY ACTION
1079
+ // wording as `{decision:"block", reason}` on stdout (exit 0) — Devin's
1080
+ // turn-end force-retry channel (its exit-2 is not a force-retry). Every
1081
+ // other event falls through to the generic Claude additionalContext path
1082
+ // below (Devin honors `hookSpecificOutput.additionalContext`).
1083
+ if (session?.cli === "devin" && eventType === "Stop") {
1084
+ const policyAttribution = policyNames.length === 1
1085
+ ? `policy: ${policyNames[0]}`
1086
+ : `policies: ${policyNames.join(", ")}`;
1087
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
1088
+ return {
1089
+ exitCode: 0,
1090
+ stdout: JSON.stringify({ decision: "block", reason: reasonText }),
1091
+ stderr: "",
1092
+ policyName: policyNames[0],
1093
+ policyNames,
1094
+ reason: combined,
1095
+ decision: "instruct",
1096
+ };
1097
+ }
983
1098
 
984
- // Goose: a non-block PreToolUse decision injects nothing (verified live — no
985
- // additional-context channel), and Goose has no Stop event, so instruct
986
- // degrades to allow + stderr note, like Hermes / Factory non-Stop events.
987
- if (session?.cli === "goose") {
988
- const stderrMsg = instructEntries
989
- .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
990
- .join("\n");
1099
+ // Antigravity CLI: its OWN instruct shapes (verified live agy v1.1.2).
1100
+ // • UserPromptSubmit (canonical for PreInvocation) → `{injectSteps:[{
1101
+ // ephemeralMessage}]}` injects the instruction as a transient system
1102
+ // message before the model runs.
1103
+ // • Stop → `{decision:"continue", reason}` re-enters the loop with the
1104
+ // MANDATORY-ACTION directive (Antigravity's only turn-end channel).
1105
+ // • Every other event lacks an additional-context channel → degrade to
1106
+ // allow + stderr note, like Hermes.
1107
+ if (session?.cli === "antigravity") {
1108
+ if (eventType === "UserPromptSubmit") {
991
1109
  return {
992
1110
  exitCode: 0,
993
- stdout: "",
994
- stderr: stderrMsg + "\n",
1111
+ stdout: JSON.stringify({
1112
+ injectSteps: [{ ephemeralMessage: `Instruction from failproofai: ${combined}` }],
1113
+ }),
1114
+ stderr: "",
995
1115
  policyName: policyNames[0],
996
1116
  policyNames,
997
1117
  reason: combined,
998
1118
  decision: "instruct",
999
1119
  };
1000
1120
  }
1001
-
1002
- if (eventType === "Stop" || eventType === "SubagentStop") {
1003
- // Stop/SubagentStop instruct: exitCode 2 + stderr forces Claude to retry
1004
- // the agent (or subagent) loop with the reason as context. Same widening
1005
- // as the deny branch above — custom policies subscribing to
1006
- // SubagentStop need the same retry semantics; the 5 builtin
1007
- // require-*-before-stop policies still match Stop only by design.
1121
+ if (eventType === "Stop") {
1008
1122
  const policyAttribution = policyNames.length === 1
1009
1123
  ? `policy: ${policyNames[0]}`
1010
1124
  : `policies: ${policyNames.join(", ")}`;
1011
1125
  const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
1012
- // Copilot CLI: exit-2 from agentStop / subagentStop is logged as
1013
- // `[WARNING] Hook warning: ...` but does NOT trigger retry. The
1014
- // documented retry shape is `{decision: "block", reason}` JSON on
1015
- // stdout (exit 0). Mirrors the cli==="copilot" branch in the deny
1016
- // path so custom instruct policies enforce on Copilot.
1017
- if (session?.cli === "copilot") {
1018
- return {
1019
- exitCode: 0,
1020
- stdout: JSON.stringify({ decision: "block", reason: reasonText }),
1021
- stderr: "",
1022
- policyName: policyNames[0],
1023
- policyNames,
1024
- reason: combined,
1025
- decision: "instruct",
1026
- };
1027
- }
1028
1126
  return {
1029
- exitCode: 2,
1030
- stdout: "",
1031
- stderr: reasonText,
1127
+ exitCode: 0,
1128
+ stdout: JSON.stringify({ decision: "continue", reason: reasonText }),
1129
+ stderr: "",
1032
1130
  policyName: policyNames[0],
1033
1131
  policyNames,
1034
1132
  reason: combined,
1035
1133
  decision: "instruct",
1036
1134
  };
1037
1135
  }
1038
-
1039
- const response = {
1040
- hookSpecificOutput: {
1041
- hookEventName: eventType,
1042
- additionalContext: `Instruction from failproofai: ${combined}`,
1043
- },
1044
- };
1136
+ const stderrMsg = instructEntries
1137
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1138
+ .join("\n");
1045
1139
  return {
1046
1140
  exitCode: 0,
1047
- stdout: JSON.stringify(response),
1048
- stderr: "",
1141
+ stdout: "",
1142
+ stderr: stderrMsg + "\n",
1049
1143
  policyName: policyNames[0],
1050
1144
  policyNames,
1051
1145
  reason: combined,
@@ -1053,93 +1147,166 @@ export async function evaluatePolicies(
1053
1147
  };
1054
1148
  }
1055
1149
 
1056
- // All policies allowed — pass along any informational messages
1057
- if (allowEntries.length > 0) {
1058
- const combined = allowEntries.map((e) => e.reason).join("\n");
1059
- const policyNames = allowEntries.map((e) => e.policyName);
1150
+ // Goose: a non-block PreToolUse decision injects nothing (verified live — no
1151
+ // additional-context channel), and Goose has no Stop event, so instruct
1152
+ // degrades to allow + stderr note, like Hermes / Factory non-Stop events.
1153
+ if (session?.cli === "goose") {
1154
+ const stderrMsg = instructEntries
1155
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1156
+ .join("\n");
1157
+ return {
1158
+ exitCode: 0,
1159
+ stdout: "",
1160
+ stderr: stderrMsg + "\n",
1161
+ policyName: policyNames[0],
1162
+ policyNames,
1163
+ reason: combined,
1164
+ decision: "instruct",
1165
+ };
1166
+ }
1060
1167
 
1061
- // Cursor: emit the flat shape; allow-with-info maps to
1062
- // `{permission: "allow", additional_context}`.
1063
- if (session?.cli === "cursor") {
1064
- const response = {
1065
- permission: "allow",
1066
- additional_context: `Note from failproofai: ${combined}`,
1067
- };
1068
- const stderrMsg = allowEntries
1069
- .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1070
- .join("\n");
1168
+ if (eventType === "Stop" || eventType === "SubagentStop") {
1169
+ // Stop/SubagentStop instruct: exitCode 2 + stderr forces Claude to retry
1170
+ // the agent (or subagent) loop with the reason as context. Same widening
1171
+ // as the deny branch above — custom policies subscribing to
1172
+ // SubagentStop need the same retry semantics; the 5 builtin
1173
+ // require-*-before-stop policies still match Stop only by design.
1174
+ const policyAttribution = policyNames.length === 1
1175
+ ? `policy: ${policyNames[0]}`
1176
+ : `policies: ${policyNames.join(", ")}`;
1177
+ const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
1178
+ // Copilot CLI: exit-2 from agentStop / subagentStop is logged as
1179
+ // `[WARNING] Hook warning: ...` but does NOT trigger retry. The
1180
+ // documented retry shape is `{decision: "block", reason}` JSON on
1181
+ // stdout (exit 0). Mirrors the cli==="copilot" branch in the deny
1182
+ // path so custom instruct policies enforce on Copilot.
1183
+ if (session?.cli === "copilot") {
1071
1184
  return {
1072
1185
  exitCode: 0,
1073
- stdout: JSON.stringify(response),
1074
- stderr: stderrMsg + "\n",
1186
+ stdout: JSON.stringify({ decision: "block", reason: reasonText }),
1187
+ stderr: "",
1075
1188
  policyName: policyNames[0],
1076
1189
  policyNames,
1077
1190
  reason: combined,
1078
- decision: "allow",
1191
+ decision: "instruct",
1079
1192
  };
1080
1193
  }
1194
+ return {
1195
+ exitCode: 2,
1196
+ stdout: "",
1197
+ stderr: reasonText,
1198
+ policyName: policyNames[0],
1199
+ policyNames,
1200
+ reason: combined,
1201
+ decision: "instruct",
1202
+ };
1203
+ }
1081
1204
 
1082
- // Pi: same shape as Cursor — flat `{permission: "allow", reason}`.
1083
- if (session?.cli === "pi") {
1084
- const response = {
1085
- permission: "allow",
1086
- reason: `Note from failproofai: ${combined}`,
1087
- };
1088
- const stderrMsg = allowEntries
1089
- .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1090
- .join("\n");
1091
- return {
1092
- exitCode: 0,
1093
- stdout: JSON.stringify(response),
1094
- stderr: stderrMsg + "\n",
1095
- policyName: policyNames[0],
1096
- policyNames,
1097
- reason: combined,
1098
- decision: "allow",
1099
- };
1100
- }
1205
+ const response = {
1206
+ hookSpecificOutput: {
1207
+ hookEventName: eventType,
1208
+ additionalContext: `Instruction from failproofai: ${combined}`,
1209
+ },
1210
+ };
1211
+ return {
1212
+ exitCode: 0,
1213
+ stdout: JSON.stringify(response),
1214
+ stderr: "",
1215
+ policyName: policyNames[0],
1216
+ policyNames,
1217
+ reason: combined,
1218
+ decision: "instruct",
1219
+ };
1220
+ }
1101
1221
 
1102
- // OpenClaw: same flat shape as Pi — {permission:"allow", reason}. The shim
1103
- // returns undefined (no block) for an allow verdict regardless, so the note
1104
- // surfaces via stderr; keeping the flat stdout shape keeps the shim's parse
1105
- // path uniform across every verdict.
1106
- if (session?.cli === "openclaw") {
1107
- const stderrMsg = allowEntries
1108
- .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1109
- .join("\n");
1110
- return {
1111
- exitCode: 0,
1112
- stdout: JSON.stringify({
1113
- permission: "allow",
1114
- reason: `Note from failproofai: ${combined}`,
1115
- }),
1116
- stderr: stderrMsg + "\n",
1117
- policyName: policyNames[0],
1118
- policyNames,
1119
- reason: combined,
1120
- decision: "allow",
1121
- };
1122
- }
1222
+ function formatAllow(
1223
+ eventType: HookEventType,
1224
+ session: SessionMetadata | undefined,
1225
+ allowEntries: ReadonlyArray<{ policyName: string; reason: string }>,
1226
+ ): EvaluationResult {
1227
+ const combined = allowEntries.map((e) => e.reason).join("\n");
1228
+ const policyNames = allowEntries.map((e) => e.policyName);
1123
1229
 
1124
- const supportsHookSpecificOutput =
1125
- eventType === "PreToolUse" ||
1126
- eventType === "PostToolUse" ||
1127
- eventType === "UserPromptSubmit" ||
1128
- eventType === "PermissionRequest";
1230
+ // Cursor: emit the flat shape; allow-with-info maps to
1231
+ // `{permission: "allow", additional_context}`.
1232
+ if (session?.cli === "cursor") {
1233
+ const response = {
1234
+ permission: "allow",
1235
+ additional_context: `Note from failproofai: ${combined}`,
1236
+ };
1129
1237
  const stderrMsg = allowEntries
1130
1238
  .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1131
1239
  .join("\n");
1132
- // Only events with a real additional-context channel carry the allow-note
1133
- // to the agent. Everything else (Stop, SubagentStop, Session*, PreCompact, …)
1134
- // has NO channel, so we keep informational allow-notes OUT of stdout — a
1135
- // bare `{reason}` there is rendered as noise (e.g. droid printing a Stop's
1136
- // "…skipping commit check…skipping PR check…" wall on a perfectly fine turn).
1137
- // The note is still logged to stderr + the activity store for diagnostics.
1138
- if (supportsHookSpecificOutput) {
1139
- const response = { hookSpecificOutput: { hookEventName: eventType, additionalContext: `Note from failproofai: ${combined}` } };
1140
- return { exitCode: 0, stdout: JSON.stringify(response), stderr: stderrMsg + "\n", policyName: policyNames[0], policyNames, reason: combined, decision: "allow" };
1141
- }
1142
- return { exitCode: 0, stdout: "", stderr: stderrMsg + "\n", policyName: policyNames[0], policyNames, reason: combined, decision: "allow" };
1240
+ return {
1241
+ exitCode: 0,
1242
+ stdout: JSON.stringify(response),
1243
+ stderr: stderrMsg + "\n",
1244
+ policyName: policyNames[0],
1245
+ policyNames,
1246
+ reason: combined,
1247
+ decision: "allow",
1248
+ };
1143
1249
  }
1144
- return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
1250
+
1251
+ // Pi: same shape as Cursor — flat `{permission: "allow", reason}`.
1252
+ if (session?.cli === "pi") {
1253
+ const response = {
1254
+ permission: "allow",
1255
+ reason: `Note from failproofai: ${combined}`,
1256
+ };
1257
+ const stderrMsg = allowEntries
1258
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1259
+ .join("\n");
1260
+ return {
1261
+ exitCode: 0,
1262
+ stdout: JSON.stringify(response),
1263
+ stderr: stderrMsg + "\n",
1264
+ policyName: policyNames[0],
1265
+ policyNames,
1266
+ reason: combined,
1267
+ decision: "allow",
1268
+ };
1269
+ }
1270
+
1271
+ // OpenClaw: same flat shape as Pi — {permission:"allow", reason}. The shim
1272
+ // returns undefined (no block) for an allow verdict regardless, so the note
1273
+ // surfaces via stderr; keeping the flat stdout shape keeps the shim's parse
1274
+ // path uniform across every verdict.
1275
+ if (session?.cli === "openclaw") {
1276
+ const stderrMsg = allowEntries
1277
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1278
+ .join("\n");
1279
+ return {
1280
+ exitCode: 0,
1281
+ stdout: JSON.stringify({
1282
+ permission: "allow",
1283
+ reason: `Note from failproofai: ${combined}`,
1284
+ }),
1285
+ stderr: stderrMsg + "\n",
1286
+ policyName: policyNames[0],
1287
+ policyNames,
1288
+ reason: combined,
1289
+ decision: "allow",
1290
+ };
1291
+ }
1292
+
1293
+ const supportsHookSpecificOutput =
1294
+ eventType === "PreToolUse" ||
1295
+ eventType === "PostToolUse" ||
1296
+ eventType === "UserPromptSubmit" ||
1297
+ eventType === "PermissionRequest";
1298
+ const stderrMsg = allowEntries
1299
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
1300
+ .join("\n");
1301
+ // Only events with a real additional-context channel carry the allow-note
1302
+ // to the agent. Everything else (Stop, SubagentStop, Session*, PreCompact, …)
1303
+ // has NO channel, so we keep informational allow-notes OUT of stdout — a
1304
+ // bare `{reason}` there is rendered as noise (e.g. droid printing a Stop's
1305
+ // "…skipping commit check…skipping PR check…" wall on a perfectly fine turn).
1306
+ // The note is still logged to stderr + the activity store for diagnostics.
1307
+ if (supportsHookSpecificOutput) {
1308
+ const response = { hookSpecificOutput: { hookEventName: eventType, additionalContext: `Note from failproofai: ${combined}` } };
1309
+ return { exitCode: 0, stdout: JSON.stringify(response), stderr: stderrMsg + "\n", policyName: policyNames[0], policyNames, reason: combined, decision: "allow" };
1310
+ }
1311
+ return { exitCode: 0, stdout: "", stderr: stderrMsg + "\n", policyName: policyNames[0], policyNames, reason: combined, decision: "allow" };
1145
1312
  }