failproofai 1.0.12-beta.0 → 1.0.12-beta.1

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 (195) 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 +1 -1
  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]__0588st-tpiuia._.js +3 -0
  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]__0vcyntk4snf0f._.js → [root-of-the-server]__0b78b0j8iyv6l._.js} +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1_xbg2kx30wp-._.js → [root-of-the-server]__0naevg4bcoh54._.js} +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.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/_0eoura3_9gjb-._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/_128ebxr83nk6g._.js +3 -0
  89. package/.next/standalone/.next/server/chunks/ssr/{_1gb0ifpkowwub._.js → _1a3tubds7s7ow._.js} +10 -9
  90. package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_00jxj8foxqz5t.js → node_modules_html-to-image_es_index_1i8oid1gc7s1f.js} +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9ddabn3_._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0agdmptetetdt._.js +5 -0
  101. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0mdwgjisv4fub._.js +5 -0
  102. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4l1tzrm._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/src_hooks_daemon-client_ts_0k41_i7jg6ndv._.js +1 -1
  104. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  105. package/.next/standalone/.next/server/pages/404.html +1 -1
  106. package/.next/standalone/.next/server/pages/500.html +1 -1
  107. package/.next/standalone/.next/server/preview-props.json +3 -3
  108. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  109. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  110. package/.next/standalone/.next/static/chunks/{3tn1bzk094e8e.js → 0_zfad4co-t2z.js} +1 -1
  111. package/.next/standalone/.next/static/chunks/0jutamwqz8pg5.js +1 -0
  112. package/.next/standalone/.next/static/chunks/{2meqb9kygrncb.js → 114hbolvrv94e.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{2znt1784j9gd9.js → 18433qz-8njv3.js} +1 -1
  114. package/.next/standalone/.next/static/chunks/1b32-afqacaen.js +1 -0
  115. package/.next/standalone/.next/static/chunks/{13jv-xvabeb62.js → 1p-hsi58i-6h0.js} +1 -1
  116. package/.next/standalone/.next/static/chunks/{0pjxnp927y7_j.js → 2-tf6_7u623kd.js} +1 -1
  117. package/.next/standalone/.next/static/chunks/{2qz5piiir-qqm.js → 24w3x2da9sk49.js} +1 -1
  118. package/.next/standalone/.next/static/chunks/{0a-ccsp3xmypz.js → 3-pku9qwv1yfu.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/42e8zab314654.js +1 -0
  120. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +41 -0
  121. package/.next/standalone/fp-cloud-cli/README.md +42 -1
  122. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +117 -12
  123. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +285 -19
  124. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +84 -18
  125. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +170 -6
  126. package/.next/standalone/fp-cloud-cli/fp_cli/models.py +89 -6
  127. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +259 -21
  128. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +8 -5
  129. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +2 -2
  130. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +17 -9
  131. package/.next/standalone/fp-cloud-cli/tests/test_cloud_jev.py +692 -0
  132. package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +46 -0
  133. package/.next/standalone/fp-cloud-cli/tests/test_output.py +23 -0
  134. package/.next/standalone/hermes-plugin/README.md +6 -4
  135. package/.next/standalone/hermes-plugin/__init__.py +16 -0
  136. package/.next/standalone/hermes-plugin/client.py +5 -1
  137. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  138. package/.next/standalone/package.json +9 -9
  139. package/.next/standalone/server.js +1 -1
  140. package/bin/failproofai.mjs +17 -1
  141. package/dist/cli.mjs +2930 -1747
  142. package/dist/worker.mjs +1450 -145
  143. package/hermes-plugin/README.md +6 -4
  144. package/hermes-plugin/__init__.py +16 -0
  145. package/hermes-plugin/client.py +5 -1
  146. package/hermes-plugin/plugin.yaml +1 -1
  147. package/package.json +9 -9
  148. package/src/audit/open-browser.ts +1 -19
  149. package/src/hooks/agent-roster.ts +172 -0
  150. package/src/hooks/agent-targets.ts +60 -0
  151. package/src/hooks/cloud-connection.ts +97 -27
  152. package/src/hooks/cloud-enrollment-cli.ts +7 -4
  153. package/src/hooks/cloud-introspect.ts +2 -5
  154. package/src/hooks/cloud-managed-policies.ts +380 -20
  155. package/src/hooks/cloud-policy-errors.ts +481 -0
  156. package/src/hooks/config-status.ts +1 -1
  157. package/src/hooks/configure-wizard.ts +15 -311
  158. package/src/hooks/custom-hooks-loader.ts +26 -3
  159. package/src/hooks/daemon-client.ts +9 -4
  160. package/src/hooks/daemon-service.ts +2 -1
  161. package/src/hooks/effective-reviewers.ts +22 -1
  162. package/src/hooks/fp-home.ts +6 -0
  163. package/src/hooks/handler.ts +235 -18
  164. package/src/hooks/health-probe.ts +15 -0
  165. package/src/hooks/integrations.ts +18 -11
  166. package/src/hooks/jev-cli.ts +197 -0
  167. package/src/hooks/jev-cloud-connection.ts +92 -0
  168. package/src/hooks/manager.ts +122 -16
  169. package/src/hooks/pack-failclosed.ts +2 -0
  170. package/src/hooks/pack-manifest.ts +8 -1
  171. package/src/hooks/policy-evaluator.ts +5 -1
  172. package/src/hooks/policy-registry.ts +3 -2
  173. package/src/hooks/policy-reviewability.ts +21 -5
  174. package/src/hooks/semantic/cloud-jev-health.ts +227 -0
  175. package/src/hooks/semantic/cloud-jev.ts +677 -0
  176. package/src/hooks/semantic/jev-client.ts +18 -1
  177. package/src/hooks/semantic/jev-config.ts +173 -3
  178. package/src/hooks/semantic/jev-review.ts +129 -10
  179. package/src/hooks/semantic/pack-policies.ts +2 -1
  180. package/src/hooks/semantic/types.ts +8 -0
  181. package/src/hooks/session-pause-cli.ts +1 -1
  182. package/src/hooks/tui.ts +0 -271
  183. package/src/hooks/worker-server.ts +5 -1
  184. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0uhs2t1b._.js +0 -5
  185. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7cyqlb2._.js +0 -3
  186. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ts0fg5yih1c1._.js +0 -3
  187. package/.next/standalone/.next/server/chunks/ssr/_1ve8y151ua7qx._.js +0 -3
  188. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo55j16x._.js +0 -5
  189. package/.next/standalone/.next/static/chunks/2ank2u3apy8m6.js +0 -1
  190. package/.next/standalone/.next/static/chunks/2ohktp_-lkv3x.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2py0eldhwz8ay.js +0 -1
  192. package/src/hooks/cloud-login.ts +0 -245
  193. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → xq8UgF5yFxty1VWxY2hyn}/_buildManifest.js +0 -0
  194. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → xq8UgF5yFxty1VWxY2hyn}/_clientMiddlewareManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → xq8UgF5yFxty1VWxY2hyn}/_ssgManifest.js +0 -0
