failproofai 1.0.12-beta.0 → 1.0.12-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) 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.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js +22 -22
  23. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  31. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  32. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/index.html +1 -1
  35. package/.next/standalone/.next/server/app/index.rsc +3 -3
  36. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  37. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  39. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  43. package/.next/standalone/.next/server/app/policies/page.js +4 -4
  44. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  54. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  57. package/.next/standalone/.next/server/app/settings/page.js +7 -7
  58. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/{[externals]__0-ha5ydqq0mws._.js → [externals]__0vt9i6fqinjne._.js} +1 -1
  61. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__20xe05hm5u8_1._.js → [root-of-the-server]__0f_4g-vazrvxv._.js} +1 -1
  62. package/.next/standalone/.next/server/chunks/_0hf5q4xzyg40j._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/_0tovk6q0miud6._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/_19011-u44143j._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/_1ek68lnh2daur._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/{_0kc-piux3hady._.js → _1y590mjzpkbos._.js} +2 -2
  68. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/src_hooks_0_em82s16zfym._.js +5 -0
  70. package/.next/standalone/.next/server/chunks/src_hooks_builtin-policies_ts_1yq5ybgx1v98e._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3h94wnn._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/src_hooks_daemon-client_ts_0il_yu1zc0291._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn_4twyh._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ts0fg5yih1c1._.js → [root-of-the-server]__00l2u6j86cb8y._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1_xbg2kx30wp-._.js → [root-of-the-server]__0naevg4bcoh54._.js} +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pwj2h2efe5f-._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0vcyntk4snf0f._.js → [root-of-the-server]__1vofkb2oq7p7e._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/_0bseae9u7g98r._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/_0mouhaflye1ro._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/{_1gb0ifpkowwub._.js → _0ouu886szh6yj._.js} +10 -9
  91. package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/{_1ve8y151ua7qx._.js → _1fw0_nzkae5ao._.js} +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/_20hn4gn87nsjc._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/app_components_project-list_tsx_1ke6anr0vt64h._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_00jxj8foxqz5t.js → node_modules_html-to-image_es_index_1fv03uigni8s5.js} +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0janj0i6dqko7._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9ddabn3_._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0agdmptetetdt._.js +5 -0
  107. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0mdwgjisv4fub._.js +5 -0
  108. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4l1tzrm._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/src_hooks_daemon-client_ts_0k41_i7jg6ndv._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589gp4p60j._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me658zqmh_._.js +1 -1
  112. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  113. package/.next/standalone/.next/server/pages/404.html +1 -1
  114. package/.next/standalone/.next/server/pages/500.html +1 -1
  115. package/.next/standalone/.next/server/preview-props.json +3 -3
  116. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  117. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  118. package/.next/standalone/.next/static/chunks/{0pjxnp927y7_j.js → 0ay__-d2h1xb5.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{2znt1784j9gd9.js → 0tbm3vjm62h-2.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/1c_jf6g5wnslk.js +1 -0
  121. package/.next/standalone/.next/static/chunks/{3tn1bzk094e8e.js → 1my3-5p47t7r-.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/20py8k1gd_i1m.js +1 -0
  123. package/.next/standalone/.next/static/chunks/21hckpmsc4g70.js +6 -0
  124. package/.next/standalone/.next/static/chunks/2hmex93ppj1ke.js +1 -0
  125. package/.next/standalone/.next/static/chunks/2o6d37j425uci.js +1 -0
  126. package/.next/standalone/.next/static/chunks/40oworn_-x12p.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{2qz5piiir-qqm.js → 42xptz88mtq3x.js} +1 -1
  128. package/.next/standalone/app/actions/update-jev-config.ts +5 -3
  129. package/.next/standalone/app/settings/jev-panel.tsx +1 -1
  130. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +32 -0
  131. package/.next/standalone/fp-cloud-cli/README.md +40 -1
  132. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +91 -12
  133. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +78 -16
  134. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +84 -18
  135. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +124 -6
  136. package/.next/standalone/fp-cloud-cli/fp_cli/models.py +89 -6
  137. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +189 -20
  138. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +8 -5
  139. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +2 -2
  140. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +14 -7
  141. package/.next/standalone/fp-cloud-cli/tests/test_cloud_jev.py +527 -0
  142. package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +46 -0
  143. package/.next/standalone/fp-cloud-cli/tests/test_output.py +23 -0
  144. package/.next/standalone/hermes-plugin/README.md +6 -4
  145. package/.next/standalone/hermes-plugin/__init__.py +27 -0
  146. package/.next/standalone/hermes-plugin/client.py +5 -1
  147. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  148. package/.next/standalone/package.json +11 -10
  149. package/.next/standalone/sdk/python/uv.lock +31 -8
  150. package/.next/standalone/server.js +1 -1
  151. package/bin/failproofai.mjs +17 -1
  152. package/dist/cli.mjs +3507 -1250
  153. package/dist/worker.mjs +1779 -169
  154. package/hermes-plugin/README.md +6 -4
  155. package/hermes-plugin/__init__.py +27 -0
  156. package/hermes-plugin/client.py +5 -1
  157. package/hermes-plugin/plugin.yaml +1 -1
  158. package/package.json +11 -10
  159. package/src/audit/open-browser.ts +6 -7
  160. package/src/hooks/agent-roster.ts +180 -0
  161. package/src/hooks/agent-skill.ts +203 -0
  162. package/src/hooks/agent-targets.ts +60 -0
  163. package/src/hooks/cloud-connection.ts +91 -45
  164. package/src/hooks/cloud-enrollment-cli.ts +7 -4
  165. package/src/hooks/cloud-login.ts +6 -3
  166. package/src/hooks/cloud-managed-policies.ts +430 -20
  167. package/src/hooks/cloud-policy-errors.ts +481 -0
  168. package/src/hooks/codex-system-hooks.ts +10 -4
  169. package/src/hooks/config-status.ts +10 -4
  170. package/src/hooks/configure-wizard.ts +54 -26
  171. package/src/hooks/custom-hooks-loader.ts +26 -3
  172. package/src/hooks/daemon-client.ts +9 -4
  173. package/src/hooks/daemon-service.ts +2 -1
  174. package/src/hooks/delivery-health.ts +47 -2
  175. package/src/hooks/effective-reviewers.ts +36 -1
  176. package/src/hooks/fp-config.ts +19 -0
  177. package/src/hooks/fp-home.ts +6 -0
  178. package/src/hooks/handler.ts +255 -19
  179. package/src/hooks/health-probe.ts +15 -0
  180. package/src/hooks/hook-activity-store.ts +7 -1
  181. package/src/hooks/integrations.ts +36 -14
  182. package/src/hooks/jev-activity.ts +117 -1
  183. package/src/hooks/jev-cli.ts +196 -1
  184. package/src/hooks/jev-cloud-connection.ts +92 -0
  185. package/src/hooks/manager.ts +174 -34
  186. package/src/hooks/pack-failclosed.ts +2 -0
  187. package/src/hooks/pack-manifest.ts +8 -1
  188. package/src/hooks/policy-evaluator.ts +22 -1
  189. package/src/hooks/policy-registry.ts +3 -2
  190. package/src/hooks/policy-reviewability.ts +23 -5
  191. package/src/hooks/semantic/cloud-jev-health.ts +227 -0
  192. package/src/hooks/semantic/cloud-jev.ts +868 -0
  193. package/src/hooks/semantic/combine.ts +26 -1
  194. package/src/hooks/semantic/jev-client.ts +19 -2
  195. package/src/hooks/semantic/jev-config.ts +179 -30
  196. package/src/hooks/semantic/jev-review.ts +217 -10
  197. package/src/hooks/semantic/pack-policies.ts +2 -1
  198. package/src/hooks/semantic/types.ts +8 -0
  199. package/src/hooks/session-pause-cli.ts +1 -1
  200. package/src/hooks/tui.ts +13 -4
  201. package/src/hooks/uninstall-cli.ts +12 -1
  202. package/src/hooks/worker-server.ts +5 -1
  203. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0uhs2t1b._.js +0 -5
  204. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7cyqlb2._.js +0 -3
  205. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo55j16x._.js +0 -5
  206. package/.next/standalone/.next/static/chunks/0a-ccsp3xmypz.js +0 -1
  207. package/.next/standalone/.next/static/chunks/13jv-xvabeb62.js +0 -6
  208. package/.next/standalone/.next/static/chunks/2ank2u3apy8m6.js +0 -1
  209. package/.next/standalone/.next/static/chunks/2meqb9kygrncb.js +0 -1
  210. package/.next/standalone/.next/static/chunks/2ohktp_-lkv3x.js +0 -1
  211. package/.next/standalone/.next/static/chunks/2py0eldhwz8ay.js +0 -1
  212. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_buildManifest.js +0 -0
  213. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_clientMiddlewareManifest.js +0 -0
  214. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_ssgManifest.js +0 -0
@@ -0,0 +1,868 @@
1
+ /**
2
+ * FailproofAI Cloud Jev (CONTRACT C10): one tool call in, one verdict out, with
3
+ * the checks FailproofAI Cloud deployed to this machine decided ON CLOUD.
4
+ *
5
+ * Jev policies, and the Jev half of `both` policies, never reach the machine.
6
+ * When Cloud sets this machine's Jev mode to `observe` or `enforce`, every
7
+ * gated tool call is sent to `POST /enforcement/v1/jev/systemone` on the Cloud
8
+ * Jev credential as today's Jev request — the machine's own questions, which
9
+ * are the global intent questions (ALWAYS, even with no installed pack's check
10
+ * applying) plus its installed packs' per-policy questions — and a `cloud`
11
+ * block of tool-call metadata (`buildCloudBlock`). Cloud strips the block,
12
+ * adds this machine's deployed Cloud checks, asks TypeSafe once, decides its
13
+ * own checks with a port of `decide` / `decideV1`, and replies with every
14
+ * answer plus `cloud.verdict`.
15
+ *
16
+ * Here the machine decides its pack checks from the answers exactly as the
17
+ * local path does (minus any group Cloud dropped, `droppedLocal`) and merges
18
+ * the two verdicts (`mergeCloudVerdict`). The merged verdict then goes to
19
+ * `combine.ts` unchanged, via `toReview` in `jev-review.ts`.
20
+ *
21
+ * Never throws, and never guesses: anything short of a complete, well-formed
22
+ * reply — no transport, timeout, HTTP error, a reply without a valid `cloud`
23
+ * block, a missing answer — is `degraded`, and the combine keeps the regex
24
+ * result for that call (a `both` policy's regex half stays hard).
25
+ */
26
+ import { DEFAULT_JEV_MODEL, MAX_REQUEST_CHARS } from "./compile";
27
+ import { DEFAULT_THRESHOLDS, decide, decideV1, everyTargetNamed, partlyNamed, scanTargets, type TargetScan } from "./decide";
28
+ import { redactSecrets } from "./envelope";
29
+ import { classifyTool } from "./facts";
30
+ import { DEFAULT_JEV_TIMEOUT_MS, prepareSemantic, type PreparedCall, type SemanticOptions, type SemanticOutcome } from "./evaluator";
31
+ import { JevError, readAnswers, type JevTransport } from "./jev-client";
32
+ import type { JevProviderKind } from "./jev-config";
33
+ import { INJECTION_PROBE, SCOPE_PROBE, TASK_PROBES } from "./policies";
34
+ import { redactSecretsDetailed } from "./redact";
35
+ import type {
36
+ Facts,
37
+ IntentMode,
38
+ JevRequest,
39
+ JevResponse,
40
+ NoulQuestion,
41
+ PolicyOutcome,
42
+ SemanticInput,
43
+ SemanticPolicy,
44
+ SemanticVerdict,
45
+ } from "./types";
46
+ import type { AgentIdentity } from "../agent-targets";
47
+
48
+ /** The `cloud` block's version (CONTRACT C10.3). */
49
+ export const CLOUD_BLOCK_VERSION = 1;
50
+
51
+ /**
52
+ * How many characters of `targetScan.groups` (serialized) are sent. The one
53
+ * part of the block that grows with the tool input: an MCP call with thousands
54
+ * of string fields, or a huge command whose scan fell back to its raw words.
55
+ * Past it a prefix of the groups goes with `complete: false`, which is strictly
56
+ * safer — an incomplete scan clears nothing (`decide.ts`).
57
+ */
58
+ export const MAX_TARGET_SCAN_CHARS = 64_000;
59
+
60
+ /**
61
+ * The block's serialized UTF-8 bytes may not exceed this: Cloud answers a
62
+ * larger one with a 400 (256 KiB there), and a refused call is a fallback.
63
+ * Kept a little under Cloud's cap. The rest of the block is bounded by the
64
+ * envelope's own caps (three human turns of at most `MAX_USER_MESSAGE_CHARS`,
65
+ * one capped agent message, the capped facts); if it still does not fit, the
66
+ * human's turns are left out — the stricter direction, since no consent can
67
+ * then be read.
68
+ */
69
+ export const MAX_CLOUD_BLOCK_BYTES = 240_000;
70
+
71
+ const blockBytes = (block: CloudJevBlock): number => Buffer.byteLength(JSON.stringify(block), "utf8");
72
+
73
+ /** What a secret word in a target group becomes: no human's words can contain it. */
74
+ export const REDACTED_TARGET_WORD = "\u0000redacted";
75
+
76
+ /** The `cloud` block of a request (CONTRACT C10.3). */
77
+ export interface CloudJevBlock {
78
+ v: 1 | 2;
79
+ machineId: string;
80
+ agent?: AgentIdentity;
81
+ intentMode: IntentMode;
82
+ facts: Facts;
83
+ targetScan: { groups: string[][]; complete: boolean };
84
+ userSaid: string[];
85
+ agentLastMessage: string | null;
86
+ userSaidCut: boolean;
87
+ /** The machine's own per-policy question groups, in the order Cloud drops them from (last first). */
88
+ localPolicies: string[];
89
+ }
90
+
91
+ /** FailproofAI Cloud's reply block, validated. Its outcomes carry a pack-shaped `cloud:<id>` origin. */
92
+ export interface CloudJevReply {
93
+ /** The ENFORCED checks' verdict: an observe assignment's outcomes are in `observed`, not here. */
94
+ verdict: SemanticVerdict;
95
+ asked: string[];
96
+ droppedLocal: string[];
97
+ droppedCloud: string[];
98
+ /** What the checks of an `observe` assignment would have done (deny / instruct only). */
99
+ observed: ObservedCloudCheck[];
100
+ /** Every check of an `observe` assignment Cloud asked (its name). */
101
+ observedAsked: string[];
102
+ }
103
+
104
+ /**
105
+ * One `observe` Cloud assignment's would-have: recorded on the activity row's
106
+ * `observed` list like an observe JS policy's, never applied, never a clear.
107
+ * One per policy, its most severe check.
108
+ */
109
+ export interface ObservedCloudCheck {
110
+ cloudPolicyId: string;
111
+ cloudVersion: number;
112
+ check: string;
113
+ decision: "deny" | "instruct";
114
+ reason: string;
115
+ }
116
+
117
+ /** What the Cloud review adds to a `SemanticOutcome`, for the verdict log and the budget report. */
118
+ export interface CloudReplySummary {
119
+ asked: string[];
120
+ /** The machine's own groups Cloud dropped, with the pack that declared each (null when none did). */
121
+ droppedLocal: Array<{ name: string; packId: string | null }>;
122
+ droppedCloud: string[];
123
+ /**
124
+ * TypeSafe's answers to the Cloud checks' own questions (`<check>.<probe>`,
125
+ * `<check>.exempt`), for the activity row's per-check record. Ids and
126
+ * numbers only; the machine never decides from these (Cloud did).
127
+ */
128
+ answers: Record<string, number>;
129
+ /**
130
+ * Checks of WATCHED (observe-assigned) Cloud policies, decided apart into
131
+ * the reply's `observed` block: recorded on the activity row only. They
132
+ * never reach the verdict, any clear, or the merge.
133
+ */
134
+ observedOutcomes: PolicyOutcome[];
135
+ observed: ObservedCloudCheck[];
136
+ observedAsked: string[];
137
+ }
138
+
139
+ export type CloudSemanticOutcome =
140
+ | (Extract<SemanticOutcome, { status: "ok" }> & { cloud: CloudReplySummary })
141
+ | Extract<SemanticOutcome, { status: "degraded" }>;
142
+
143
+ export interface CloudSemanticOptions extends SemanticOptions {
144
+ /** This machine's FailproofAI Cloud machine id, from `credentials.json`. */
145
+ machineId: string;
146
+ /** Explicit null = v2 request with unresolved identity (no scoped checks). */
147
+ agent?: AgentIdentity | null;
148
+ /** Cloud policy ids whose Jev assignment on this machine is `observe` (`active.json` `jevPolicies`). */
149
+ observedPolicies?: ReadonlyArray<string>;
150
+ }
151
+
152
+ // ── The request ──────────────────────────────────────────────────────────────
153
+
154
+ function noul(probe: { instructions: string; criteria?: { true: string; false: string } }): NoulQuestion {
155
+ return {
156
+ type: "noul",
157
+ instructions: probe.instructions,
158
+ ...(probe.criteria ? { criteria: { ...probe.criteria } } : {}),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * The global intent questions a Cloud call ALWAYS carries, whatever the
164
+ * machine's own checks are — Cloud's checks are decided on them:
165
+ *
166
+ * - `injection`, always (`compile.ts` asks it on every call it sends);
167
+ * - v1: the three task probes whenever a human message was recorded, as
168
+ * `compileRequest` asks them;
169
+ * - v0: `scope` whenever a human message was recorded. `compileRequest` asks
170
+ * it only when a SELECTED policy may be overridden, and the machine cannot
171
+ * know whether a Cloud check may be — without it no Cloud check could ever
172
+ * be cleared in v0. `decide` reads `scope` only inside the override branch,
173
+ * so the extra question changes no local verdict.
174
+ */
175
+ export function cloudGlobalQuestions(intent: IntentMode, userSaidCount: number): Record<string, NoulQuestion> {
176
+ const questions: Record<string, NoulQuestion> = { [INJECTION_PROBE.id]: noul(INJECTION_PROBE) };
177
+ if (userSaidCount > 0) {
178
+ if (intent === "v1") for (const probe of TASK_PROBES) questions[probe.id] = noul(probe);
179
+ else questions[SCOPE_PROBE.id] = noul(SCOPE_PROBE);
180
+ }
181
+ return questions;
182
+ }
183
+
184
+ /**
185
+ * How much of the tool input's text is searched for secrets. The groups sent
186
+ * are cut at {@link MAX_TARGET_SCAN_CHARS} and come from the text in order
187
+ * (the shell scan itself reads far less), so this covers every word that can
188
+ * be sent, while a 1 MB heredoc does not cost the hook a second full pass.
189
+ */
190
+ const MAX_SECRET_SCAN_CHARS = 4 * MAX_TARGET_SCAN_CHARS;
191
+
192
+ /**
193
+ * Secret-shaped strings in the text `scanTargets` reads, lowercased like its
194
+ * words: the command, or — for any other tool — the string values it takes a
195
+ * target from (the same ≤300-character, non-content fields).
196
+ */
197
+ function secretWords(toolInput: Record<string, unknown>): string[] {
198
+ const text = (
199
+ typeof toolInput.command === "string"
200
+ ? toolInput.command
201
+ : Object.entries(toolInput)
202
+ .filter(([key, v]) => typeof v === "string" && v.length <= 300 && !/content|old_string|new_string|body|text|prompt/i.test(key))
203
+ .map(([, v]) => v as string)
204
+ .join("\n")
205
+ ).slice(0, MAX_SECRET_SCAN_CHARS);
206
+ try {
207
+ return redactSecretsDetailed(text, { blunt: false })
208
+ .found.map((s) => s.toLowerCase())
209
+ .filter((s) => s.length > 0);
210
+ } catch {
211
+ return [];
212
+ }
213
+ }
214
+
215
+ /**
216
+ * `scanTargets(toolInput)` as the `cloud` block carries it: each group's Set as
217
+ * a sorted array, `targets` left out (it is the groups' union).
218
+ *
219
+ * Two changes, both in the stricter direction only:
220
+ *
221
+ * - A word that is (part of) a secret the narrow redactor finds in the tool
222
+ * input is replaced by {@link REDACTED_TARGET_WORD}, which no human's words
223
+ * can contain. The envelope already keeps that secret out of what Jev reads;
224
+ * this keeps it out of the metadata too. The group is kept, so the call still
225
+ * names as many targets — one the human can no longer be matched against.
226
+ * - Past {@link MAX_TARGET_SCAN_CHARS}, a prefix of the groups with
227
+ * `complete: false`: an incomplete scan clears nothing.
228
+ */
229
+ export function cloudTargetScan(toolInput: Record<string, unknown>, scan: TargetScan = scanTargets(toolInput)): CloudJevBlock["targetScan"] {
230
+ const secrets = scan.groups.length > 0 ? secretWords(toolInput) : [];
231
+ const hidden = (word: string): boolean =>
232
+ secrets.some((s) => s === word || (word.length >= 8 && s.includes(word)) || (s.length >= 8 && word.includes(s)));
233
+ const groups: string[][] = [];
234
+ let chars = 2;
235
+ let complete = scan.complete;
236
+ for (const group of scan.groups) {
237
+ const words = [...new Set([...group].map((w) => (hidden(w) ? REDACTED_TARGET_WORD : w)))].sort();
238
+ const cost = JSON.stringify(words).length + 1;
239
+ if (chars + cost > MAX_TARGET_SCAN_CHARS) {
240
+ complete = false;
241
+ break;
242
+ }
243
+ chars += cost;
244
+ groups.push(words);
245
+ }
246
+ return { groups, complete };
247
+ }
248
+
249
+ /** The machine's facts with their strings passed through the narrow redactor, as the envelope sends them. */
250
+ function cloudFacts(facts: Facts): Facts {
251
+ const scrub = (s: string | null): string | null => (s === null ? null : redactSecrets(s, { blunt: false }).text);
252
+ return {
253
+ toolName: facts.toolName,
254
+ toolClass: facts.toolClass,
255
+ toolIsKnown: facts.toolIsKnown,
256
+ cwd: scrub(facts.cwd),
257
+ projectRoot: scrub(facts.projectRoot),
258
+ currentGitBranch: scrub(facts.currentGitBranch),
259
+ paths: facts.paths.map((p) => ({ asWritten: scrub(p.asWritten) ?? "", resolved: scrub(p.resolved) ?? "", relation: p.relation })),
260
+ permissionMode: facts.permissionMode,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * The `cloud` block for a prepared call (CONTRACT C10.3): what the local
266
+ * decider reads, so Cloud decides its checks on the same evidence.
267
+ *
268
+ * - `facts`: the machine's `Facts`, strings redacted as the envelope's are.
269
+ * - `targetScan`: {@link cloudTargetScan}.
270
+ * - `userSaid`: the human turns the envelope CARRIES, uncut (`prepared.userSaid`,
271
+ * cleaned in v1), each with secrets redacted by the rules the envelope uses
272
+ * for everything that leaves the machine (`blunt`), and each redaction marker
273
+ * then replaced by {@link REDACTED_SAID}, which has no letters: a marker's
274
+ * label ("GitHub personal access token", "assigned secret") would otherwise
275
+ * NAME a target the human never named.
276
+ * - `agentLastMessage`: the string the envelope SENT (v1), null in v0.
277
+ * - `userSaidCut`, `intentMode`: as the local decider takes them.
278
+ * - `localPolicies`: the names of this call's own per-policy question groups.
279
+ *
280
+ * Redaction changes the words Cloud's target checks compare, and a substring
281
+ * check can flip either way (a hidden target word no longer matches; a hidden
282
+ * human word no longer names one). So the checks Cloud runs on the block are
283
+ * run here on both the block and the unredacted data ({@link namingReadings});
284
+ * where they differ, the scan is sent `complete: false`, which withholds every
285
+ * clear and every softening of an overridable check — Cloud is then stricter
286
+ * than the local decider, never laxer.
287
+ */
288
+ export function buildCloudBlock(prepared: PreparedCall, input: SemanticInput, machineId: string, agent?: AgentIdentity | null): CloudJevBlock {
289
+ const scan = scanTargets(input.toolInput);
290
+ const block: CloudJevBlock = {
291
+ v: agent === undefined ? CLOUD_BLOCK_VERSION : 2,
292
+ machineId,
293
+ ...(agent ? { agent } : {}),
294
+ intentMode: prepared.intent,
295
+ facts: cloudFacts(prepared.facts),
296
+ targetScan: cloudTargetScan(input.toolInput, scan),
297
+ userSaid: prepared.userSaid.map((turn) => redactSecrets(turn, { blunt: true }).text.replace(REDACTION_MARKER, REDACTED_SAID)),
298
+ agentLastMessage: prepared.agentLastMessage,
299
+ userSaidCut: prepared.userSaidCut,
300
+ localPolicies: prepared.selected.map((p) => p.name),
301
+ };
302
+ // Bounded by the caps above in practice; this is the floor under them. The
303
+ // human's turns go first, then the target scan; each time the scan is marked
304
+ // incomplete, which clears nothing. `[""]`, not `[]`: it names nothing, but
305
+ // it still says a human spoke, so Cloud's beyond-the-task flag (which needs
306
+ // one) is not switched off by the size of the call.
307
+ if (blockBytes(block) > MAX_CLOUD_BLOCK_BYTES) {
308
+ block.userSaid = prepared.userSaid.length > 0 ? [""] : [];
309
+ block.userSaidCut = false;
310
+ block.targetScan = { ...block.targetScan, complete: false };
311
+ }
312
+ if (blockBytes(block) > MAX_CLOUD_BLOCK_BYTES) {
313
+ block.targetScan = { groups: [], complete: false };
314
+ }
315
+ if (
316
+ block.targetScan.complete &&
317
+ namingReadings(block.intentMode, scan.groups, prepared.userSaid, prepared.agentLastMessage) !==
318
+ namingReadings(
319
+ block.intentMode,
320
+ block.targetScan.groups.map((g) => new Set(g)),
321
+ block.userSaid,
322
+ block.agentLastMessage,
323
+ )
324
+ ) {
325
+ block.targetScan = { ...block.targetScan, complete: false };
326
+ }
327
+ return block;
328
+ }
329
+
330
+ /** A redaction marker (`<redacted:label>`, `redact.ts`) in the human's words. */
331
+ const REDACTION_MARKER = /<redacted:[^>]*>/g;
332
+
333
+ /** What a redaction marker in `userSaid` becomes: no letters, so it names no target. */
334
+ export const REDACTED_SAID = "\u0000";
335
+
336
+ /**
337
+ * Everything Cloud's port of `decide` / `decideV1` reads from the target groups
338
+ * and the human's words, as one comparable string: the group count, whether a
339
+ * human spoke, `everyTargetNamed`, and (v1) `partlyNamed` — over the human's
340
+ * words plus, in v1, the agent message they replied to, exactly as the deciders
341
+ * build their evidence.
342
+ */
343
+ function namingReadings(
344
+ intent: IntentMode,
345
+ groups: Set<string>[],
346
+ userSaid: ReadonlyArray<string>,
347
+ agentLastMessage: string | null,
348
+ ): string {
349
+ const scan: TargetScan = { targets: new Set(), groups, complete: true };
350
+ const said = intent === "v1" && agentLastMessage ? [...userSaid, agentLastMessage] : userSaid;
351
+ return [groups.length, userSaid.length > 0, everyTargetNamed(scan, said), intent === "v1" && partlyNamed(scan, said)].join();
352
+ }
353
+
354
+ /** The request sent to FailproofAI Cloud: today's, the global questions always, and the block. */
355
+ export function buildCloudRequest(prepared: PreparedCall, input: SemanticInput, machineId: string, agent?: AgentIdentity | null): JevRequest {
356
+ const own = prepared.compiled.request;
357
+ return {
358
+ model: own.model,
359
+ state: own.state,
360
+ questions: { ...own.questions, ...cloudGlobalQuestions(prepared.intent, prepared.userSaid.length) },
361
+ cloud: buildCloudBlock(prepared, input, machineId, agent) as unknown as Record<string, unknown>,
362
+ };
363
+ }
364
+
365
+ // ── The reply ────────────────────────────────────────────────────────────────
366
+
367
+ /** A Cloud policy id, as `cloud-managed-policies.ts` accepts one. */
368
+ const POLICY_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
369
+ /** Longest check name accepted back; declarations are far shorter. */
370
+ const MAX_NAME_CHARS = 256;
371
+ /** Most outcomes / names accepted back: 24 declarations per policy leaves this far out of reach. */
372
+ const MAX_LIST = 4096;
373
+ /**
374
+ * Longest Cloud reason kept (review n8). Cloud builds it from bounded titles
375
+ * and guidance, so this is never reached by a working server; past it the
376
+ * reason is cut rather than refused, since refusing would drop Cloud's verdict.
377
+ */
378
+ export const MAX_CLOUD_REASON_CHARS = 8_192;
379
+
380
+ function malformed(what: string): never {
381
+ throw new JevError("malformed", `FailproofAI Cloud's reply: ${what}`);
382
+ }
383
+
384
+ const isProbability = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
385
+ const isName = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= MAX_NAME_CHARS;
386
+
387
+ function nullableProbability(v: unknown, what: string): number | null {
388
+ if (v === null || v === undefined) return null;
389
+ if (!isProbability(v)) malformed(`${what} is not a probability`);
390
+ return v;
391
+ }
392
+
393
+ function names(v: unknown, what: string): string[] {
394
+ if (v === undefined || v === null) return [];
395
+ if (!Array.isArray(v) || v.length > MAX_LIST || !v.every(isName)) malformed(`${what} is not a list of names`);
396
+ return [...v];
397
+ }
398
+
399
+ const DECISIONS = new Set(["allow", "deny", "instruct"]);
400
+ const VERDICTS = new Set(["deny", "instruct", "overridden", "none"]);
401
+ const INTENTS = new Set(["op-requested", "task-step", "downgraded-task-step"]);
402
+
403
+ /** One Cloud outcome as parsed: the outcome, and what only the reply can say about it. */
404
+ interface ParsedCloudOutcome {
405
+ outcome: PolicyOutcome;
406
+ cloudPolicyId: string;
407
+ cloudVersion: number;
408
+ /** The assignment's effect, when Cloud says (`effect`); absent reads as `enforce`. */
409
+ observe: boolean;
410
+ /** Cloud's own line for this check, when it sends one. */
411
+ reason: string | null;
412
+ }
413
+
414
+ /** One Cloud outcome, rebuilt field by field so nothing but these fields crosses over. */
415
+ function parseOutcome(raw: unknown, i: number): ParsedCloudOutcome {
416
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) malformed(`outcome ${i} is not an object`);
417
+ const o = raw as Record<string, unknown>;
418
+ if (!isName(o.policy)) malformed(`outcome ${i} has no policy name`);
419
+ if (o.mode !== "deny" && o.mode !== "instruct") malformed(`outcome ${i} has no mode`);
420
+ if (!isProbability(o.evidence)) malformed(`outcome ${i} has no evidence`);
421
+ if (typeof o.targetNamedByUser !== "boolean" || typeof o.escalatedByInjection !== "boolean") {
422
+ malformed(`outcome ${i} is missing a flag`);
423
+ }
424
+ if (typeof o.verdict !== "string" || !VERDICTS.has(o.verdict)) malformed(`outcome ${i} has no verdict`);
425
+ if (o.intent !== undefined && (typeof o.intent !== "string" || !INTENTS.has(o.intent))) malformed(`outcome ${i} has an unknown intent`);
426
+ if (o.targetScanIncomplete !== undefined && o.targetScanIncomplete !== true) malformed(`outcome ${i} has a bad targetScanIncomplete`);
427
+ if (o.userCanOverride !== undefined && typeof o.userCanOverride !== "boolean") malformed(`outcome ${i} has a bad userCanOverride`);
428
+ const origin = o.origin as Record<string, unknown> | undefined;
429
+ if (
430
+ !origin ||
431
+ typeof origin !== "object" ||
432
+ typeof origin.cloudPolicyId !== "string" ||
433
+ !POLICY_ID_RE.test(origin.cloudPolicyId) ||
434
+ !Number.isSafeInteger(origin.cloudVersion) ||
435
+ (origin.cloudVersion as number) < 0
436
+ ) {
437
+ malformed(`outcome ${i} has no Cloud origin`);
438
+ }
439
+ if (o.effect !== undefined && o.effect !== "enforce" && o.effect !== "observe") malformed(`outcome ${i} has an unknown effect`);
440
+ if (o.reason !== undefined && o.reason !== null && typeof o.reason !== "string") malformed(`outcome ${i} reason is not text`);
441
+ const outcome: PolicyOutcome = {
442
+ policy: o.policy,
443
+ mode: o.mode,
444
+ ...(typeof o.userCanOverride === "boolean" ? { userCanOverride: o.userCanOverride } : {}),
445
+ // Pack-shaped, so the attribution the hook path already has files it as
446
+ // `cloudPolicyId`/`cloudVersion` (`jevAttribution` in `handler.ts`), and the
447
+ // review keys it as that policy's own reviewer (`cloudReviewerName`).
448
+ origin: { packId: `cloud:${origin.cloudPolicyId}`, packVersion: String(origin.cloudVersion) },
449
+ evidence: o.evidence,
450
+ exempt: nullableProbability(o.exempt, `outcome ${i} exempt`),
451
+ userAsked: nullableProbability(o.userAsked, `outcome ${i} userAsked`),
452
+ targetNamedByUser: o.targetNamedByUser as boolean,
453
+ escalatedByInjection: o.escalatedByInjection as boolean,
454
+ verdict: o.verdict as PolicyOutcome["verdict"],
455
+ ...(o.intent !== undefined ? { intent: o.intent as PolicyOutcome["intent"] } : {}),
456
+ ...(o.targetScanIncomplete === true ? { targetScanIncomplete: true as const } : {}),
457
+ };
458
+ return {
459
+ outcome,
460
+ cloudPolicyId: origin.cloudPolicyId as string,
461
+ cloudVersion: origin.cloudVersion as number,
462
+ observe: o.effect === "observe",
463
+ reason: typeof o.reason === "string" ? capReason(o.reason) : null,
464
+ };
465
+ }
466
+
467
+ function capReason(reason: string): string {
468
+ return reason.length > MAX_CLOUD_REASON_CHARS ? `${reason.slice(0, MAX_CLOUD_REASON_CHARS - 1)}…` : reason;
469
+ }
470
+
471
+ const SEVERITY: Record<"deny" | "instruct", number> = { instruct: 1, deny: 2 };
472
+
473
+ /**
474
+ * The would-haves of the observed outcomes: per Cloud policy, its most severe
475
+ * check that fired (`deny`, or a warning). A check that found nothing, or that
476
+ * the human asked for, would have done nothing.
477
+ */
478
+ function observedChecks(
479
+ parsed: ReadonlyArray<ParsedCloudOutcome>,
480
+ watched: { v: Record<string, unknown>; parsed: ParsedCloudOutcome[] } | null = null,
481
+ ): ObservedCloudCheck[] {
482
+ // The observed verdict's own reason, when exactly one of its checks fired.
483
+ const firing = watched?.parsed.filter((p) => p.outcome.verdict === "deny" || p.outcome.verdict === "instruct") ?? [];
484
+ const watchedReason = firing.length === 1 && typeof watched?.v.reason === "string" ? capReason(watched.v.reason) : null;
485
+ const byPolicy = new Map<string, ObservedCloudCheck>();
486
+ for (const p of parsed) {
487
+ const verdict = p.outcome.verdict;
488
+ if (verdict !== "deny" && verdict !== "instruct") continue;
489
+ const prev = byPolicy.get(p.cloudPolicyId);
490
+ if (prev && SEVERITY[prev.decision] >= SEVERITY[verdict]) continue;
491
+ byPolicy.set(p.cloudPolicyId, {
492
+ cloudPolicyId: p.cloudPolicyId,
493
+ cloudVersion: p.cloudVersion,
494
+ check: p.outcome.policy,
495
+ decision: verdict,
496
+ reason: p.reason ?? (firing[0] === p ? watchedReason : null) ?? `Flagged by semantic review (semantic/${p.outcome.policy})`,
497
+ });
498
+ }
499
+ return [...byPolicy.values()];
500
+ }
501
+
502
+ /**
503
+ * The reply's `cloud` block, validated and rebuilt, or a `malformed` JevError.
504
+ * A reply without one (a server that does not implement C10) is malformed:
505
+ * nothing it says can be told apart from "Cloud has no checks here".
506
+ */
507
+ /** One verdict block (`verdict` + `asked`) validated, with every rule a reply's verdict is held to. */
508
+ function parseVerdictBlock(
509
+ rawVerdict: unknown,
510
+ rawAsked: unknown,
511
+ what: string,
512
+ ): { v: Record<string, unknown>; parsed: ParsedCloudOutcome[]; asked: string[] } {
513
+ const v = rawVerdict as Record<string, unknown> | undefined;
514
+ if (!v || typeof v !== "object" || Array.isArray(v)) malformed(`no ${what}`);
515
+ if (typeof v.decision !== "string" || !DECISIONS.has(v.decision)) malformed(`${what} has no decision`);
516
+ if (v.reason !== null && v.reason !== undefined && typeof v.reason !== "string") malformed(`${what} reason is not text`);
517
+ if (!Array.isArray(v.outcomes) || v.outcomes.length > MAX_LIST) malformed(`${what} has no outcomes list`);
518
+ if (v.beyondTask !== undefined && typeof v.beyondTask !== "boolean") malformed(`${what} beyondTask is not a flag`);
519
+ const parsed = v.outcomes.map(parseOutcome);
520
+ const asked = names(rawAsked, `${what} asked`);
521
+ // Each outcome must answer a check Cloud says it ASKED, once. The review
522
+ // clears a `both` policy's regex half from these outcomes, so one that
523
+ // `asked` does not vouch for — `asked: []` beside a fabricated `none` — is a
524
+ // contradiction, not a clear: the whole reply is refused, and the call is
525
+ // decided by the regex alone (security review of #873).
526
+ const askedSet = new Set(asked);
527
+ const seen = new Set<string>();
528
+ parsed.forEach(({ outcome: o }, i) => {
529
+ if (!askedSet.has(o.policy)) malformed(`${what} outcome ${i} answers ${JSON.stringify(o.policy)}, which Cloud did not ask`);
530
+ const key = `${o.origin?.packId ?? ""}\u0000${o.policy}`;
531
+ if (seen.has(key)) malformed(`${what} outcome ${i} answers ${JSON.stringify(o.policy)} twice`);
532
+ seen.add(key);
533
+ });
534
+ return { v, parsed, asked };
535
+ }
536
+
537
+ export function parseCloudReply(raw: unknown, observedPolicies: ReadonlyArray<string> = []): CloudJevReply {
538
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) malformed("no cloud block");
539
+ const block = raw as Record<string, unknown>;
540
+ const { v, parsed, asked } = parseVerdictBlock(block.verdict, block.asked, "verdict");
541
+ // `observed` (CONTRACT, fix round): the checks of watched (`observe`)
542
+ // assignments, decided apart, present only when one was asked. Held to the
543
+ // same rules as the verdict; recorded, never applied.
544
+ let watched: { v: Record<string, unknown>; parsed: ParsedCloudOutcome[]; asked: string[] } | null = null;
545
+ if (block.observed !== undefined && block.observed !== null) {
546
+ const o = block.observed as Record<string, unknown>;
547
+ if (typeof o !== "object" || Array.isArray(o)) malformed("observed is not an object");
548
+ watched = parseVerdictBlock(o.verdict, o.asked, "observed verdict");
549
+ if (watched.parsed.some((p) => p.outcome.verdict === "deny") && watched.v.decision !== "deny") {
550
+ malformed("an observed check denied but its verdict does not");
551
+ }
552
+ }
553
+ // An `observe` assignment's checks (Cloud says so per outcome, or this
554
+ // machine's deployment does) are recorded, never applied: they leave the
555
+ // verdict, so they can neither deny nor clear anything.
556
+ const observeIds = new Set(observedPolicies);
557
+ const isObserved = (p: ParsedCloudOutcome) => p.observe || observeIds.has(p.cloudPolicyId);
558
+ const enforced = parsed.filter((p) => !isObserved(p)).map((p) => p.outcome);
559
+ const observedHere = parsed.filter(isObserved);
560
+ const observed = [...observedHere, ...(watched?.parsed ?? [])];
561
+ // A check that fired deny makes the verdict a deny; a reply that says
562
+ // otherwise contradicts itself.
563
+ if (enforced.some((o) => o.verdict === "deny") && v.decision !== "deny") malformed("a check denied but the verdict does not");
564
+ const cloudReason = typeof v.reason === "string" ? capReason(v.reason) : null;
565
+ let decision = v.decision as SemanticVerdict["decision"];
566
+ let reason = cloudReason;
567
+ if (observedHere.length > 0) {
568
+ // Decided again from the enforced checks alone, never above what Cloud
569
+ // said. Cloud's reason stands when the decision does; otherwise the
570
+ // combine's generic line names the deciding check.
571
+ const own: SemanticVerdict["decision"] = enforced.some((o) => o.verdict === "deny")
572
+ ? "deny"
573
+ : enforced.some((o) => o.verdict === "instruct") || v.beyondTask === true
574
+ ? "instruct"
575
+ : "allow";
576
+ const capped = RANK[own] <= RANK[decision] ? own : decision;
577
+ if (capped !== decision) reason = null;
578
+ decision = capped;
579
+ }
580
+ return {
581
+ observed: observedChecks(observed, watched),
582
+ observedAsked: [...observedHere.map((p) => p.outcome.policy), ...(watched?.asked ?? [])],
583
+ verdict: {
584
+ decision,
585
+ reason,
586
+ outcomes: enforced,
587
+ injectionSuspected: nullableProbability(v.injectionSuspected, "injectionSuspected"),
588
+ scopeWithinRequest: nullableProbability(v.scopeWithinRequest, "scopeWithinRequest"),
589
+ beyondTask: v.beyondTask === true,
590
+ },
591
+ asked,
592
+ droppedLocal: names(block.droppedLocal, "droppedLocal"),
593
+ droppedCloud: names(block.droppedCloud, "droppedCloud"),
594
+ };
595
+ }
596
+
597
+ /**
598
+ * The reply's optional `observed: {verdict, asked}` — the outcomes of checks
599
+ * from watched Cloud policies, which never block. Read leniently and apart
600
+ * from {@link parseCloudReply}: they are only recorded, so a malformed block
601
+ * costs the record, never the call's verdict. Only outcomes `observed.asked`
602
+ * names, each once.
603
+ */
604
+ export function parseObservedOutcomes(raw: unknown): PolicyOutcome[] {
605
+ try {
606
+ const block = (raw as Record<string, unknown> | null)?.observed as Record<string, unknown> | undefined;
607
+ if (!block || typeof block !== "object" || Array.isArray(block)) return [];
608
+ const asked = new Set(names(block.asked, "observed.asked"));
609
+ const outcomes = ((block.verdict as Record<string, unknown> | undefined)?.outcomes ?? []) as unknown[];
610
+ if (!Array.isArray(outcomes) || outcomes.length > MAX_LIST) return [];
611
+ const seen = new Set<string>();
612
+ const out: PolicyOutcome[] = [];
613
+ outcomes.forEach((o, i) => {
614
+ const parsed = parseOutcome(o, i).outcome;
615
+ if (!asked.has(parsed.policy) || seen.has(parsed.policy)) return;
616
+ seen.add(parsed.policy);
617
+ out.push(parsed);
618
+ });
619
+ return out;
620
+ } catch {
621
+ return [];
622
+ }
623
+ }
624
+
625
+ // ── The merge (CONTRACT C10.5) ───────────────────────────────────────────────
626
+
627
+ const RANK: Record<SemanticVerdict["decision"], number> = { allow: 0, instruct: 1, deny: 2 };
628
+
629
+ /** Lines of the given reasons, first seen first, each once. */
630
+ function joinLines(reasons: ReadonlyArray<string | null>): string | null {
631
+ const lines: string[] = [];
632
+ for (const reason of reasons) {
633
+ if (!reason) continue;
634
+ for (const line of reason.split("\n")) if (!lines.includes(line)) lines.push(line);
635
+ }
636
+ return lines.length > 0 ? lines.join("\n") : null;
637
+ }
638
+
639
+ /**
640
+ * Cloud's verdict and the machine's own, as ONE verdict for `combine.ts`:
641
+ *
642
+ * - outcomes: Cloud's first, then the machine's;
643
+ * - decision: the most severe (deny > instruct > allow);
644
+ * - reason: the deny's (Cloud's first) for a deny; every warning line (Cloud's
645
+ * first, each once — a beyond-the-task line both sides raised is one line)
646
+ * for an instruct; the allow notes (Cloud's first) for an allow;
647
+ * - `beyondTask`: either side's; `injectionSuspected` / `scopeWithinRequest`:
648
+ * the machine's own reading of the shared answer, else Cloud's.
649
+ *
650
+ * Pure.
651
+ */
652
+ export function mergeCloudVerdict(cloud: SemanticVerdict, local: SemanticVerdict): SemanticVerdict {
653
+ const decision = RANK[cloud.decision] >= RANK[local.decision] ? cloud.decision : local.decision;
654
+ let reason: string | null;
655
+ if (decision === "deny") {
656
+ reason = cloud.decision === "deny" ? cloud.reason ?? local.reason : local.reason;
657
+ } else if (decision === "instruct") {
658
+ reason = joinLines([cloud, local].filter((v) => v.decision === "instruct").map((v) => v.reason));
659
+ } else {
660
+ reason = joinLines([cloud.reason, local.reason]);
661
+ }
662
+ const beyond = cloud.beyondTask === undefined && local.beyondTask === undefined ? undefined : cloud.beyondTask === true || local.beyondTask === true;
663
+ return {
664
+ decision,
665
+ reason,
666
+ outcomes: [...cloud.outcomes, ...local.outcomes],
667
+ injectionSuspected: local.injectionSuspected ?? cloud.injectionSuspected,
668
+ scopeWithinRequest: local.scopeWithinRequest ?? cloud.scopeWithinRequest,
669
+ ...(beyond !== undefined ? { beyondTask: beyond } : {}),
670
+ };
671
+ }
672
+
673
+ // ── The call ─────────────────────────────────────────────────────────────────
674
+
675
+ /** An allow with nothing decided: Cloud's short-circuit verdict. */
676
+ const EMPTY_ALLOW: SemanticVerdict = {
677
+ decision: "allow",
678
+ reason: null,
679
+ outcomes: [],
680
+ injectionSuspected: null,
681
+ scopeWithinRequest: null,
682
+ beyondTask: false,
683
+ };
684
+
685
+ function envNumber(name: string, fallback: number): number {
686
+ const n = Number(process.env[name]);
687
+ return Number.isFinite(n) && n > 0 ? n : fallback;
688
+ }
689
+
690
+ /** The answers the machine needs back: its own questions, minus the groups Cloud dropped. */
691
+ function expectedQuestions(request: JevRequest, prepared: PreparedCall, dropped: ReadonlySet<string>): JevRequest {
692
+ if (dropped.size === 0) return request;
693
+ const questions = Object.fromEntries(
694
+ Object.entries(request.questions).filter(([id]) => {
695
+ const owner = prepared.compiled.owners.get(id);
696
+ return !(typeof owner === "string" && dropped.has(owner));
697
+ }),
698
+ );
699
+ return { ...request, questions };
700
+ }
701
+
702
+ /** A question id's own part, as declarations allow it (`force_push`, `exempt`). */
703
+ const QUESTION_PART_RE = /^[a-z][a-z0-9_]{0,31}$/;
704
+
705
+ /**
706
+ * The answers to the questions Cloud added for the checks it asked: every
707
+ * `<check>.<part>` id whose check is in `asked`, read leniently (a missing or
708
+ * malformed one is simply not recorded — Cloud has already decided).
709
+ */
710
+ export function cloudCheckAnswers(response: JevResponse, asked: ReadonlyArray<string>): Record<string, number> {
711
+ const out: Record<string, number> = {};
712
+ if (asked.length === 0) return out;
713
+ const names = new Set(asked);
714
+ const answers = response.answers && typeof response.answers === "object" ? response.answers : {};
715
+ for (const [id, a] of Object.entries(answers)) {
716
+ const dot = id.lastIndexOf(".");
717
+ if (dot <= 0 || !names.has(id.slice(0, dot)) || !QUESTION_PART_RE.test(id.slice(dot + 1))) continue;
718
+ const p = (a as { noul?: unknown } | null)?.noul;
719
+ if (isProbability(p)) out[id] = p;
720
+ }
721
+ return out;
722
+ }
723
+
724
+ /** Whatever answers a short-circuit reply carries, read leniently: none is required. */
725
+ function presentAnswers(response: JevResponse): Record<string, number> {
726
+ const out: Record<string, number> = {};
727
+ const answers = response.answers && typeof response.answers === "object" ? response.answers : {};
728
+ for (const [id, a] of Object.entries(answers)) {
729
+ const p = (a as { noul?: unknown } | null)?.noul;
730
+ if (isProbability(p)) out[id] = p;
731
+ }
732
+ return out;
733
+ }
734
+
735
+ /**
736
+ * Ask FailproofAI Cloud about one call and return the merged verdict. Mirrors
737
+ * `evaluateSemantic` in everything but where the verdict comes from.
738
+ */
739
+ export async function evaluateCloudSemantic(input: SemanticInput, opts: CloudSemanticOptions): Promise<CloudSemanticOutcome> {
740
+ const started = performance.now();
741
+ const elapsed = () => Math.round(performance.now() - started);
742
+ // A known tool with no side effects (TodoWrite, Task, …) selects no check
743
+ // anywhere — `selectPolicies` answers `[]` for it before reading a single
744
+ // declaration, and Cloud selects its checks with an exact port of that
745
+ // function — so Cloud's reply is its short-circuit, an empty allow, every
746
+ // time. Not asked for: a round trip on every such call would buy nothing.
747
+ // Decided from the tool's name alone, before the envelope and its redaction
748
+ // passes are built for nothing (review n10).
749
+ const tool = classifyTool(input.toolName);
750
+ if (tool.toolIsKnown && tool.toolClass === "other") {
751
+ return {
752
+ status: "ok",
753
+ verdict: { ...EMPTY_ALLOW, outcomes: [] },
754
+ answers: {},
755
+ latencyMs: elapsed(),
756
+ inputTokens: null,
757
+ questionCount: 0,
758
+ truncated: false,
759
+ requestCut: false,
760
+ redactions: 0,
761
+ model: opts.model ?? DEFAULT_JEV_MODEL,
762
+ modelVerified: true,
763
+ via: "none",
764
+ cloud: { asked: [], droppedLocal: [], droppedCloud: [], answers: {}, observed: [], observedAsked: [], observedOutcomes: [] },
765
+ };
766
+ }
767
+ let prepared: PreparedCall;
768
+ let request: JevRequest;
769
+ try {
770
+ prepared = prepareSemantic(input, opts);
771
+ request = buildCloudRequest(prepared, input, opts.machineId, opts.agent);
772
+ } catch (err) {
773
+ return {
774
+ status: "degraded",
775
+ reason: `prepare: ${err instanceof Error ? err.message : String(err)}`,
776
+ latencyMs: elapsed(),
777
+ questionCount: 0,
778
+ truncated: false,
779
+ requestCut: false,
780
+ };
781
+ }
782
+ const questionCount = Object.keys(request.questions).length;
783
+ // What Cloud forwards is `{model, state, questions}` plus its own checks; the
784
+ // machine's share is checked here the way `prepareSemantic` checks it.
785
+ const { cloud: _block, ...forwarded } = request;
786
+ void _block;
787
+ const oversized = prepared.oversized || JSON.stringify(forwarded).length > MAX_REQUEST_CHARS;
788
+ const truncated = prepared.truncated || oversized;
789
+ const requestCut = prepared.requestCut || oversized;
790
+ const degraded = (reason: string): CloudSemanticOutcome => ({
791
+ status: "degraded",
792
+ reason,
793
+ latencyMs: elapsed(),
794
+ questionCount,
795
+ truncated,
796
+ requestCut,
797
+ });
798
+
799
+ const transport: JevTransport | undefined = opts.transport;
800
+ if (!transport) return degraded("no-transport");
801
+ const via: JevProviderKind = opts.via ?? "failproofai";
802
+ if (opts.signal?.aborted) return degraded("aborted");
803
+
804
+ try {
805
+ const timeout = AbortSignal.timeout(opts.timeoutMs ?? envNumber("FAILPROOFAI_JEV_TIMEOUT_MS", DEFAULT_JEV_TIMEOUT_MS));
806
+ const signal = opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout;
807
+ const response = await transport(request, signal);
808
+ const reply = parseCloudReply(response.cloud, opts.observedPolicies);
809
+ // The full outcomes of the watched checks the reply was vetted to have asked
810
+ // (`parseCloudReply` refuses the whole reply otherwise), for the per-check record.
811
+ const observedAsked = new Set(reply.observedAsked);
812
+ const observedOutcomes = parseObservedOutcomes(response.cloud).filter((o) => observedAsked.has(o.policy));
813
+
814
+ // Only the machine's own groups can be dropped from its side; a name it
815
+ // did not send is not its to act on.
816
+ const own = new Map(prepared.selected.map((p) => [p.name, p] as const));
817
+ const dropped = new Set(reply.droppedLocal.filter((name) => own.has(name)));
818
+ const kept: SemanticPolicy[] = prepared.selected.filter((p) => !dropped.has(p.name));
819
+
820
+ // Short-circuit (C10.4 step 6): nothing of the machine's left to decide and
821
+ // no Cloud check asked, so Cloud made no TypeSafe call and there are no
822
+ // answers to demand. The local verdict is then an empty allow whatever the
823
+ // answers — `decide` with nothing selected reads no per-policy answer, and
824
+ // no warning can fire without one.
825
+ const shortCircuit =
826
+ kept.length === 0 && reply.asked.length === 0 && reply.verdict.outcomes.length === 0 && reply.observedAsked.length === 0;
827
+ const answers = shortCircuit ? presentAnswers(response) : readAnswers(expectedQuestions(request, prepared, dropped), response);
828
+
829
+ const local =
830
+ prepared.intent === "v1"
831
+ ? decideV1(kept, answers, input.toolInput, prepared.userSaid, prepared.agentLastMessage, {
832
+ ...opts.v1,
833
+ userSaidCut: prepared.userSaidCut,
834
+ })
835
+ : decide(kept, answers, input.toolInput, prepared.userSaid, opts.thresholds ?? DEFAULT_THRESHOLDS, prepared.userSaidCut);
836
+
837
+ return {
838
+ status: "ok",
839
+ verdict: mergeCloudVerdict(reply.verdict, local),
840
+ answers,
841
+ latencyMs: elapsed(),
842
+ inputTokens: typeof response.usage?.input_tokens === "number" ? response.usage.input_tokens : null,
843
+ questionCount,
844
+ truncated,
845
+ requestCut,
846
+ redactions: prepared.envelope.redactions,
847
+ model: response.model || request.model,
848
+ modelVerified: response.modelUnverified !== true,
849
+ via,
850
+ cloud: {
851
+ asked: reply.asked,
852
+ droppedLocal: [...dropped].map((name) => ({ name, packId: own.get(name)?.origin?.packId ?? null })),
853
+ droppedCloud: reply.droppedCloud,
854
+ answers: cloudCheckAnswers(response, [...reply.asked, ...reply.observedAsked]),
855
+ observed: reply.observed,
856
+ observedAsked: reply.observedAsked,
857
+ observedOutcomes,
858
+ },
859
+ };
860
+ } catch (err) {
861
+ // Checked first: a transport that surfaces the abort as its own timeout
862
+ // error must still read as the caller's abort, not as Jev being slow.
863
+ if (opts.signal?.aborted) return degraded("aborted");
864
+ if (err instanceof JevError) return degraded(err.code);
865
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) return degraded("timeout");
866
+ return degraded(`error: ${err instanceof Error ? err.message : String(err)}`);
867
+ }
868
+ }