failproofai 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +4 -4
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js +4 -4
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/run/route.js +8 -7
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  33. package/.next/standalone/.next/server/app/audit/page.js +9 -7
  34. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/index.html +1 -1
  37. package/.next/standalone/.next/server/app/index.rsc +15 -15
  38. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  39. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  40. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  41. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  42. package/.next/standalone/.next/server/app/page.js +6 -6
  43. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  46. package/.next/standalone/.next/server/app/policies/page.js +11 -10
  47. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page.js +8 -7
  51. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -6
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page.js +7 -6
  60. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +52 -8
  63. package/.next/standalone/.next/server/app/settings/page.js +12 -7
  64. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/chunks/{[externals]__1msfs-h._.js → [externals]__19_pzeq._.js} +1 -1
  67. package/.next/standalone/.next/server/chunks/{[externals]__1_bftcl._.js → [externals]__20kzpkf._.js} +1 -1
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +22 -0
  70. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +21 -21
  71. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +16 -16
  74. package/.next/standalone/.next/server/chunks/{_1c3k-8x._.js → _1q5i8mb._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/lib_16xa545._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +5 -0
  78. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +4 -2
  80. package/.next/standalone/.next/server/chunks/ssr/[externals]__0ohnuzs._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01bmjsj._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +4 -0
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__02npjtd._.js → [root-of-the-server]__056wjo4._.js} +3 -3
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__059yza8._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06pflha._.js +3 -0
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0eip4_k._.js +22 -0
  87. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0p-5p8u._.js → [root-of-the-server]__0n0xg95._.js} +3 -3
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qcb0mg._.js +3 -0
  89. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__013jr2b._.js → [root-of-the-server]__0rwtwpm._.js} +3 -3
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0yrsbd_._.js +3 -0
  91. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0da85px._.js → [root-of-the-server]__11mayhe._.js} +3 -3
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12e7nhs._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13d-wb6._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13t1zkw._.js +3 -0
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +4 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19evfi8._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1dinjii._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +4 -0
  99. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0cxe_2_._.js → [root-of-the-server]__1mf3zp6._.js} +3 -3
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ftmoxc._.js → [root-of-the-server]__1pprgri._.js} +3 -3
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1p2otjt._.js → [root-of-the-server]__1q4p5b8._.js} +3 -3
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qiz0e4._.js +3 -0
  103. package/.next/standalone/.next/server/chunks/ssr/{_166t73i._.js → _0-vcssj._.js} +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +3 -0
  105. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/ssr/_0uyu3jf._.js +3 -0
  108. package/.next/standalone/.next/server/chunks/ssr/_13kfn90._.js +23 -0
  109. package/.next/standalone/.next/server/chunks/ssr/{_1es2j7i._.js → _1gb0ifp._.js} +5 -5
  110. package/.next/standalone/.next/server/chunks/ssr/{_1_qswah._.js → _1q46vxx._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +13 -13
  113. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +3 -0
  114. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  117. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +3 -0
  118. package/.next/standalone/.next/server/chunks/ssr/{node_modules_next_dist_0drixxt._.js → node_modules_next_dist_0w6mzq5._.js} +4 -4
  119. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_18_d8l1._.js +151 -0
  120. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0-q0umm._.js +3 -0
  121. package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9d._.js +12 -0
  122. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0g194sy._.js +3 -0
  123. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +4 -2
  124. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1kx9e0d._.js +3 -0
  125. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +5 -0
  126. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  127. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  128. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +1 -1
  129. package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +3 -0
  130. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  131. package/.next/standalone/.next/server/pages/404.html +1 -1
  132. package/.next/standalone/.next/server/pages/500.html +1 -1
  133. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  134. package/.next/standalone/.next/server/server-reference-manifest.json +67 -23
  135. package/.next/standalone/.next/static/chunks/06dnzbolj00mc.js +6 -0
  136. package/.next/standalone/.next/static/chunks/0bhidk90e-07f.css +2 -0
  137. package/.next/standalone/.next/static/chunks/{2mdh397ghgnvv.js → 0m-9d6yn9hx4j.js} +1 -1
  138. package/.next/standalone/.next/static/chunks/{0__8a7m868fvf.js → 0wbjy0zo-m7is.js} +1 -1
  139. package/.next/standalone/.next/static/chunks/16f3fa-lx38hk.js +1 -0
  140. package/.next/standalone/.next/static/chunks/{0o6qlkgubtoex.js → 21uv-uusw329x.js} +1 -1
  141. package/.next/standalone/.next/static/chunks/2g2tki08kdhie.js +1 -0
  142. package/.next/standalone/.next/static/chunks/2qdpj67x6ifk_.js +69 -0
  143. package/.next/standalone/.next/static/chunks/2qv4hshejedtx.css +1 -0
  144. package/.next/standalone/.next/static/chunks/3-nbtkhg9y-1j.js +1 -0
  145. package/.next/standalone/.next/static/chunks/355km0ihuqo1p.js +1 -0
  146. package/.next/standalone/.next/static/chunks/{13i7-9is-vhys.js → 3c3qbmosdjl6w.js} +1 -1
  147. package/.next/standalone/PROBE-FOLLOWUP.md +186 -0
  148. package/.next/standalone/app/actions/get-jev-config.ts +604 -0
  149. package/.next/standalone/app/actions/pack-actions.ts +12 -0
  150. package/.next/standalone/app/actions/update-jev-config.ts +569 -0
  151. package/.next/standalone/app/components/jev-notices.tsx +96 -0
  152. package/.next/standalone/app/policies/hooks-client.tsx +14 -3
  153. package/.next/standalone/app/settings/jev-panel.tsx +630 -0
  154. package/.next/standalone/app/settings/page.tsx +20 -1
  155. package/.next/standalone/app/settings/settings-client.tsx +27 -1
  156. package/.next/standalone/app/settings/settings.css +79 -0
  157. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +22 -4
  158. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +14 -1
  159. package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +17 -3
  160. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +3 -0
  161. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +22 -0
  162. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +169 -0
  163. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +1 -1
  164. package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +65 -0
  165. package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +61 -0
  166. package/.next/standalone/package.json +10 -10
  167. package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
  168. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  169. package/.next/standalone/sdk/typescript/CHANGELOG.md +18 -1
  170. package/.next/standalone/sdk/typescript/integration/fixtures/nextjs/package-lock.json +0 -274
  171. package/.next/standalone/sdk/typescript/scripts/release.mjs +30 -0
  172. package/.next/standalone/server.js +1 -1
  173. package/README.md +3 -2
  174. package/bin/failproofai.mjs +148 -4
  175. package/dist/cli.mjs +18599 -9478
  176. package/dist/index.js +19 -1
  177. package/dist/worker.mjs +10500 -3773
  178. package/package.json +10 -10
  179. package/pi-extension/index.ts +11 -0
  180. package/scripts/build-policy-pack.mjs +53 -2
  181. package/src/audit/features.ts +3 -2
  182. package/src/hooks/builtin-policies.ts +195 -8
  183. package/src/hooks/cloud-connection.ts +190 -1
  184. package/src/hooks/cloud-enrollment-cli.ts +57 -8
  185. package/src/hooks/cloud-introspect.ts +6 -0
  186. package/src/hooks/cloud-managed-policies.ts +22 -0
  187. package/src/hooks/configure-wizard.ts +26 -6
  188. package/src/hooks/custom-hooks-loader.ts +98 -10
  189. package/src/hooks/custom-hooks-registry.ts +45 -1
  190. package/src/hooks/effective-reviewers.ts +243 -0
  191. package/src/hooks/first-run-gate.ts +5 -0
  192. package/src/hooks/flush-cli.ts +35 -8
  193. package/src/hooks/fp-config.ts +266 -1
  194. package/src/hooks/fp-home.ts +23 -0
  195. package/src/hooks/fp-reset.ts +22 -4
  196. package/src/hooks/handler.ts +303 -7
  197. package/src/hooks/hook-activity-store.ts +118 -6
  198. package/src/hooks/hook-telemetry.ts +41 -0
  199. package/src/hooks/jev-activity.ts +385 -0
  200. package/src/hooks/jev-cli.ts +2172 -0
  201. package/src/hooks/jev-cloud-connection.ts +392 -0
  202. package/src/hooks/loader-utils.ts +6 -0
  203. package/src/hooks/manager.ts +45 -7
  204. package/src/hooks/pack-cli.ts +568 -37
  205. package/src/hooks/pack-failclosed.ts +3 -0
  206. package/src/hooks/pack-manifest.ts +489 -7
  207. package/src/hooks/pack-store.ts +194 -11
  208. package/src/hooks/policy-authority.ts +386 -0
  209. package/src/hooks/policy-catalog.ts +206 -0
  210. package/src/hooks/policy-evaluator.ts +963 -796
  211. package/src/hooks/policy-registry.ts +27 -1
  212. package/src/hooks/policy-reviewability.ts +258 -0
  213. package/src/hooks/policy-types.ts +128 -0
  214. package/src/hooks/semantic/combine.ts +581 -0
  215. package/src/hooks/semantic/compile.ts +176 -0
  216. package/src/hooks/semantic/decide.ts +513 -0
  217. package/src/hooks/semantic/envelope.ts +1255 -0
  218. package/src/hooks/semantic/evaluator.ts +556 -0
  219. package/src/hooks/semantic/facts.ts +387 -0
  220. package/src/hooks/semantic/intent.ts +1190 -0
  221. package/src/hooks/semantic/jev-client.ts +1155 -0
  222. package/src/hooks/semantic/jev-config.ts +1142 -0
  223. package/src/hooks/semantic/jev-review.ts +389 -0
  224. package/src/hooks/semantic/jev-stats.ts +289 -0
  225. package/src/hooks/semantic/jev-throttle.ts +427 -0
  226. package/src/hooks/semantic/pack-policies.ts +294 -0
  227. package/src/hooks/semantic/policies.ts +597 -0
  228. package/src/hooks/semantic/precondition-names.ts +60 -0
  229. package/src/hooks/semantic/preconditions.ts +58 -0
  230. package/src/hooks/semantic/redact.ts +2937 -0
  231. package/src/hooks/semantic/session-root.ts +116 -0
  232. package/src/hooks/semantic/types.ts +162 -0
  233. package/src/hooks/semver-precedence.ts +128 -0
  234. package/src/hooks/tui.ts +4 -0
  235. package/src/hooks/types.ts +1 -1
  236. package/src/hooks/worker-server.ts +121 -27
  237. package/src/index.ts +6 -0
  238. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cuho4x._.js +0 -3
  239. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1_r2rbg._.js +0 -24
  240. package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +0 -3
  241. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-_ki57._.js +0 -4
  242. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +0 -4
  243. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bd3mje._.js +0 -3
  244. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cg-bgc._.js +0 -5
  245. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cpu_mj._.js +0 -3
  246. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u3w0ll._.js +0 -22
  247. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17d_ffl._.js +0 -3
  248. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19d9tgz._.js +0 -5
  249. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +0 -3
  250. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +0 -3
  251. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1phc187._.js +0 -3
  252. package/.next/standalone/.next/server/chunks/ssr/_06imw3p._.js +0 -5
  253. package/.next/standalone/.next/server/chunks/ssr/_0h_douw._.js +0 -3
  254. package/.next/standalone/.next/server/chunks/ssr/_1-i_gzc._.js +0 -23
  255. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +0 -3
  256. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +0 -3
  257. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +0 -3
  258. package/.next/standalone/.next/static/chunks/094xgi4owxaqf.js +0 -1
  259. package/.next/standalone/.next/static/chunks/1rz20_pz828f3.js +0 -6
  260. package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +0 -1
  261. package/.next/standalone/.next/static/chunks/2klitrtzpaoe0.js +0 -1
  262. package/.next/standalone/.next/static/chunks/2rshywgeqsyzk.css +0 -2
  263. package/.next/standalone/.next/static/chunks/3pzx4chkhko9k.js +0 -1
  264. package/.next/standalone/.next/static/chunks/3rh5o7e16irrm.js +0 -69
  265. package/.next/standalone/.next/static/chunks/43ufqrz8qo3h-.js +0 -1
  266. /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → 5yKM8NoTOpUI0MoQl8eo5}/_buildManifest.js +0 -0
  267. /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → 5yKM8NoTOpUI0MoQl8eo5}/_clientMiddlewareManifest.js +0 -0
  268. /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → 5yKM8NoTOpUI0MoQl8eo5}/_ssgManifest.js +0 -0