@@ -0,0 +1,677 @@
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
+ verdict: SemanticVerdict;
94
+ asked: string[];
95
+ droppedLocal: string[];
96
+ droppedCloud: string[];
97
+ }
98
+
99
+ /** What the Cloud review adds to a `SemanticOutcome`, for the verdict log and the budget report. */
100
+ export interface CloudReplySummary {
101
+ asked: string[];
102
+ /** The machine's own groups Cloud dropped, with the pack that declared each (null when none did). */
103
+ droppedLocal: Array<{ name: string; packId: string | null }>;
104
+ droppedCloud: string[];
105
+ }
106
+
107
+ export type CloudSemanticOutcome =
108
+ | (Extract<SemanticOutcome, { status: "ok" }> & { cloud: CloudReplySummary })
109
+ | Extract<SemanticOutcome, { status: "degraded" }>;
110
+
111
+ export interface CloudSemanticOptions extends SemanticOptions {
112
+ /** This machine's FailproofAI Cloud machine id, from `credentials.json`. */
113
+ machineId: string;
114
+ /** Explicit null = v2 request with unresolved identity (no scoped checks). */
115
+ agent?: AgentIdentity | null;
116
+ }
117
+
118
+ // ── The request ──────────────────────────────────────────────────────────────
119
+
120
+ function noul(probe: { instructions: string; criteria?: { true: string; false: string } }): NoulQuestion {
121
+ return {
122
+ type: "noul",
123
+ instructions: probe.instructions,
124
+ ...(probe.criteria ? { criteria: { ...probe.criteria } } : {}),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * The global intent questions a Cloud call ALWAYS carries, whatever the
130
+ * machine's own checks are — Cloud's checks are decided on them:
131
+ *
132
+ * - `injection`, always (`compile.ts` asks it on every call it sends);
133
+ * - v1: the three task probes whenever a human message was recorded, as
134
+ * `compileRequest` asks them;
135
+ * - v0: `scope` whenever a human message was recorded. `compileRequest` asks
136
+ * it only when a SELECTED policy may be overridden, and the machine cannot
137
+ * know whether a Cloud check may be — without it no Cloud check could ever
138
+ * be cleared in v0. `decide` reads `scope` only inside the override branch,
139
+ * so the extra question changes no local verdict.
140
+ */
141
+ export function cloudGlobalQuestions(intent: IntentMode, userSaidCount: number): Record<string, NoulQuestion> {
142
+ const questions: Record<string, NoulQuestion> = { [INJECTION_PROBE.id]: noul(INJECTION_PROBE) };
143
+ if (userSaidCount > 0) {
144
+ if (intent === "v1") for (const probe of TASK_PROBES) questions[probe.id] = noul(probe);
145
+ else questions[SCOPE_PROBE.id] = noul(SCOPE_PROBE);
146
+ }
147
+ return questions;
148
+ }
149
+
150
+ /**
151
+ * How much of the tool input's text is searched for secrets. The groups sent
152
+ * are cut at {@link MAX_TARGET_SCAN_CHARS} and come from the text in order
153
+ * (the shell scan itself reads far less), so this covers every word that can
154
+ * be sent, while a 1 MB heredoc does not cost the hook a second full pass.
155
+ */
156
+ const MAX_SECRET_SCAN_CHARS = 4 * MAX_TARGET_SCAN_CHARS;
157
+
158
+ /**
159
+ * Secret-shaped strings in the text `scanTargets` reads, lowercased like its
160
+ * words: the command, or — for any other tool — the string values it takes a
161
+ * target from (the same ≤300-character, non-content fields).
162
+ */
163
+ function secretWords(toolInput: Record<string, unknown>): string[] {
164
+ const text = (
165
+ typeof toolInput.command === "string"
166
+ ? toolInput.command
167
+ : Object.entries(toolInput)
168
+ .filter(([key, v]) => typeof v === "string" && v.length <= 300 && !/content|old_string|new_string|body|text|prompt/i.test(key))
169
+ .map(([, v]) => v as string)
170
+ .join("\n")
171
+ ).slice(0, MAX_SECRET_SCAN_CHARS);
172
+ try {
173
+ return redactSecretsDetailed(text, { blunt: false })
174
+ .found.map((s) => s.toLowerCase())
175
+ .filter((s) => s.length > 0);
176
+ } catch {
177
+ return [];
178
+ }
179
+ }
180
+
181
+ /**
182
+ * `scanTargets(toolInput)` as the `cloud` block carries it: each group's Set as
183
+ * a sorted array, `targets` left out (it is the groups' union).
184
+ *
185
+ * Two changes, both in the stricter direction only:
186
+ *
187
+ * - A word that is (part of) a secret the narrow redactor finds in the tool
188
+ * input is replaced by {@link REDACTED_TARGET_WORD}, which no human's words
189
+ * can contain. The envelope already keeps that secret out of what Jev reads;
190
+ * this keeps it out of the metadata too. The group is kept, so the call still
191
+ * names as many targets — one the human can no longer be matched against.
192
+ * - Past {@link MAX_TARGET_SCAN_CHARS}, a prefix of the groups with
193
+ * `complete: false`: an incomplete scan clears nothing.
194
+ */
195
+ export function cloudTargetScan(toolInput: Record<string, unknown>, scan: TargetScan = scanTargets(toolInput)): CloudJevBlock["targetScan"] {
196
+ const secrets = scan.groups.length > 0 ? secretWords(toolInput) : [];
197
+ const hidden = (word: string): boolean =>
198
+ secrets.some((s) => s === word || (word.length >= 8 && s.includes(word)) || (s.length >= 8 && word.includes(s)));
199
+ const groups: string[][] = [];
200
+ let chars = 2;
201
+ let complete = scan.complete;
202
+ for (const group of scan.groups) {
203
+ const words = [...new Set([...group].map((w) => (hidden(w) ? REDACTED_TARGET_WORD : w)))].sort();
204
+ const cost = JSON.stringify(words).length + 1;
205
+ if (chars + cost > MAX_TARGET_SCAN_CHARS) {
206
+ complete = false;
207
+ break;
208
+ }
209
+ chars += cost;
210
+ groups.push(words);
211
+ }
212
+ return { groups, complete };
213
+ }
214
+
215
+ /** The machine's facts with their strings passed through the narrow redactor, as the envelope sends them. */
216
+ function cloudFacts(facts: Facts): Facts {
217
+ const scrub = (s: string | null): string | null => (s === null ? null : redactSecrets(s, { blunt: false }).text);
218
+ return {
219
+ toolName: facts.toolName,
220
+ toolClass: facts.toolClass,
221
+ toolIsKnown: facts.toolIsKnown,
222
+ cwd: scrub(facts.cwd),
223
+ projectRoot: scrub(facts.projectRoot),
224
+ currentGitBranch: scrub(facts.currentGitBranch),
225
+ paths: facts.paths.map((p) => ({ asWritten: scrub(p.asWritten) ?? "", resolved: scrub(p.resolved) ?? "", relation: p.relation })),
226
+ permissionMode: facts.permissionMode,
227
+ };
228
+ }
229
+
230
+ /**
231
+ * The `cloud` block for a prepared call (CONTRACT C10.3): what the local
232
+ * decider reads, so Cloud decides its checks on the same evidence.
233
+ *
234
+ * - `facts`: the machine's `Facts`, strings redacted as the envelope's are.
235
+ * - `targetScan`: {@link cloudTargetScan}.
236
+ * - `userSaid`: the human turns the envelope CARRIES, uncut (`prepared.userSaid`,
237
+ * cleaned in v1), each with secrets redacted by the rules the envelope uses
238
+ * for everything that leaves the machine (`blunt`), and each redaction marker
239
+ * then replaced by {@link REDACTED_SAID}, which has no letters: a marker's
240
+ * label ("GitHub personal access token", "assigned secret") would otherwise
241
+ * NAME a target the human never named.
242
+ * - `agentLastMessage`: the string the envelope SENT (v1), null in v0.
243
+ * - `userSaidCut`, `intentMode`: as the local decider takes them.
244
+ * - `localPolicies`: the names of this call's own per-policy question groups.
245
+ *
246
+ * Redaction changes the words Cloud's target checks compare, and a substring
247
+ * check can flip either way (a hidden target word no longer matches; a hidden
248
+ * human word no longer names one). So the checks Cloud runs on the block are
249
+ * run here on both the block and the unredacted data ({@link namingReadings});
250
+ * where they differ, the scan is sent `complete: false`, which withholds every
251
+ * clear and every softening of an overridable check — Cloud is then stricter
252
+ * than the local decider, never laxer.
253
+ */
254
+ export function buildCloudBlock(prepared: PreparedCall, input: SemanticInput, machineId: string, agent?: AgentIdentity | null): CloudJevBlock {
255
+ const scan = scanTargets(input.toolInput);
256
+ const block: CloudJevBlock = {
257
+ v: agent === undefined ? CLOUD_BLOCK_VERSION : 2,
258
+ machineId,
259
+ ...(agent ? { agent } : {}),
260
+ intentMode: prepared.intent,
261
+ facts: cloudFacts(prepared.facts),
262
+ targetScan: cloudTargetScan(input.toolInput, scan),
263
+ userSaid: prepared.userSaid.map((turn) => redactSecrets(turn, { blunt: true }).text.replace(REDACTION_MARKER, REDACTED_SAID)),
264
+ agentLastMessage: prepared.agentLastMessage,
265
+ userSaidCut: prepared.userSaidCut,
266
+ localPolicies: prepared.selected.map((p) => p.name),
267
+ };
268
+ // Bounded by the caps above in practice; this is the floor under them. The
269
+ // human's turns go first, then the target scan; each time the scan is marked
270
+ // incomplete, which clears nothing. `[""]`, not `[]`: it names nothing, but
271
+ // it still says a human spoke, so Cloud's beyond-the-task flag (which needs
272
+ // one) is not switched off by the size of the call.
273
+ if (blockBytes(block) > MAX_CLOUD_BLOCK_BYTES) {
274
+ block.userSaid = prepared.userSaid.length > 0 ? [""] : [];
275
+ block.userSaidCut = false;
276
+ block.targetScan = { ...block.targetScan, complete: false };
277
+ }
278
+ if (blockBytes(block) > MAX_CLOUD_BLOCK_BYTES) {
279
+ block.targetScan = { groups: [], complete: false };
280
+ }
281
+ if (
282
+ block.targetScan.complete &&
283
+ namingReadings(block.intentMode, scan.groups, prepared.userSaid, prepared.agentLastMessage) !==
284
+ namingReadings(
285
+ block.intentMode,
286
+ block.targetScan.groups.map((g) => new Set(g)),
287
+ block.userSaid,
288
+ block.agentLastMessage,
289
+ )
290
+ ) {
291
+ block.targetScan = { ...block.targetScan, complete: false };
292
+ }
293
+ return block;
294
+ }
295
+
296
+ /** A redaction marker (`<redacted:label>`, `redact.ts`) in the human's words. */
297
+ const REDACTION_MARKER = /<redacted:[^>]*>/g;
298
+
299
+ /** What a redaction marker in `userSaid` becomes: no letters, so it names no target. */
300
+ export const REDACTED_SAID = "\u0000";
301
+
302
+ /**
303
+ * Everything Cloud's port of `decide` / `decideV1` reads from the target groups
304
+ * and the human's words, as one comparable string: the group count, whether a
305
+ * human spoke, `everyTargetNamed`, and (v1) `partlyNamed` — over the human's
306
+ * words plus, in v1, the agent message they replied to, exactly as the deciders
307
+ * build their evidence.
308
+ */
309
+ function namingReadings(
310
+ intent: IntentMode,
311
+ groups: Set<string>[],
312
+ userSaid: ReadonlyArray<string>,
313
+ agentLastMessage: string | null,
314
+ ): string {
315
+ const scan: TargetScan = { targets: new Set(), groups, complete: true };
316
+ const said = intent === "v1" && agentLastMessage ? [...userSaid, agentLastMessage] : userSaid;
317
+ return [groups.length, userSaid.length > 0, everyTargetNamed(scan, said), intent === "v1" && partlyNamed(scan, said)].join();
318
+ }
319
+
320
+ /** The request sent to FailproofAI Cloud: today's, the global questions always, and the block. */
321
+ export function buildCloudRequest(prepared: PreparedCall, input: SemanticInput, machineId: string, agent?: AgentIdentity | null): JevRequest {
322
+ const own = prepared.compiled.request;
323
+ return {
324
+ model: own.model,
325
+ state: own.state,
326
+ questions: { ...own.questions, ...cloudGlobalQuestions(prepared.intent, prepared.userSaid.length) },
327
+ cloud: buildCloudBlock(prepared, input, machineId, agent) as unknown as Record<string, unknown>,
328
+ };
329
+ }
330
+
331
+ // ── The reply ────────────────────────────────────────────────────────────────
332
+
333
+ /** A Cloud policy id, as `cloud-managed-policies.ts` accepts one. */
334
+ const POLICY_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
335
+ /** Longest check name accepted back; declarations are far shorter. */
336
+ const MAX_NAME_CHARS = 256;
337
+ /** Most outcomes / names accepted back: 24 declarations per policy leaves this far out of reach. */
338
+ const MAX_LIST = 4096;
339
+ /**
340
+ * Longest Cloud reason kept (review n8). Cloud builds it from bounded titles
341
+ * and guidance, so this is never reached by a working server; past it the
342
+ * reason is cut rather than refused, since refusing would drop Cloud's verdict.
343
+ */
344
+ export const MAX_CLOUD_REASON_CHARS = 8_192;
345
+
346
+ function malformed(what: string): never {
347
+ throw new JevError("malformed", `FailproofAI Cloud's reply: ${what}`);
348
+ }
349
+
350
+ const isProbability = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
351
+ const isName = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= MAX_NAME_CHARS;
352
+
353
+ function nullableProbability(v: unknown, what: string): number | null {
354
+ if (v === null || v === undefined) return null;
355
+ if (!isProbability(v)) malformed(`${what} is not a probability`);
356
+ return v;
357
+ }
358
+
359
+ function names(v: unknown, what: string): string[] {
360
+ if (v === undefined || v === null) return [];
361
+ if (!Array.isArray(v) || v.length > MAX_LIST || !v.every(isName)) malformed(`${what} is not a list of names`);
362
+ return [...v];
363
+ }
364
+
365
+ const DECISIONS = new Set(["allow", "deny", "instruct"]);
366
+ const VERDICTS = new Set(["deny", "instruct", "overridden", "none"]);
367
+ const INTENTS = new Set(["op-requested", "task-step", "downgraded-task-step"]);
368
+
369
+ /** One Cloud outcome, rebuilt field by field so nothing but these fields crosses over. */
370
+ function parseOutcome(raw: unknown, i: number): PolicyOutcome {
371
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) malformed(`outcome ${i} is not an object`);
372
+ const o = raw as Record<string, unknown>;
373
+ if (!isName(o.policy)) malformed(`outcome ${i} has no policy name`);
374
+ if (o.mode !== "deny" && o.mode !== "instruct") malformed(`outcome ${i} has no mode`);
375
+ if (!isProbability(o.evidence)) malformed(`outcome ${i} has no evidence`);
376
+ if (typeof o.targetNamedByUser !== "boolean" || typeof o.escalatedByInjection !== "boolean") {
377
+ malformed(`outcome ${i} is missing a flag`);
378
+ }
379
+ if (typeof o.verdict !== "string" || !VERDICTS.has(o.verdict)) malformed(`outcome ${i} has no verdict`);
380
+ if (o.intent !== undefined && (typeof o.intent !== "string" || !INTENTS.has(o.intent))) malformed(`outcome ${i} has an unknown intent`);
381
+ if (o.targetScanIncomplete !== undefined && o.targetScanIncomplete !== true) malformed(`outcome ${i} has a bad targetScanIncomplete`);
382
+ if (o.userCanOverride !== undefined && typeof o.userCanOverride !== "boolean") malformed(`outcome ${i} has a bad userCanOverride`);
383
+ const origin = o.origin as Record<string, unknown> | undefined;
384
+ if (
385
+ !origin ||
386
+ typeof origin !== "object" ||
387
+ typeof origin.cloudPolicyId !== "string" ||
388
+ !POLICY_ID_RE.test(origin.cloudPolicyId) ||
389
+ !Number.isSafeInteger(origin.cloudVersion) ||
390
+ (origin.cloudVersion as number) < 0
391
+ ) {
392
+ malformed(`outcome ${i} has no Cloud origin`);
393
+ }
394
+ return {
395
+ policy: o.policy,
396
+ mode: o.mode,
397
+ ...(typeof o.userCanOverride === "boolean" ? { userCanOverride: o.userCanOverride } : {}),
398
+ // Pack-shaped, so the attribution the hook path already has files it as
399
+ // `cloudPolicyId`/`cloudVersion` (`jevAttribution` in `handler.ts`), and the
400
+ // review keys it as that policy's own reviewer (`cloudReviewerName`).
401
+ origin: { packId: `cloud:${origin.cloudPolicyId}`, packVersion: String(origin.cloudVersion) },
402
+ evidence: o.evidence,
403
+ exempt: nullableProbability(o.exempt, `outcome ${i} exempt`),
404
+ userAsked: nullableProbability(o.userAsked, `outcome ${i} userAsked`),
405
+ targetNamedByUser: o.targetNamedByUser as boolean,
406
+ escalatedByInjection: o.escalatedByInjection as boolean,
407
+ verdict: o.verdict as PolicyOutcome["verdict"],
408
+ ...(o.intent !== undefined ? { intent: o.intent as PolicyOutcome["intent"] } : {}),
409
+ ...(o.targetScanIncomplete === true ? { targetScanIncomplete: true as const } : {}),
410
+ };
411
+ }
412
+
413
+ /**
414
+ * The reply's `cloud` block, validated and rebuilt, or a `malformed` JevError.
415
+ * A reply without one (a server that does not implement C10) is malformed:
416
+ * nothing it says can be told apart from "Cloud has no checks here".
417
+ */
418
+ export function parseCloudReply(raw: unknown): CloudJevReply {
419
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) malformed("no cloud block");
420
+ const block = raw as Record<string, unknown>;
421
+ const v = block.verdict as Record<string, unknown> | undefined;
422
+ if (!v || typeof v !== "object" || Array.isArray(v)) malformed("no verdict");
423
+ if (typeof v.decision !== "string" || !DECISIONS.has(v.decision)) malformed("verdict has no decision");
424
+ if (v.reason !== null && v.reason !== undefined && typeof v.reason !== "string") malformed("verdict reason is not text");
425
+ if (!Array.isArray(v.outcomes) || v.outcomes.length > MAX_LIST) malformed("verdict has no outcomes list");
426
+ if (v.beyondTask !== undefined && typeof v.beyondTask !== "boolean") malformed("verdict beyondTask is not a flag");
427
+ const outcomes = v.outcomes.map(parseOutcome);
428
+ const asked = names(block.asked, "asked");
429
+ // Each outcome must answer a check Cloud says it ASKED, once. The review
430
+ // clears a `both` policy's regex half from these outcomes, so one that
431
+ // `asked` does not vouch for — `asked: []` beside a fabricated `none` — is a
432
+ // contradiction, not a clear: the whole reply is refused, and the call is
433
+ // decided by the regex alone (security review of #873).
434
+ const askedSet = new Set(asked);
435
+ const seen = new Set<string>();
436
+ outcomes.forEach((o, i) => {
437
+ if (!askedSet.has(o.policy)) malformed(`outcome ${i} answers ${JSON.stringify(o.policy)}, which Cloud did not ask`);
438
+ const key = `${o.origin?.packId ?? ""}\u0000${o.policy}`;
439
+ if (seen.has(key)) malformed(`outcome ${i} answers ${JSON.stringify(o.policy)} twice`);
440
+ seen.add(key);
441
+ });
442
+ // A check that fired deny makes the verdict a deny; a reply that says
443
+ // otherwise contradicts itself.
444
+ if (outcomes.some((o) => o.verdict === "deny") && v.decision !== "deny") malformed("a check denied but the verdict does not");
445
+ return {
446
+ verdict: {
447
+ decision: v.decision as SemanticVerdict["decision"],
448
+ reason:
449
+ typeof v.reason === "string"
450
+ ? v.reason.length > MAX_CLOUD_REASON_CHARS
451
+ ? `${v.reason.slice(0, MAX_CLOUD_REASON_CHARS - 1)}…`
452
+ : v.reason
453
+ : null,
454
+ outcomes,
455
+ injectionSuspected: nullableProbability(v.injectionSuspected, "injectionSuspected"),
456
+ scopeWithinRequest: nullableProbability(v.scopeWithinRequest, "scopeWithinRequest"),
457
+ beyondTask: v.beyondTask === true,
458
+ },
459
+ asked,
460
+ droppedLocal: names(block.droppedLocal, "droppedLocal"),
461
+ droppedCloud: names(block.droppedCloud, "droppedCloud"),
462
+ };
463
+ }
464
+
465
+ // ── The merge (CONTRACT C10.5) ───────────────────────────────────────────────
466
+
467
+ const RANK: Record<SemanticVerdict["decision"], number> = { allow: 0, instruct: 1, deny: 2 };
468
+
469
+ /** Lines of the given reasons, first seen first, each once. */
470
+ function joinLines(reasons: ReadonlyArray<string | null>): string | null {
471
+ const lines: string[] = [];
472
+ for (const reason of reasons) {
473
+ if (!reason) continue;
474
+ for (const line of reason.split("\n")) if (!lines.includes(line)) lines.push(line);
475
+ }
476
+ return lines.length > 0 ? lines.join("\n") : null;
477
+ }
478
+
479
+ /**
480
+ * Cloud's verdict and the machine's own, as ONE verdict for `combine.ts`:
481
+ *
482
+ * - outcomes: Cloud's first, then the machine's;
483
+ * - decision: the most severe (deny > instruct > allow);
484
+ * - reason: the deny's (Cloud's first) for a deny; every warning line (Cloud's
485
+ * first, each once — a beyond-the-task line both sides raised is one line)
486
+ * for an instruct; the allow notes (Cloud's first) for an allow;
487
+ * - `beyondTask`: either side's; `injectionSuspected` / `scopeWithinRequest`:
488
+ * the machine's own reading of the shared answer, else Cloud's.
489
+ *
490
+ * Pure.
491
+ */
492
+ export function mergeCloudVerdict(cloud: SemanticVerdict, local: SemanticVerdict): SemanticVerdict {
493
+ const decision = RANK[cloud.decision] >= RANK[local.decision] ? cloud.decision : local.decision;
494
+ let reason: string | null;
495
+ if (decision === "deny") {
496
+ reason = cloud.decision === "deny" ? cloud.reason ?? local.reason : local.reason;
497
+ } else if (decision === "instruct") {
498
+ reason = joinLines([cloud, local].filter((v) => v.decision === "instruct").map((v) => v.reason));
499
+ } else {
500
+ reason = joinLines([cloud.reason, local.reason]);
501
+ }
502
+ const beyond = cloud.beyondTask === undefined && local.beyondTask === undefined ? undefined : cloud.beyondTask === true || local.beyondTask === true;
503
+ return {
504
+ decision,
505
+ reason,
506
+ outcomes: [...cloud.outcomes, ...local.outcomes],
507
+ injectionSuspected: local.injectionSuspected ?? cloud.injectionSuspected,
508
+ scopeWithinRequest: local.scopeWithinRequest ?? cloud.scopeWithinRequest,
509
+ ...(beyond !== undefined ? { beyondTask: beyond } : {}),
510
+ };
511
+ }
512
+
513
+ // ── The call ─────────────────────────────────────────────────────────────────
514
+
515
+ /** An allow with nothing decided: Cloud's short-circuit verdict. */
516
+ const EMPTY_ALLOW: SemanticVerdict = {
517
+ decision: "allow",
518
+ reason: null,
519
+ outcomes: [],
520
+ injectionSuspected: null,
521
+ scopeWithinRequest: null,
522
+ beyondTask: false,
523
+ };
524
+
525
+ function envNumber(name: string, fallback: number): number {
526
+ const n = Number(process.env[name]);
527
+ return Number.isFinite(n) && n > 0 ? n : fallback;
528
+ }
529
+
530
+ /** The answers the machine needs back: its own questions, minus the groups Cloud dropped. */
531
+ function expectedQuestions(request: JevRequest, prepared: PreparedCall, dropped: ReadonlySet<string>): JevRequest {
532
+ if (dropped.size === 0) return request;
533
+ const questions = Object.fromEntries(
534
+ Object.entries(request.questions).filter(([id]) => {
535
+ const owner = prepared.compiled.owners.get(id);
536
+ return !(typeof owner === "string" && dropped.has(owner));
537
+ }),
538
+ );
539
+ return { ...request, questions };
540
+ }
541
+
542
+ /** Whatever answers a short-circuit reply carries, read leniently: none is required. */
543
+ function presentAnswers(response: JevResponse): Record<string, number> {
544
+ const out: Record<string, number> = {};
545
+ const answers = response.answers && typeof response.answers === "object" ? response.answers : {};
546
+ for (const [id, a] of Object.entries(answers)) {
547
+ const p = (a as { noul?: unknown } | null)?.noul;
548
+ if (isProbability(p)) out[id] = p;
549
+ }
550
+ return out;
551
+ }
552
+
553
+ /**
554
+ * Ask FailproofAI Cloud about one call and return the merged verdict. Mirrors
555
+ * `evaluateSemantic` in everything but where the verdict comes from.
556
+ */
557
+ export async function evaluateCloudSemantic(input: SemanticInput, opts: CloudSemanticOptions): Promise<CloudSemanticOutcome> {
558
+ const started = performance.now();
559
+ const elapsed = () => Math.round(performance.now() - started);
560
+ // A known tool with no side effects (TodoWrite, Task, …) selects no check
561
+ // anywhere — `selectPolicies` answers `[]` for it before reading a single
562
+ // declaration, and Cloud selects its checks with an exact port of that
563
+ // function — so Cloud's reply is its short-circuit, an empty allow, every
564
+ // time. Not asked for: a round trip on every such call would buy nothing.
565
+ // Decided from the tool's name alone, before the envelope and its redaction
566
+ // passes are built for nothing (review n10).
567
+ const tool = classifyTool(input.toolName);
568
+ if (tool.toolIsKnown && tool.toolClass === "other") {
569
+ return {
570
+ status: "ok",
571
+ verdict: { ...EMPTY_ALLOW, outcomes: [] },
572
+ answers: {},
573
+ latencyMs: elapsed(),
574
+ inputTokens: null,
575
+ questionCount: 0,
576
+ truncated: false,
577
+ requestCut: false,
578
+ redactions: 0,
579
+ model: opts.model ?? DEFAULT_JEV_MODEL,
580
+ modelVerified: true,
581
+ via: "none",
582
+ cloud: { asked: [], droppedLocal: [], droppedCloud: [] },
583
+ };
584
+ }
585
+ let prepared: PreparedCall;
586
+ let request: JevRequest;
587
+ try {
588
+ prepared = prepareSemantic(input, opts);
589
+ request = buildCloudRequest(prepared, input, opts.machineId, opts.agent);
590
+ } catch (err) {
591
+ return {
592
+ status: "degraded",
593
+ reason: `prepare: ${err instanceof Error ? err.message : String(err)}`,
594
+ latencyMs: elapsed(),
595
+ questionCount: 0,
596
+ truncated: false,
597
+ requestCut: false,
598
+ };
599
+ }
600
+ const questionCount = Object.keys(request.questions).length;
601
+ // What Cloud forwards is `{model, state, questions}` plus its own checks; the
602
+ // machine's share is checked here the way `prepareSemantic` checks it.
603
+ const { cloud: _block, ...forwarded } = request;
604
+ void _block;
605
+ const oversized = prepared.oversized || JSON.stringify(forwarded).length > MAX_REQUEST_CHARS;
606
+ const truncated = prepared.truncated || oversized;
607
+ const requestCut = prepared.requestCut || oversized;
608
+ const degraded = (reason: string): CloudSemanticOutcome => ({
609
+ status: "degraded",
610
+ reason,
611
+ latencyMs: elapsed(),
612
+ questionCount,
613
+ truncated,
614
+ requestCut,
615
+ });
616
+
617
+ const transport: JevTransport | undefined = opts.transport;
618
+ if (!transport) return degraded("no-transport");
619
+ const via: JevProviderKind = opts.via ?? "failproofai";
620
+ if (opts.signal?.aborted) return degraded("aborted");
621
+
622
+ try {
623
+ const timeout = AbortSignal.timeout(opts.timeoutMs ?? envNumber("FAILPROOFAI_JEV_TIMEOUT_MS", DEFAULT_JEV_TIMEOUT_MS));
624
+ const signal = opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout;
625
+ const response = await transport(request, signal);
626
+ const reply = parseCloudReply(response.cloud);
627
+
628
+ // Only the machine's own groups can be dropped from its side; a name it
629
+ // did not send is not its to act on.
630
+ const own = new Map(prepared.selected.map((p) => [p.name, p] as const));
631
+ const dropped = new Set(reply.droppedLocal.filter((name) => own.has(name)));
632
+ const kept: SemanticPolicy[] = prepared.selected.filter((p) => !dropped.has(p.name));
633
+
634
+ // Short-circuit (C10.4 step 6): nothing of the machine's left to decide and
635
+ // no Cloud check asked, so Cloud made no TypeSafe call and there are no
636
+ // answers to demand. The local verdict is then an empty allow whatever the
637
+ // answers — `decide` with nothing selected reads no per-policy answer, and
638
+ // no warning can fire without one.
639
+ const shortCircuit = kept.length === 0 && reply.asked.length === 0 && reply.verdict.outcomes.length === 0;
640
+ const answers = shortCircuit ? presentAnswers(response) : readAnswers(expectedQuestions(request, prepared, dropped), response);
641
+
642
+ const local =
643
+ prepared.intent === "v1"
644
+ ? decideV1(kept, answers, input.toolInput, prepared.userSaid, prepared.agentLastMessage, {
645
+ ...opts.v1,
646
+ userSaidCut: prepared.userSaidCut,
647
+ })
648
+ : decide(kept, answers, input.toolInput, prepared.userSaid, opts.thresholds ?? DEFAULT_THRESHOLDS, prepared.userSaidCut);
649
+
650
+ return {
651
+ status: "ok",
652
+ verdict: mergeCloudVerdict(reply.verdict, local),
653
+ answers,
654
+ latencyMs: elapsed(),
655
+ inputTokens: typeof response.usage?.input_tokens === "number" ? response.usage.input_tokens : null,
656
+ questionCount,
657
+ truncated,
658
+ requestCut,
659
+ redactions: prepared.envelope.redactions,
660
+ model: response.model || request.model,
661
+ modelVerified: response.modelUnverified !== true,
662
+ via,
663
+ cloud: {
664
+ asked: reply.asked,
665
+ droppedLocal: [...dropped].map((name) => ({ name, packId: own.get(name)?.origin?.packId ?? null })),
666
+ droppedCloud: reply.droppedCloud,
667
+ },
668
+ };
669
+ } catch (err) {
670
+ // Checked first: a transport that surfaces the abort as its own timeout
671
+ // error must still read as the caller's abort, not as Jev being slow.
672
+ if (opts.signal?.aborted) return degraded("aborted");
673
+ if (err instanceof JevError) return degraded(err.code);
674
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) return degraded("timeout");
675
+ return degraded(`error: ${err instanceof Error ? err.message : String(err)}`);
676
+ }
677
+ }