failproofai 1.0.7-beta.2 → 1.0.7

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