@@ -0,0 +1,1190 @@
1
+ /**
2
+ * What the human actually asked for.
3
+ *
4
+ * The semantic evaluator may clear a REVIEWABLE policy when the user explicitly
5
+ * asked for the action — `git push --force` is right when you just said "force
6
+ * push it". Cutting those false blocks is the whole point of the evaluator, so
7
+ * where "the user asked" comes from is a product decision, not a puzzle. It was
8
+ * taken on 2026-09-23 and it is written out here in full.
9
+ *
10
+ * WHAT IS RECORDED. The prompt the harness itself hands the hook at its
11
+ * prompt-submit event (canonical `UserPromptSubmit`), on every harness whose
12
+ * event carries the human's text — `PROMPT_CHANNELS` below audits all twelve,
13
+ * one row each, and docs/reference/jev-intent.mdx prints the same table. The
14
+ * text is cleaned of harness wrapping (`cleanHumanTurn`), redacted, capped and
15
+ * stored 0600; the last five prompts of a session are kept for six hours.
16
+ * Nothing on the file system is consulted: the session transcript is a file the
17
+ * agent can rewrite in one command, so it is never asked who wrote a prompt.
18
+ *
19
+ * WHOSE FILE IT IS. 0600 on the file proves nothing if another local user can
20
+ * write to a directory above it — they rename it away and leave their own, and
21
+ * every check on the file then passes. So the read path holds the store's whole
22
+ * path to the same rule `jev-config.ts` holds `jev.json`'s directory to
23
+ * (`looseIntentStoreDir` below): loose write bits are taken off where we can,
24
+ * and a directory that is still writable by someone else stops the read. Then
25
+ * nothing is recorded as the human's, and nothing is cleared.
26
+ *
27
+ * THE ACCEPTED RISK, PLAINLY. An agent that can already run commands can make a
28
+ * harness submit a prompt: `claude -p "…"`, `codex exec`, `copilot -p`,
29
+ * `cursor-agent -p`, `devin -p`, `droid -p`, `goose run -t` and `pi -p` all
30
+ * fire the same event, with the same payload, as a person typing, in a child
31
+ * session that agent controls. It can also run failproofai's own hook binary
32
+ * with a payload it wrote. Nothing inside this module can tell either from the
33
+ * real thing — both are the same program reading the same stdin — so an agent
34
+ * with a shell can forge its own consent, and this module does not pretend
35
+ * otherwise.
36
+ *
37
+ * WHY THAT IS ACCEPTED. The alternative was built and measured (round 9): every
38
+ * field that would prove a human typed the prompt is a field no shipping
39
+ * harness sends, so requiring one recorded NOTHING, anywhere, and Jev judged
40
+ * every call with no stated intent — unable to clear a single regex deny, which
41
+ * is the entire feature. A capture that works is worth more than a forgery an
42
+ * agent can only perform once it is already executing commands.
43
+ *
44
+ * WHAT CONSENT CAN AND CANNOT DO. A recorded prompt can only ever clear a
45
+ * policy that is already marked reviewable (`effectiveAuthority` in
46
+ * policy-types.ts) and whose semantic policy sets `userCanOverride`. A hard
47
+ * policy is never cleared by anything Jev says, so a forged prompt can never
48
+ * turn a HARD deny into an allow, and skipping the hook entirely gains an
49
+ * agent nothing, since the harness invokes failproofai for the tool call
50
+ * independently.
51
+ *
52
+ * What it CAN do is worth stating exactly, because "it only stops a nudge" is
53
+ * not true: of the fifteen reviewable builtins (policy-catalog.ts), TWELVE are
54
+ * blocking denies — `protect-env-vars`, `block-env-files`,
55
+ * `block-read-outside-cwd`, `block-rm-rf`, `block-force-push`,
56
+ * `block-secrets-write` and the six infra-CLI blocks — and only
57
+ * `warn-git-amend`, `warn-destructive-sql` and `warn-global-package-install`
58
+ * are nudges. So a forged consent can turn a real deny into an allow on those
59
+ * twelve. `block-work-on-main` is hard. That is the size of the trade; see
60
+ * docs/reference/jev-intent.mdx, which keeps the list.
61
+ *
62
+ * WHAT IS STILL REFUSED, because it is cheap and an agent cannot forge it by
63
+ * asking: a turn the harness's own payload marks as machine-submitted (a cron
64
+ * or loop wakeup, an injected system turn, another extension's message, an
65
+ * OpenClaw heartbeat), a payload naming a sub-agent (`agent_id`), a session id
66
+ * that is not a plain name, an event that is not the prompt-submit one, and
67
+ * text that is nothing but harness wrapping — failproofai's own stop-gate
68
+ * words among it, which some harnesses feed back as the next user turn.
69
+ *
70
+ * - Replay: the eval harness reads historical transcripts, where human
71
+ * messages are the non-meta `user` entries whose content is text rather than
72
+ * a tool result.
73
+ *
74
+ * Alongside each prompt, `captureIntent` snapshots the agent's last visible
75
+ * message from the transcript at that moment, so a reply like "yes, do it" can
76
+ * be understood. The agent wrote that message: it is sent to Jev labelled as
77
+ * agent-written and is never consent on its own. (One caveat worth knowing, in
78
+ * a consumer rather than here: decide v1 lets that message satisfy the
79
+ * deterministic "did the user name this target" check, so an agent that writes
80
+ * its own transcript can supply the target name — see decide.ts and the docs'
81
+ * Known limits.)
82
+ *
83
+ * Text inside a tool call claiming "the user approved this" is never consulted.
84
+ */
85
+ import {
86
+ chmodSync,
87
+ closeSync,
88
+ mkdirSync,
89
+ openSync,
90
+ readdirSync,
91
+ readFileSync,
92
+ readSync,
93
+ renameSync,
94
+ statSync,
95
+ unlinkSync,
96
+ writeFileSync,
97
+ } from "node:fs";
98
+ import { dirname, resolve } from "node:path";
99
+ import { failproofaiHome, semanticDir } from "../fp-home";
100
+ import type { IntegrationType } from "../types";
101
+ import { MAX_USER_MESSAGE_CHARS, redactSecrets } from "./envelope";
102
+
103
+ export const MAX_RECORDED_PROMPTS = 5;
104
+ /** Older than this and a prompt no longer describes what the agent is doing. */
105
+ export const INTENT_MAX_AGE_MS = 6 * 60 * 60 * 1000;
106
+ /** A prompt stamped further in the future than this was not written by us. */
107
+ const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
108
+
109
+ const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
110
+
111
+ const sessionsDir = (): string => resolve(semanticDir(), "sessions");
112
+ const intentFile = (sessionId: string): string => resolve(sessionsDir(), `${sessionId}.json`);
113
+
114
+ /**
115
+ * Group- or world-WRITE bits on a directory the store sits under. Anyone who
116
+ * can write to one can rename it away and leave their own in its place, and
117
+ * every check on the file inside then passes — so the file's own 0600 proves
118
+ * nothing about who wrote it. Read bits are deliberately not included: a 0755
119
+ * directory is ordinary and gives nobody that power.
120
+ *
121
+ * Exactly `jev-config.ts`'s `DIR_WRITABLE_BY_OTHERS`, for exactly its reason.
122
+ * The config refuses a `jev.json` whose DIRECTORY is loose; this is the same
123
+ * check on the store that answers "did the human ask for this", which is the
124
+ * one input that can clear a reviewable deny.
125
+ */
126
+ const DIR_WRITABLE_BY_OTHERS = 0o022;
127
+
128
+ /** Whether permission bits can be trusted to mean anything here. */
129
+ function modesAreMeaningful(): boolean {
130
+ // On Windows `stat().mode` is synthesized, so the check would refuse every
131
+ // read without protecting anything (`jev-config.ts` says the same).
132
+ return process.platform !== "win32";
133
+ }
134
+
135
+ /**
136
+ * The directories the session files sit under, innermost first, up to and
137
+ * including the failproofai home. Every one of them is ours by construction —
138
+ * `state/`, `state/semantic/` and its `sessions/` — but only the two this
139
+ * module creates are created 0700; `state/` is the daemon's
140
+ * (`fpai-collect`'s `create_dir_all`) and arrives at the umask, which on a
141
+ * umask-002 box is 0775.
142
+ */
143
+ function storeDirChain(): string[] {
144
+ const home = resolve(failproofaiHome());
145
+ const chain: string[] = [];
146
+ let dir = sessionsDir();
147
+ // Bounded: the chain is four deep, and a home that is not an ancestor (a
148
+ // layout change, a symlink) must not walk to the filesystem root.
149
+ for (let i = 0; i < 8; i++) {
150
+ chain.push(dir);
151
+ if (dir === home) break;
152
+ const parent = dirname(dir);
153
+ if (parent === dir) break;
154
+ dir = parent;
155
+ }
156
+ return chain;
157
+ }
158
+
159
+ /**
160
+ * The first directory under the failproofai home that someone else can write
161
+ * to and we cannot fix, or null when the store's whole path is ours alone.
162
+ *
163
+ * Loose bits are TIGHTENED before they are refused, because on an ordinary
164
+ * umask-002 machine `~/.failproofai/state` is 0775 the moment the daemon
165
+ * creates it, and refusing that would switch the clearing half of the
166
+ * evaluator off for most Linux users with nothing said. Taking the write bits
167
+ * off is the same remedy `failproofai jev setup` applies to the home, it only
168
+ * ever removes access, and it only touches directories inside failproofai's
169
+ * own home. What survives it — a directory we do not own — is the case the
170
+ * check exists for: another local user who can rename `semantic/` away and
171
+ * leave their own `sessions/<id>.json`, whose prompts would be read as the
172
+ * human's and could clear the four reviewable BLOCKING policies.
173
+ *
174
+ * Exported so `failproofai jev status` can report it the way it reports a
175
+ * refused config.
176
+ */
177
+ export function looseIntentStoreDir(): string | null {
178
+ if (!modesAreMeaningful()) return null;
179
+ for (const dir of storeDirChain()) {
180
+ let mode: number;
181
+ try {
182
+ mode = statSync(dir).mode & 0o777;
183
+ } catch {
184
+ // Not there — nothing can be read out of it either — or not ours to
185
+ // stat, which the read itself then reports.
186
+ continue;
187
+ }
188
+ if ((mode & DIR_WRITABLE_BY_OTHERS) === 0) continue;
189
+ try {
190
+ chmodSync(dir, mode & ~DIR_WRITABLE_BY_OTHERS);
191
+ if ((statSync(dir).mode & DIR_WRITABLE_BY_OTHERS) !== 0) return dir;
192
+ } catch {
193
+ return dir;
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ interface RecordedPrompt {
200
+ at: number;
201
+ text: string;
202
+ /** The agent's last visible message when this prompt was submitted. */
203
+ agent?: string | null;
204
+ }
205
+
206
+ interface IntentFile {
207
+ prompts: RecordedPrompt[];
208
+ }
209
+
210
+ function readIntentFile(sessionId: string): IntentFile {
211
+ try {
212
+ const parsed = JSON.parse(readFileSync(intentFile(sessionId), "utf8")) as IntentFile;
213
+ return Array.isArray(parsed?.prompts) ? parsed : { prompts: [] };
214
+ } catch {
215
+ return { prompts: [] };
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Append one prompt to the session's file, the newest `MAX_RECORDED_PROMPTS`
221
+ * kept. Atomic, 0600 file in a 0700 directory. Returns false instead of
222
+ * throwing: losing intent only means no override.
223
+ *
224
+ * A prompt identical to the one just recorded REPLACES it instead of being
225
+ * appended, so a harness that fires its prompt event more than once for the
226
+ * same message — OpenCode's `message.updated` fires on every update of it —
227
+ * cannot push the rest of the session's task out of a five-slot window with
228
+ * copies of one line. The replacement carries the new timestamp and the new
229
+ * agent snapshot, so the entry stays the latest thing the human said.
230
+ */
231
+ function appendPrompt(sessionId: string, entry: RecordedPrompt): boolean {
232
+ try {
233
+ const file = readIntentFile(sessionId);
234
+ const last = file.prompts[file.prompts.length - 1];
235
+ const kept = last && last.text === entry.text ? file.prompts.slice(0, -1) : file.prompts;
236
+ file.prompts = [...kept, entry].slice(-MAX_RECORDED_PROMPTS);
237
+ const dir = sessionsDir();
238
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
239
+ const target = intentFile(sessionId);
240
+ const isNew = !fileExists(target);
241
+ const tmp = `${target}.${process.pid}.tmp`;
242
+ writeFileSync(tmp, JSON.stringify(file), { mode: 0o600 });
243
+ renameSync(tmp, target);
244
+ // Once per session, not per prompt: sweep the files no read can use.
245
+ if (isNew) pruneExpiredSessions(entry.at);
246
+ return true;
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ function fileExists(path: string): boolean {
253
+ try {
254
+ statSync(path);
255
+ return true;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
261
+ const SESSION_FILE_RE = /^[A-Za-z0-9._-]{1,128}\.json(?:\.\d+\.tmp)?$/;
262
+
263
+ /**
264
+ * Delete session files last written longer ago than `INTENT_MAX_AGE_MS`. A
265
+ * file's mtime is its newest recorded prompt's, so every prompt in such a
266
+ * file is already outside the window every read filters by and can never be
267
+ * read again. Removing it loses nothing and keeps one-file-per-session from
268
+ * growing without bound.
269
+ */
270
+ export function pruneExpiredSessions(now: number = Date.now()): number {
271
+ let removed = 0;
272
+ try {
273
+ const dir = sessionsDir();
274
+ for (const name of readdirSync(dir)) {
275
+ if (!SESSION_FILE_RE.test(name)) continue;
276
+ const path = resolve(dir, name);
277
+ try {
278
+ const st = statSync(path);
279
+ if (st.isFile() && now - st.mtimeMs > INTENT_MAX_AGE_MS) {
280
+ unlinkSync(path);
281
+ removed++;
282
+ }
283
+ } catch {
284
+ // Raced with another writer or already gone: nothing to do.
285
+ }
286
+ }
287
+ } catch {
288
+ // No directory yet.
289
+ }
290
+ return removed;
291
+ }
292
+
293
+ /**
294
+ * Store one cleaned prompt, with the agent message it replies to. Module-
295
+ * private on purpose: `captureIntent` is the only door into `user_said`, and
296
+ * every origin check lives on the other side of it. An exported writer that
297
+ * took a bare string would be a second door with no check on it at all, which
298
+ * is what this used to be (`recordUserPrompt`, deleted 2026-09-23).
299
+ *
300
+ * Redacted before it is capped, like everything stored here, so a cut can never
301
+ * leave half a secret the patterns no longer match. Never throws: losing intent
302
+ * only means no override.
303
+ */
304
+ function recordPrompt(sessionId: string, prompt: string, agent: string | null, now: number): boolean {
305
+ if (!SESSION_ID_RE.test(sessionId)) return false;
306
+ if (prompt.trim().length === 0) return false;
307
+ try {
308
+ return appendPrompt(sessionId, { at: now, text: storable(prompt.trim()), agent: agent === null ? null : storable(agent) });
309
+ } catch {
310
+ return false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * The session's unexpired prompts, oldest first. Tolerates a hand-edited file.
316
+ *
317
+ * Nothing is read out of a store whose path someone else can write to
318
+ * (`looseIntentStoreDir`). Returning none is the fail-closed answer: Jev is
319
+ * still asked, and judges with no stated intent, so no reviewable deny is
320
+ * cleared on a prompt we cannot say is the owner's.
321
+ */
322
+ function livePrompts(sessionId: string | undefined, now: number): RecordedPrompt[] {
323
+ if (!sessionId || !SESSION_ID_RE.test(sessionId)) return [];
324
+ if (looseIntentStoreDir() !== null) return [];
325
+ return readIntentFile(sessionId).prompts.filter(
326
+ (p) =>
327
+ typeof p?.text === "string" &&
328
+ typeof p.at === "number" &&
329
+ now - p.at <= INTENT_MAX_AGE_MS &&
330
+ p.at - now <= MAX_CLOCK_SKEW_MS,
331
+ );
332
+ }
333
+
334
+ // ── Transcript replay ────────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * Harness-generated user entries: slash-command echoes, caveats,
338
+ * notifications — and failproofai's own words. Cursor submits a Stop gate's
339
+ * `followup_message` as the next user message, and Copilot, Devin and
340
+ * OpenClaw feed a Stop block's reason back into the next turn, so what
341
+ * policy-evaluator.ts wrote can arrive looking like a prompt.
342
+ *
343
+ * Also the wrappers Claude Code puts around a user-role turn that another
344
+ * agent or session wrote (a peer session, a teammate, a coordinator, a
345
+ * channel): its own classifier calls those "never user intent".
346
+ */
347
+ const NON_HUMAN_PREFIXES = [
348
+ "<local-command-caveat>",
349
+ "<local-command-stdout>",
350
+ "<local-command-stderr>",
351
+ "<task-notification>",
352
+ "<system-reminder>",
353
+ "[Request interrupted",
354
+ "MANDATORY ACTION REQUIRED from failproofai",
355
+ "Instruction from failproofai:",
356
+ "<cross-session-message",
357
+ "<teammate-message",
358
+ "<agent-message",
359
+ "<coordinator-relay",
360
+ "<channel source=",
361
+ ];
362
+
363
+ // ── Tag blocks, in linear time ───────────────────────────────────────────────
364
+ //
365
+ // A prompt can be megabytes of pasted text, and it is cleaned on the hook path
366
+ // before anything caps it. A lazy regex such as /<x>[\s\S]*?<\/x>/g rescans to
367
+ // the end of the text from every opener that has no closer, so its cost grows
368
+ // with the square of the number of unclosed openers: 1 MB of them took over
369
+ // 20 s, long enough for the daemon client to give up and deny every hook on
370
+ // the machine. These helpers find exactly the regex's matches with indexOf:
371
+ // once an opener has no closer after it, no later opener can have one either,
372
+ // so the scan stops there.
373
+
374
+ /**
375
+ * Replace every block the lazy regex `/<tag>([\s\S]*?)<\/tag>/g` matches —
376
+ * with `attributes`, `/<tag[^>]*>([\s\S]*?)<\/tag[^>]*>/g` — by
377
+ * `replace(inner)`. Same matches, same order, linear time.
378
+ */
379
+ function replaceTagBlocks(text: string, tag: string, replace: (inner: string) => string, attributes = false): string {
380
+ const open = attributes ? `<${tag}` : `<${tag}>`;
381
+ const close = attributes ? `</${tag}` : `</${tag}>`;
382
+ let out = "";
383
+ let from = 0;
384
+ for (;;) {
385
+ const at = text.indexOf(open, from);
386
+ if (at < 0) break;
387
+ let inner = at + open.length;
388
+ if (attributes) {
389
+ const gt = text.indexOf(">", inner);
390
+ if (gt < 0) break;
391
+ inner = gt + 1;
392
+ }
393
+ const closeAt = text.indexOf(close, inner);
394
+ if (closeAt < 0) break;
395
+ let end = closeAt + close.length;
396
+ if (attributes) {
397
+ const gt = text.indexOf(">", end);
398
+ if (gt < 0) break;
399
+ end = gt + 1;
400
+ }
401
+ out += text.slice(from, at) + replace(text.slice(inner, closeAt));
402
+ from = end;
403
+ }
404
+ return from === 0 ? text : out + text.slice(from);
405
+ }
406
+
407
+ /** The inner text of the first block `/<tag>([\s\S]*?)<\/tag>/` matches, in linear time. */
408
+ function firstTagContent(text: string, tag: string): string | undefined {
409
+ const open = `<${tag}>`;
410
+ const at = text.indexOf(open);
411
+ if (at < 0) return undefined;
412
+ const closeAt = text.indexOf(`</${tag}>`, at + open.length);
413
+ return closeAt < 0 ? undefined : text.slice(at + open.length, closeAt);
414
+ }
415
+
416
+ function stripHarnessMarkup(text: string): string {
417
+ const noReminders = replaceTagBlocks(text, "system-reminder", () => "");
418
+ return replaceTagBlocks(noReminders, "pasted_content", () => "[pasted content]", true).trim();
419
+ }
420
+
421
+ /**
422
+ * The human-typed text of a transcript entry, or null if the entry is not a
423
+ * human message. Slash commands count only through their typed arguments.
424
+ */
425
+ export function humanMessageText(entry: unknown): string | null {
426
+ const e = entry as {
427
+ type?: string;
428
+ isMeta?: boolean;
429
+ isSidechain?: boolean;
430
+ message?: { role?: string; content?: unknown };
431
+ };
432
+ if (e?.type !== "user" || e.isMeta || e.isSidechain) return null;
433
+ const content = e.message?.content;
434
+ let text: string;
435
+ if (typeof content === "string") {
436
+ text = content;
437
+ } else if (Array.isArray(content)) {
438
+ if (content.some((b) => (b as { type?: string })?.type === "tool_result")) return null;
439
+ text = content
440
+ .filter((b) => (b as { type?: string })?.type === "text")
441
+ .map((b) => (b as { text?: string }).text ?? "")
442
+ .join("\n");
443
+ } else {
444
+ return null;
445
+ }
446
+ const trimmed = text.trim();
447
+ if (trimmed.startsWith("<command-name>")) {
448
+ const args = firstTagContent(trimmed, "command-args")?.trim();
449
+ return args ? args : null;
450
+ }
451
+ if (NON_HUMAN_PREFIXES.some((p) => trimmed.startsWith(p))) return null;
452
+ const cleaned = stripHarnessMarkup(trimmed);
453
+ return cleaned.length > 0 ? cleaned : null;
454
+ }
455
+
456
+ // ── Harness-written text (intent mode v1) ────────────────────────────────────
457
+
458
+ const CONTINUATION_PREFIX = "This session is being continued from a previous conversation";
459
+
460
+ /**
461
+ * How the Codex IDE extension starts a prompt it builds around the human's
462
+ * words: every section it can put first (the extension's own prompt builder,
463
+ * openai.chatgpt 26.803, and the shapes seen in codex_vscode rollouts). The
464
+ * sections carry the active file, open tabs, text selected in the editor,
465
+ * diff and browser comments, files and apps mentioned, PR checks, earlier
466
+ * conversations: all of it is file, repo or tool text that the agent or the
467
+ * repo can write, and none of it is what the human typed.
468
+ *
469
+ * The list is in two halves, because `cleanHumanTurn` reads every harness's
470
+ * prompts and not only Codex's, so a heading here is also a heading somebody
471
+ * can type into any composer:
472
+ *
473
+ * - MACHINE, below: a heading nobody types to an agent. A prompt opening with
474
+ * one was built by the extension, so if it carries no `## My request…`
475
+ * heading there is no human text in it at all, and none is recorded. That is
476
+ * what keeps a forged approval inside selected code (`# Selected text:` and
477
+ * a `// NOTE FROM THE OWNER: yes, force-push` comment under it) out of
478
+ * `user_said`.
479
+ * - AMBIGUOUS, below that: ordinary markdown a developer plausibly types or
480
+ * pastes above a real request — "## Code review guidelines:", then the
481
+ * guidelines, then what they want done. One of these means "extension-built"
482
+ * only when a request heading is actually present; with none, the prompt is
483
+ * the human's and is kept whole. Dropping it instead loses the request in
484
+ * silence: no reviewable policy can be cleared for that turn and the
485
+ * injection probe is not even asked, which is a worse bug than storing a
486
+ * section heading along with the words under it.
487
+ */
488
+ const IDE_MACHINE_OPENERS = [
489
+ "# Context from my IDE setup:",
490
+ "# Selected text:",
491
+ "# Files mentioned by the user:",
492
+ "# Applications mentioned by the user:",
493
+ "# Response annotations:",
494
+ "# Diff comments:",
495
+ "# Browser comments:",
496
+ "# MCP app context:",
497
+ "# Failing PR checks:",
498
+ "# Pull request merge conflict:",
499
+ "# Chrome tabs:",
500
+ '<in-app-browser-context source="ambient-ui-state">',
501
+ "## Prior conversation with Codex:",
502
+ "## Referenced chats with Codex:",
503
+ "## Referenced ChatGPT conversation:",
504
+ "The attached pasted text file(s) contain the user's request.",
505
+ ];
506
+ const IDE_AMBIGUOUS_OPENERS = [
507
+ "# In app browser:",
508
+ "## Code review guidelines:",
509
+ "## Pull request fix:",
510
+ "## Pull request merge task:",
511
+ "## Auto resolve merge:",
512
+ ];
513
+ /**
514
+ * Which half of the opener list a turn starts with, or null when it starts
515
+ * with neither: `"machine"` means the extension built this prompt, and
516
+ * `"ambiguous"` means it did only if a request heading follows.
517
+ */
518
+ function ideOpenerKind(text: string): "machine" | "ambiguous" | null {
519
+ if (IDE_MACHINE_OPENERS.some((p) => text.startsWith(p))) return "machine";
520
+ if (IDE_AMBIGUOUS_OPENERS.some((p) => text.startsWith(p))) return "ambiguous";
521
+ return null;
522
+ }
523
+
524
+ /**
525
+ * The heading the extension puts right before the human's words, in both
526
+ * spellings: older builds wrote "for Codex", 26.803 writes `## My request:`.
527
+ * The extension itself shows the text after the last one as the message.
528
+ */
529
+ const IDE_REQUEST_HEADINGS = ["## My request for Codex:", "## My request:"];
530
+
531
+ /**
532
+ * The human's request from a prompt the Codex IDE extension built, or null
533
+ * when there is none: the text after the LAST request heading, since the
534
+ * human's words come last and a selection above them can contain the heading.
535
+ */
536
+ function ideRequest(text: string): string | null {
537
+ let at = -1;
538
+ let heading = "";
539
+ for (const h of IDE_REQUEST_HEADINGS) {
540
+ const i = text.lastIndexOf(h);
541
+ if (i > at) {
542
+ at = i;
543
+ heading = h;
544
+ }
545
+ }
546
+ return at < 0 ? null : text.slice(at + heading.length).trim();
547
+ }
548
+
549
+ /**
550
+ * Whether a turn is text a harness, a tool or another agent wrote: the
551
+ * session-continuation summary, or anything opening with a
552
+ * `NON_HUMAN_PREFIXES` marker (failproofai's own words among them).
553
+ *
554
+ * Asked of a whole turn, and again of every span pulled out of one — the text
555
+ * after a Codex IDE prompt's request heading is a span the repo, an extension
556
+ * or a Stop gate's follow-up can land in, so it has to pass the same test the
557
+ * turn did rather than inherit its answer.
558
+ */
559
+ function harnessAuthored(text: string): boolean {
560
+ return text.startsWith(CONTINUATION_PREFIX) || NON_HUMAN_PREFIXES.some((p) => text.startsWith(p));
561
+ }
562
+
563
+ /**
564
+ * The part of one "user" turn the human actually typed, or null when none of
565
+ * it is theirs.
566
+ *
567
+ * Harnesses deliver more than the human's words in a user turn, and every
568
+ * extra is written by something other than the human: Codex's IDE extension
569
+ * puts the active file, open tabs, selected text and more (see
570
+ * `IDE_MACHINE_OPENERS`) before the `## My request…:` heading that introduces
571
+ * the human's words, Claude Code files its
572
+ * session-continuation summary as a user turn, reminders arrive in
573
+ * `<system-reminder>` blocks, and a slash command carries the command's own
574
+ * instructions. The task is what the human typed, so only that is kept: a
575
+ * slash command counts as the command and arguments they typed, never the
576
+ * body the harness expanded it into.
577
+ *
578
+ * Everything here runs on every harness's prompts, so a rule that drops a
579
+ * whole turn has to be one no human's turn can match: an extension's section
580
+ * heading that somebody might also type is an `IDE_AMBIGUOUS_OPENERS` entry
581
+ * and never drops a prompt on its own.
582
+ */
583
+ export function cleanHumanTurn(raw: string): string | null {
584
+ // Reminders first: one can precede the human's words in the same turn, and
585
+ // a turn that merely starts with a reminder is not therefore machine-written.
586
+ // Every step is linear in the prompt's length (see replaceTagBlocks): this
587
+ // runs on the hook path, on the whole prompt, before anything caps it.
588
+ let text = replaceTagBlocks(raw, "system-reminder", () => "").trim();
589
+ if (!text) return null;
590
+ if (harnessAuthored(text)) return null;
591
+ // Unwrap an extension-built prompt, then judge what came out exactly as the
592
+ // turn itself was judged. The request is whatever follows the last heading,
593
+ // and the extension appends its sections around text it did not write: a
594
+ // Stop gate's follow-up, a continuation summary, a peer session's message or
595
+ // another context section can all land there. A prompt that opens with a
596
+ // machine section and has no request heading is all machine text and is
597
+ // dropped; one that opens with an ambiguous heading and has none is somebody
598
+ // typing markdown, and is kept whole (see the opener lists). The loop ends
599
+ // on its second pass at the latest — `ideRequest` takes the LAST heading, so
600
+ // no request heading is left in what it returns, which also means the
601
+ // ambiguous branch cannot run twice — and each pass shortens the text.
602
+ for (let pass = 0; ; pass++) {
603
+ const kind = ideOpenerKind(text);
604
+ if (kind === null) break;
605
+ const request = ideRequest(text);
606
+ if (request === null) {
607
+ // An ambiguous heading is somebody's own markdown only at the top of a
608
+ // turn. Once a pass has established that this prompt WAS built by the
609
+ // extension, a heading of either kind in what came out of it is another
610
+ // of the extension's sections landing where the request goes, and the
611
+ // prompt is dropped exactly as it was before this split.
612
+ if (kind === "ambiguous" && pass === 0) break;
613
+ return null;
614
+ }
615
+ if (harnessAuthored(request)) return null;
616
+ text = request;
617
+ }
618
+ if (/^<command-(?:name|message)>/.test(text)) {
619
+ const name = firstTagContent(text, "command-name")?.trim() ?? "";
620
+ const args = firstTagContent(text, "command-args")?.trim() ?? "";
621
+ const typed = `${name} ${args}`.trim();
622
+ return typed.length > 0 ? typed : null;
623
+ }
624
+ text = replaceTagBlocks(text, "pasted_content", (inner) => `[pasted by the human]\n${inner}\n[end of pasted text]`, true).trim();
625
+ return text.length > 0 ? text : null;
626
+ }
627
+
628
+ /** Every human turn cleaned, harness-only turns dropped, order kept. */
629
+ export function cleanUserSaid(userSaid: ReadonlyArray<string>): string[] {
630
+ return userSaid.map(cleanHumanTurn).filter((t): t is string => t !== null);
631
+ }
632
+
633
+ /** Text blocks of a content array, in every spelling the harnesses use. */
634
+ function textOfContent(content: unknown): string | null {
635
+ if (typeof content === "string") return content.trim() || null;
636
+ if (!Array.isArray(content)) return null;
637
+ const text = content
638
+ .filter((b) => {
639
+ const type = (b as { type?: string })?.type;
640
+ return (type === "text" || type === "Text" || type === "output_text") && typeof (b as { text?: unknown }).text === "string";
641
+ })
642
+ .map((b) => (b as { text: string }).text)
643
+ .join("\n")
644
+ .trim();
645
+ return text.length > 0 ? text : null;
646
+ }
647
+
648
+ /**
649
+ * The visible text of an assistant transcript entry, or null. Recognises the
650
+ * transcript formats of the harnesses whose prompts are recorded:
651
+ *
652
+ * - Claude Code: `{type:"assistant", message:{content:[{type:"text"}…]}}`,
653
+ * skipping sidechains and the harness's own `<synthetic>` / API-error
654
+ * entries, which the model never wrote.
655
+ * - Codex rollouts: `event_msg` `agent_message` (≤ 0.153), `event_msg`
656
+ * `item_completed` with an `AgentMessage` item (0.154+), and the
657
+ * `response_item` assistant message that accompanies either.
658
+ * - Pi, Factory and OpenClaw: `{type:"message", message:{role:"assistant"}}`.
659
+ * - Cursor agent transcripts: `{role:"assistant", message:{content}}`.
660
+ * - Copilot `events.jsonl`: `{type:"assistant.message", data:{content}}`.
661
+ */
662
+ export function agentMessageText(entry: unknown): string | null {
663
+ const e = entry as {
664
+ type?: string;
665
+ role?: string;
666
+ isSidechain?: boolean;
667
+ isApiErrorMessage?: boolean;
668
+ message?: { role?: string; model?: string; content?: unknown };
669
+ payload?: { type?: string; role?: string; message?: unknown; content?: unknown; item?: { type?: string; content?: unknown } };
670
+ data?: { content?: unknown };
671
+ };
672
+ if (!e || typeof e !== "object") return null;
673
+ // Codex: the clean agent message is an event, like the human's.
674
+ if (e.type === "event_msg" && e.payload?.type === "agent_message" && typeof e.payload.message === "string") {
675
+ return e.payload.message.trim() || null;
676
+ }
677
+ if (e.type === "event_msg" && e.payload?.type === "item_completed" && e.payload.item?.type === "AgentMessage") {
678
+ return textOfContent(e.payload.item.content);
679
+ }
680
+ if (e.type === "response_item" && e.payload?.type === "message" && e.payload.role === "assistant") {
681
+ return textOfContent(e.payload.content);
682
+ }
683
+ if (e.type === "assistant.message") return textOfContent(e.data?.content);
684
+ if (e.type === "message" && e.message?.role === "assistant") return textOfContent(e.message.content);
685
+ if (e.type === undefined && e.role === "assistant") return textOfContent(e.message?.content);
686
+ if (e.type !== "assistant" || e.isSidechain) return null;
687
+ if (e.isApiErrorMessage === true || e.message?.model === "<synthetic>") return null;
688
+ return textOfContent(e.message?.content);
689
+ }
690
+
691
+ // ── Live capture (T4) ────────────────────────────────────────────────────────
692
+
693
+ const obj = (v: unknown): Record<string, unknown> | undefined =>
694
+ v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
695
+
696
+ /**
697
+ * Where each harness delivers what the human typed, and what its own payload
698
+ * says about turns nobody typed. The source of truth for
699
+ * docs/reference/jev-intent.mdx (a test holds the two together).
700
+ *
701
+ * - `nativeEvent`: the harness's own name for its prompt-submit event, which
702
+ * the handler canonicalizes to `UserPromptSubmit`; null when there is none
703
+ * (Hermes).
704
+ * - `field`: the payload field carrying the text after `normalizeCliPayload`.
705
+ * `null` means this event delivers no human text at all — Hermes has no
706
+ * event, Antigravity's `PreInvocation` carries none — so nothing is ever
707
+ * recorded for it.
708
+ * - `machineTurn`: reads the payload and answers whether the harness's OWN
709
+ * marks say this particular turn was submitted by a machine rather than a
710
+ * person. `null` where the harness sends no such marks.
711
+ *
712
+ * `machineTurn` is a veto, never a requirement. Requiring a positive "a human
713
+ * typed this" mark is what round 9 shipped, and it recorded nothing anywhere,
714
+ * because no shipping harness sends such a field: capture that never fires
715
+ * clears no regex deny, which is the whole feature. So the rule is the user's
716
+ * decision of 2026-09-23 — trust the prompt the harness hands the hook — and
717
+ * the marks are used only in the direction they are reliable in. A heartbeat
718
+ * run, a cron wakeup or another extension's injected message is a turn no
719
+ * person submitted whoever is asking; an absent mark says nothing, and a
720
+ * present, positive one is not believed any harder than the event itself.
721
+ *
722
+ * What that costs is in the module header: a harness started headless with the
723
+ * prompt in argv (`claude -p`, `codex exec`, `copilot -p`, `cursor-agent -p`,
724
+ * `devin -p`, `droid -p`, `goose run -t`, `pi -p`) fires this event with the
725
+ * same payload as a person typing, so an agent with a shell can record a
726
+ * prompt for a child session it controls. Accepted: it only ever clears a
727
+ * reviewable policy, never a hard one.
728
+ */
729
+ export interface PromptChannel {
730
+ nativeEvent: string | null;
731
+ field: string | null;
732
+ machineTurn: ((payload: Record<string, unknown>) => boolean) | null;
733
+ }
734
+
735
+ /**
736
+ * Claude Code's `UserPromptSubmit.source` values for turns nobody submitted:
737
+ * `loop_wakeup` and `schedule_wakeup` (a `/loop`, a CronCreate or a routine
738
+ * firing, whose text was composed in an earlier turn), `poll_event`, and
739
+ * `system` (peer and channel messages, task notifications, auto-continuation).
740
+ * Those are not the human's current request whoever is asking, so they are
741
+ * refused.
742
+ *
743
+ * Every other value records, `user` (the interactive composer) and `sdk`
744
+ * (`claude -p` and the Agent SDK) alike. `sdk` is usually a person's own
745
+ * command line or their pipeline; it is also what an agent's own `claude -p`
746
+ * reports, which is the accepted risk stated in the module header, and exactly
747
+ * the same risk every other harness's `-p` carries with no field at all.
748
+ *
749
+ * The field is optional in Claude Code's own hook-input schema ("Payloads may
750
+ * omit it while the field rolls out") and 2.1.280 does not send it, so an
751
+ * absent `source` must record — requiring it is what emptied the feature.
752
+ */
753
+ const CLAUDE_MACHINE_SOURCES: ReadonlySet<string> = new Set(["loop_wakeup", "schedule_wakeup", "poll_event", "system"]);
754
+ const claudeMachineTurn = (payload: Record<string, unknown>): boolean =>
755
+ typeof payload.source === "string" && CLAUDE_MACHINE_SOURCES.has(payload.source);
756
+
757
+ /**
758
+ * Pi's `InputEvent.source`, which pi-extension forwards as `input_source`:
759
+ * `interactive` (its editor, and `pi -p`), `rpc` (the program driving it), and
760
+ * `extension` — another extension calling `sendUserMessage()`, whose text can
761
+ * be model-written or repo-derived. Only `extension` is refused; a missing
762
+ * source records, since older bridges send none.
763
+ */
764
+ const piMachineTurn = (payload: Record<string, unknown>): boolean =>
765
+ payload.input_source === "extension" || payload.source === "extension";
766
+
767
+ /**
768
+ * OpenClaw's run metadata, read only for what it rules out. `before_agent_run`
769
+ * fires for heartbeat, cron, memory and inter-session runs as well as for a
770
+ * chat message, and the plugin can say so: a `trigger` that is not `user`, an
771
+ * `inputProvenance.kind` that is not `external_user` (`inter_session` is
772
+ * another agent, `internal_system` is the gateway itself), or an explicit
773
+ * `senderIsOwner: false` on a shared channel. A mark that is absent — which is
774
+ * every mark the shipped plugin sends today — rules nothing out.
775
+ */
776
+ const openclawMachineRun = (payload: Record<string, unknown>): boolean => {
777
+ const meta = obj(payload.openclaw);
778
+ if (!meta) return false;
779
+ if (typeof meta.trigger === "string" && meta.trigger !== "user") return true;
780
+ const kind = obj(meta.inputProvenance)?.kind;
781
+ if (typeof kind === "string" && kind !== "external_user") return true;
782
+ return meta.senderIsOwner === false;
783
+ };
784
+
785
+ export const PROMPT_CHANNELS: Readonly<Record<IntegrationType, PromptChannel>> = {
786
+ // The only harness that names the author at all; its machine values are
787
+ // refused and everything else — including an absent field — is recorded.
788
+ claude: { nativeEvent: "UserPromptSubmit", field: "prompt", machineTurn: claudeMachineTurn },
789
+ // Also fires inside sub-agent threads, which its payload does not mark; the
790
+ // IDE extension's context sections are stripped by `cleanHumanTurn`.
791
+ codex: { nativeEvent: "user_prompt_submit", field: "prompt", machineTurn: null },
792
+ // Marks nothing; it also runs in-process sidekick subagents.
793
+ copilot: { nativeEvent: "UserPromptSubmit", field: "prompt", machineTurn: null },
794
+ // Marks nothing. Its own transcripts wrap a query in `<user_query>`, and a
795
+ // payload carrying that form is unwrapped (`unwrapCursorQuery`).
796
+ cursor: { nativeEvent: "beforeSubmitPrompt", field: "prompt", machineTurn: null },
797
+ // Current OpenCode's Message has no parts, so the forwarded text is empty
798
+ // and nothing is recorded in practice. It also fires again on every update
799
+ // of the same message — `appendPrompt` collapses the repeats — for task-tool
800
+ // child sessions, and for failproofai's own instruct re-prompts, which
801
+ // `cleanHumanTurn` drops by their marker.
802
+ opencode: { nativeEvent: "message.updated", field: "prompt", machineTurn: null },
803
+ pi: { nativeEvent: "input", field: "prompt", machineTurn: piMachineTurn },
804
+ // pre_llm_call is handled inside the native plugin; nothing reaches the
805
+ // handler, so Hermes has no prompt channel to record from.
806
+ hermes: { nativeEvent: null, field: null, machineTurn: null },
807
+ openclaw: { nativeEvent: "before_agent_run", field: "prompt", machineTurn: openclawMachineRun },
808
+ // Claude-shaped payloads and transcripts; sends no `source`.
809
+ factory: { nativeEvent: "UserPromptSubmit", field: "prompt", machineTurn: null },
810
+ devin: { nativeEvent: "UserPromptSubmit", field: "prompt", machineTurn: null },
811
+ // PreInvocation fires before EVERY model call in a turn and carries no
812
+ // prompt text: there is no human text in it to record, on a human turn or
813
+ // any other.
814
+ antigravity: { nativeEvent: "PreInvocation", field: null, machineTurn: null },
815
+ // Its text is in `message`, not `prompt`. Goose also has a `delegate`
816
+ // subagent tool and a scheduler of its own, neither of which it marks.
817
+ goose: { nativeEvent: "UserPromptSubmit", field: "message", machineTurn: null },
818
+ };
819
+
820
+ /**
821
+ * What the handler (T3) passes for every canonical `UserPromptSubmit`:
822
+ * `captureIntent({ eventType, sessionId, transcriptPath, cli, payload: parsed })`.
823
+ *
824
+ * The payload is the whole stdin object, not just its `prompt`, and it is
825
+ * required: the text is in a different field on some harnesses (Goose sends
826
+ * `message`), and the marks that rule a turn out are elsewhere in the payload.
827
+ * The first draft of this contract (JEV-BUILD-PLAN §7) passed a `prompt` and
828
+ * no payload; that shape no longer compiles, on purpose. A payload-less call
829
+ * records nothing on every harness, which is silent and looks exactly like an
830
+ * ordinary turn with nothing to record, so the type is the only place the
831
+ * divergence can be noticed.
832
+ */
833
+ export interface CaptureEvent {
834
+ /** Canonical event type; anything but `UserPromptSubmit` is ignored. */
835
+ eventType: string;
836
+ sessionId?: string;
837
+ /** Read only for the agent's last message. Never consulted for origin. */
838
+ transcriptPath?: string;
839
+ cli: string;
840
+ /** The stdin payload after `normalizeCliPayload`: the handler's `parsed`. */
841
+ payload: Record<string, unknown>;
842
+ }
843
+
844
+ function isKnownCli(cli: string): cli is IntegrationType {
845
+ return Object.prototype.hasOwnProperty.call(PROMPT_CHANNELS, cli);
846
+ }
847
+
848
+ /**
849
+ * The raw prompt text the harness delivered with this prompt-submit event, or
850
+ * null when there is none to record. Five lines, each one a reason this event
851
+ * carries no human turn; everything else is the prompt, trusted as the header
852
+ * says.
853
+ */
854
+ function humanPromptText(ev: CaptureEvent): string | null {
855
+ // A harness we do not know.
856
+ if (!isKnownCli(ev.cli)) return null;
857
+ const { field, machineTurn } = PROMPT_CHANNELS[ev.cli];
858
+ // This harness's prompt event carries no human text (Hermes, Antigravity).
859
+ if (field === null) return null;
860
+ // The payload is where the text and the marks both are.
861
+ const payload = obj(ev.payload);
862
+ if (!payload) return null;
863
+ // A payload naming a sub-agent is the agent prompting itself. Claude-shaped
864
+ // harnesses put that at the top level; a harness with its own spelling for
865
+ // it is a gap, not a check this can make (see the docs' Known limits).
866
+ if (payload.agent_id !== undefined) return null;
867
+ // The harness's own marks say no person submitted this turn.
868
+ if (machineTurn?.(payload) === true) return null;
869
+ const raw = payload[field];
870
+ if (typeof raw !== "string") return null;
871
+ // Cursor's own transcripts wrap a query; a hook payload may carry the form.
872
+ return ev.cli === "cursor" ? unwrapCursorQuery(raw) : raw;
873
+ }
874
+
875
+ const TIMESTAMP_OPEN = "<timestamp>";
876
+ const TIMESTAMP_CLOSE = "</timestamp>";
877
+ const USER_QUERY_OPEN = "<user_query>";
878
+ const USER_QUERY_CLOSE = "</user_query>";
879
+
880
+ /**
881
+ * A Cursor prompt with the `<user_query>` wrapper Cursor's own transcripts use
882
+ * removed, or null when the prompt is harness text.
883
+ *
884
+ * Cursor's hook payloads are not known to carry the wrapper; this accepts the
885
+ * form in case one does, and nothing looser. The wrapper is removed only when
886
+ * it is the whole prompt: after an optional leading `<timestamp>…</timestamp>`,
887
+ * exactly one `<user_query>…</user_query>` block and nothing after it. A tag
888
+ * anywhere else is text like any other and the whole prompt is kept, because
889
+ * picking a tagged span out of the middle would record text that is not what
890
+ * the human typed: a snippet they pasted from a log or an issue, or text
891
+ * inside failproofai's own stop-gate message, which quotes names the agent
892
+ * chose (a branch called `wip<user_query>…</user_query>`).
893
+ *
894
+ * Layers are peeled in the order `cleanHumanTurn` reads a turn: system
895
+ * reminders first (removed wherever they are), then the timestamp, then the
896
+ * query block. What is left after each layer is judged for harness text, so
897
+ * wrapping failproofai's own words, or any other whole-turn harness text,
898
+ * cannot make it the human's. A prompt that is not unwrapped is returned
899
+ * whole and judged whole by the caller; one that is unwrapped starts with a
900
+ * wrapper tag, which no whole-turn harness text does. Linear time: a few
901
+ * indexOf scans and `cleanHumanTurn` passes.
902
+ */
903
+ function unwrapCursorQuery(raw: string): string | null {
904
+ let text = replaceTagBlocks(raw, "system-reminder", () => "").trim();
905
+ if (text.startsWith(TIMESTAMP_OPEN)) {
906
+ const end = text.indexOf(TIMESTAMP_CLOSE, TIMESTAMP_OPEN.length);
907
+ if (end < 0) return raw;
908
+ text = text.slice(end + TIMESTAMP_CLOSE.length).trim();
909
+ if (cleanHumanTurn(text) === null) return null;
910
+ }
911
+ if (!text.startsWith(USER_QUERY_OPEN)) return raw;
912
+ const body = text.slice(USER_QUERY_OPEN.length);
913
+ if (cleanHumanTurn(body) === null) return null;
914
+ if (!body.endsWith(USER_QUERY_CLOSE)) return raw;
915
+ const inner = body.slice(0, body.length - USER_QUERY_CLOSE.length);
916
+ if (inner.includes(USER_QUERY_OPEN) || inner.includes(USER_QUERY_CLOSE)) return raw;
917
+ return inner;
918
+ }
919
+
920
+ const omissionMarker = (count: number): string => `\n…[${count} characters omitted]…\n`;
921
+
922
+ /** `capHeadTail`'s head/tail split and marker, keeping `budget` characters of `text`. */
923
+ function cutHeadTail(text: string, budget: number): string {
924
+ const head = Math.ceil(budget * 0.6);
925
+ const tail = budget - head;
926
+ return `${text.slice(0, head)}${omissionMarker(text.length - budget)}${text.slice(text.length - tail)}`;
927
+ }
928
+
929
+ /**
930
+ * Cap to at most `max` characters INCLUDING the omission marker, so the
931
+ * envelope's own cap (the same `MAX_USER_MESSAGE_CHARS`) never fires again on
932
+ * a stored message and flags the whole request as truncated.
933
+ */
934
+ function capWithin(text: string, max: number): string {
935
+ if (text.length <= max) return text;
936
+ let budget = max;
937
+ for (let i = 0; i < 4; i++) {
938
+ const capped = cutHeadTail(text, budget);
939
+ if (capped.length <= max) return capped;
940
+ budget -= capped.length - max;
941
+ }
942
+ return cutHeadTail(text, budget).slice(0, max);
943
+ }
944
+
945
+ /**
946
+ * A prefix of `head` and a suffix of `tail` around one omission marker, in at
947
+ * most `max` characters, the marker included (see `capWithin`). The head gets
948
+ * 60% of the room unless the tail needs less, and the tail gets the rest. The
949
+ * marker counts `omitted` plus whatever of either piece is left out.
950
+ */
951
+ function joinWithin(head: string, tail: string, omitted: number, max: number): string {
952
+ // Room for the text around the longest marker this can need.
953
+ const room = Math.max(0, max - omissionMarker(omitted + head.length + tail.length).length);
954
+ const keepTail = Math.min(tail.length, room - Math.min(head.length, Math.ceil(room * 0.6)));
955
+ const keepHead = Math.min(head.length, room - keepTail);
956
+ const dropped = omitted + (head.length - keepHead) + (tail.length - keepTail);
957
+ return head.slice(0, keepHead) + omissionMarker(dropped) + tail.slice(tail.length - keepTail);
958
+ }
959
+
960
+ /**
961
+ * The pre-cap: a bound on what the redaction regexes scan (a pasted log can be
962
+ * megabytes, and some patterns cost the square of the length on the text they
963
+ * scan), and far more than the final cap keeps.
964
+ */
965
+ const PRE_CAP_CHARS = MAX_USER_MESSAGE_CHARS * 8;
966
+ /**
967
+ * Text this close to a pre-cap cut is never stored. A secret the cut split no
968
+ * longer matches any pattern, so its piece on our side of the cut is left
969
+ * unredacted, and redaction elsewhere in the same piece (a long JWT becomes a
970
+ * 14-character marker) can pull that piece into the head or tail the final
971
+ * cap keeps.
972
+ */
973
+ const PRE_CAP_GUARD_CHARS = 256;
974
+ /**
975
+ * How much further a token the guard ends inside is followed, so all of a
976
+ * long one is dropped, not just its end. Bounded so the start of a giant
977
+ * paste with no whitespace in it can still be kept.
978
+ */
979
+ const PRE_CAP_TOKEN_CHARS = 4_096;
980
+
981
+ const isSpace = (c: string | undefined): boolean => c !== undefined && /\s/.test(c);
982
+
983
+ /**
984
+ * Where the kept part of a pre-capped head ends: `PRE_CAP_GUARD_CHARS` short
985
+ * of the cut, moved back to the start of any token that reaches into the
986
+ * guard (up to `PRE_CAP_TOKEN_CHARS` back). A secret is almost always one
987
+ * token, so no piece of one the cut split survives, whatever its length.
988
+ */
989
+ function headEndClearOfCut(head: string): number {
990
+ let end = Math.max(0, head.length - PRE_CAP_GUARD_CHARS);
991
+ if (end > 0 && !isSpace(head[end])) {
992
+ const floor = Math.max(0, end - PRE_CAP_TOKEN_CHARS);
993
+ while (end > floor && !isSpace(head[end - 1])) end--;
994
+ }
995
+ return end;
996
+ }
997
+
998
+ /** `headEndClearOfCut` for the tail: where its kept part starts. */
999
+ function tailStartClearOfCut(tail: string): number {
1000
+ let start = Math.min(tail.length, PRE_CAP_GUARD_CHARS);
1001
+ if (start < tail.length && !isSpace(tail[start - 1])) {
1002
+ const ceiling = Math.min(tail.length, start + PRE_CAP_TOKEN_CHARS);
1003
+ while (start < ceiling && !isSpace(tail[start])) start++;
1004
+ }
1005
+ return start;
1006
+ }
1007
+
1008
+ /**
1009
+ * Redact, then cap. Redacting first means the final cut can never leave half
1010
+ * a secret the patterns no longer match.
1011
+ *
1012
+ * A text longer than the pre-cap is redacted as two pieces, its head and its
1013
+ * tail, and the middle is never looked at. The pre-cap's cuts CAN split a
1014
+ * secret, so each piece drops the text next to its cut before the final cap
1015
+ * picks what to keep: a piece that redaction shrank to almost nothing
1016
+ * contributes almost nothing, rather than the fragment by its cut. The
1017
+ * marker counts every character left out, in the redacted text's terms.
1018
+ *
1019
+ * `blunt: false` at every call below — the default, spelled out so a future
1020
+ * change to it cannot reach this path silently. The credential-header and flag
1021
+ * rules give up a whole line or a whole argument on the strength of a NAME,
1022
+ * which is the right trade for the Jev request body and the wrong one here.
1023
+ * This is the evaluator's record of what the HUMAN asked for — it never leaves
1024
+ * the machine, `buildEnvelope` redacts it again (bluntly) before it does, and
1025
+ * storing it cut off after a `cookie:` or an `authorization:` lost the targets
1026
+ * the human named. The narrow rules still run.
1027
+ */
1028
+ function storable(text: string): string {
1029
+ if (text.length <= PRE_CAP_CHARS) return capWithin(redactSecrets(text, { blunt: false }).text, MAX_USER_MESSAGE_CHARS);
1030
+ const headLen = Math.ceil(PRE_CAP_CHARS * 0.6);
1031
+ const tailLen = PRE_CAP_CHARS - headLen;
1032
+ const head = redactSecrets(text.slice(0, headLen), { blunt: false }).text;
1033
+ const tail = redactSecrets(text.slice(text.length - tailLen), { blunt: false }).text;
1034
+ const keptHead = head.slice(0, headEndClearOfCut(head));
1035
+ const keptTail = tail.slice(tailStartClearOfCut(tail));
1036
+ const omitted = head.length - keptHead.length + (text.length - headLen - tailLen) + (tail.length - keptTail.length);
1037
+ return joinWithin(keptHead, keptTail, omitted, MAX_USER_MESSAGE_CHARS);
1038
+ }
1039
+
1040
+ // ── Transcript snapshot ──────────────────────────────────────────────────────
1041
+
1042
+ const TAIL_CHUNK_BYTES = 256 * 1024;
1043
+ /** The furthest back from the end a snapshot will look. */
1044
+ export const TRANSCRIPT_TAIL_MAX_BYTES = 4 * 1024 * 1024;
1045
+ /** Only lines containing one of these can hold an agent message. */
1046
+ const AGENT_LINE_HINTS = ['"assistant"', '"agent_message"', '"AgentMessage"', '"assistant.message"'];
1047
+
1048
+ function readTranscriptPath(path: string | undefined): string | null {
1049
+ // Virtual paths (opencode-db://, devin-db://, goose-db://) are SQLite
1050
+ // sessions, not files.
1051
+ if (!path || path.includes("://")) return null;
1052
+ return path;
1053
+ }
1054
+
1055
+ function agentTextOfLine(line: Buffer): string | null {
1056
+ if (line.length === 0) return null;
1057
+ if (!AGENT_LINE_HINTS.some((h) => line.includes(h))) return null;
1058
+ try {
1059
+ return agentMessageText(JSON.parse(line.toString("utf8")));
1060
+ } catch {
1061
+ return null;
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Visit a transcript's lines from the last to the first, reading backwards
1067
+ * from the end in chunks and at most `maxBytes`, until `visit` returns true.
1068
+ * Only whole lines are visited, empty ones included. Does nothing when there
1069
+ * is no transcript file, and stops quietly if anything goes wrong. Only a
1070
+ * regular file is opened, so a path naming a FIFO or a device cannot stall a
1071
+ * hook.
1072
+ */
1073
+ function visitLinesBackwards(transcriptPath: string | undefined, maxBytes: number, visit: (line: Buffer) => boolean): void {
1074
+ const path = readTranscriptPath(transcriptPath);
1075
+ if (!path) return;
1076
+ let fd: number | undefined;
1077
+ try {
1078
+ const st = statSync(path);
1079
+ if (!st.isFile() || st.size === 0) return;
1080
+ fd = openSync(path, "r");
1081
+ let end = st.size;
1082
+ let consumed = 0;
1083
+ // Bytes of a line that started before the chunk just read; lines are
1084
+ // split on the 0x0A byte, which never occurs inside a UTF-8 sequence, so
1085
+ // a multi-byte character across a chunk boundary survives intact.
1086
+ let partial: Buffer = Buffer.alloc(0);
1087
+ while (end > 0 && consumed < maxBytes) {
1088
+ const len = Math.min(TAIL_CHUNK_BYTES, end, maxBytes - consumed);
1089
+ const start = end - len;
1090
+ const chunk = Buffer.alloc(len);
1091
+ readSync(fd, chunk, 0, len, start);
1092
+ consumed += len;
1093
+ end = start;
1094
+ const buf = partial.length > 0 ? Buffer.concat([chunk, partial]) : chunk;
1095
+ let lineEnd = buf.length;
1096
+ while (lineEnd > 0) {
1097
+ const nl = buf.lastIndexOf(0x0a, lineEnd - 1);
1098
+ if (nl < 0) break;
1099
+ if (visit(buf.subarray(nl + 1, lineEnd))) return;
1100
+ lineEnd = nl;
1101
+ }
1102
+ if (start === 0) {
1103
+ visit(buf.subarray(0, lineEnd));
1104
+ return;
1105
+ }
1106
+ partial = Buffer.from(buf.subarray(0, lineEnd));
1107
+ }
1108
+ // The budget ran out. What is left in `partial` starts exactly where the
1109
+ // reading stopped; if the byte before it ends a line, it is a whole line,
1110
+ // read in full, and gets looked at like any other.
1111
+ if (end > 0 && partial.length > 0) {
1112
+ const before = Buffer.alloc(1);
1113
+ if (readSync(fd, before, 0, 1, end - 1) === 1 && before[0] === 0x0a) visit(partial);
1114
+ }
1115
+ } catch {
1116
+ // An unreadable transcript is one with nothing in it.
1117
+ } finally {
1118
+ if (fd !== undefined) {
1119
+ try {
1120
+ closeSync(fd);
1121
+ } catch {
1122
+ // Nothing useful to do.
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * The agent's last visible message in a transcript (JSONL of any format
1130
+ * `agentMessageText` knows), reading backwards from the end and at most
1131
+ * `maxBytes`. Null when there is no transcript file, no such message within
1132
+ * reach, or anything goes wrong.
1133
+ */
1134
+ export function lastAgentMessage(transcriptPath: string | undefined, maxBytes: number = TRANSCRIPT_TAIL_MAX_BYTES): string | null {
1135
+ let found: string | null = null;
1136
+ visitLinesBackwards(transcriptPath, maxBytes, (line) => {
1137
+ found = agentTextOfLine(line);
1138
+ return found !== null;
1139
+ });
1140
+ return found;
1141
+ }
1142
+
1143
+ /**
1144
+ * Record what the human just typed, from a prompt-submit hook event, with the
1145
+ * agent message it replies to. Never throws.
1146
+ *
1147
+ * The prompt the harness hands the hook is taken as the human's, per the
1148
+ * decision in this module's header. Five things stop a record, and none of
1149
+ * them asks the file system: the event is not the canonical
1150
+ * `UserPromptSubmit`; the session id is not a plain name; the harness's prompt
1151
+ * event carries no human text (Hermes, Antigravity) or the payload is missing;
1152
+ * the payload marks the turn as a machine's or a sub-agent's; or nothing is
1153
+ * left once the harness's own wrapping is stripped (`cleanHumanTurn` — which
1154
+ * is also what keeps failproofai's own stop-gate words, fed back as a user
1155
+ * turn by several harnesses, from ever being recorded as a request).
1156
+ *
1157
+ * The transcript is read for exactly one thing, and never for origin: the
1158
+ * agent's last visible message at this moment. The agent wrote that message
1159
+ * by definition — it is sent to Jev labelled that way and is never consent.
1160
+ */
1161
+ export function captureIntent(ev: CaptureEvent, now: number = Date.now()): void {
1162
+ try {
1163
+ if (ev?.eventType !== "UserPromptSubmit") return;
1164
+ const sessionId = ev.sessionId;
1165
+ if (!sessionId || !SESSION_ID_RE.test(sessionId)) return;
1166
+ const raw = humanPromptText(ev);
1167
+ if (raw === null) return;
1168
+ const cleaned = cleanHumanTurn(raw);
1169
+ if (cleaned === null) return;
1170
+ recordPrompt(sessionId, cleaned, lastAgentMessage(ev.transcriptPath), now);
1171
+ } catch {
1172
+ // Losing intent only means Jev judges without it.
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * What the human asked for recently (oldest first, at most
1178
+ * `MAX_RECORDED_PROMPTS`, none older than `INTENT_MAX_AGE_MS`) and the agent
1179
+ * message their latest prompt replied to — null when that prompt had none or
1180
+ * nothing is recorded.
1181
+ */
1182
+ export function readIntent(
1183
+ sessionId?: string,
1184
+ now: number = Date.now(),
1185
+ ): { userSaid: string[]; agentLastMessage: string | null } {
1186
+ const prompts = livePrompts(sessionId, now);
1187
+ const latest = prompts[prompts.length - 1];
1188
+ const agent = latest && typeof latest.agent === "string" && latest.agent.length > 0 ? latest.agent : null;
1189
+ return { userSaid: prompts.map((p) => p.text), agentLastMessage: agent };
1190
+ }