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
@@ -32,7 +32,9 @@ import { getInstanceId, hashToId } from "../../lib/telemetry-id";
32
32
  import { CliError } from "../cli-error";
33
33
  import { hookLogWarn } from "./hook-logger";
34
34
  import { customPoliciesDir } from "./fp-home";
35
- import { readActiveCloudManagedPolicies } from "./cloud-managed-policies";
35
+ import { listActiveCloudManagedPolicies, readCloudJevAssignments, readCloudJevState, type CloudJevMode } from "./cloud-managed-policies";
36
+ import { readCloudPolicyErrors } from "./cloud-policy-errors";
37
+ import { readRosterLabels } from "./agent-roster";
36
38
  import { CORE_SOURCE, addPack, setPackPolicyEnabled } from "./pack-store";
37
39
  import type { ResolvedPack } from "./pack-manifest";
38
40
  import { hasInstalledRegexPacks, readInstalledPacks } from "./pack-manifest";
@@ -332,6 +334,35 @@ export function integrationsInstalledAt(scope: HookScope, cwd?: string): Integra
332
334
  */
333
335
  export type NotEnforcingReason = "no-hooks" | "no-policies" | "observe-only";
334
336
 
337
+ /** Machine-wide assignments, like the listing: a `both` policy counts once. */
338
+ function cloudPolicyStatus(): { count: number; observing: boolean } {
339
+ let count = 0;
340
+ let observing = false;
341
+ const jsIds = new Set<string>();
342
+ try {
343
+ for (const artifact of listActiveCloudManagedPolicies()) {
344
+ jsIds.add(artifact.id);
345
+ if (artifact.effect === "observe") observing = true;
346
+ else count++;
347
+ }
348
+ } catch {
349
+ // The listing reports an unreadable JS deployment; Jev is read independently.
350
+ }
351
+ try {
352
+ const { jevMode } = readCloudJevState();
353
+ if (jevMode === "enforce" || jevMode === "observe") {
354
+ for (const assignment of readCloudJevAssignments() ?? []) {
355
+ if (jsIds.has(assignment.id)) continue;
356
+ if (jevMode === "observe" || assignment.effect === "observe") observing = true;
357
+ else count++;
358
+ }
359
+ }
360
+ } catch {
361
+ // No readable Jev state: neither enforcement nor observation is known.
362
+ }
363
+ return { count, observing };
364
+ }
365
+
335
366
  export function notEnforcingReason(cwd?: string): NotEnforcingReason | null {
336
367
  const wired = deduplicateScopes(HOOK_SCOPES, cwd).some((scope) => hooksInstalledInSettings(scope, cwd));
337
368
  if (!wired) return "no-hooks";
@@ -361,15 +392,11 @@ export function notEnforcingReason(cwd?: string): NotEnforcingReason | null {
361
392
  // An unreadable manifest is reported by the listing itself.
362
393
  }
363
394
 
364
- // Cloud-managed policies, pushed from a deployment.
365
- try {
366
- for (const artifact of readActiveCloudManagedPolicies()) {
367
- if (artifact.effect === "observe") observing = true;
368
- else return null;
369
- }
370
- } catch {
371
- // No deployment, or an unreadable one: nothing enforces from there.
372
- }
395
+ // Cloud JS enforces independently of Jev mode. Jev-only assignments enforce
396
+ // only in Cloud's enforce mode, and watch only while Jev is not off.
397
+ const cloud = cloudPolicyStatus();
398
+ if (cloud.count > 0) return null;
399
+ observing ||= cloud.observing;
373
400
 
374
401
  // A configured custom policy file, or convention files on disk. Loading them
375
402
  // to ask what they hold would import user code on every launch, so their
@@ -390,10 +417,11 @@ export function notEnforcingReason(cwd?: string): NotEnforcingReason | null {
390
417
  * How many policies enforce on this machine, for the line that says so.
391
418
  *
392
419
  * Counted the same way `notEnforcingReason` decides, so the two never disagree:
393
- * pack policies that are taken and not switched off, cloud-managed policies, and
394
- * the legacy builtins of a machine still on the migration shim — observe-only
395
- * ones excluded. Custom policy files cannot be counted without importing them,
396
- * so their presence is reported on its own.
420
+ * pack policies that are taken and not switched off, Cloud JS policies and
421
+ * Jev-only assignments in Cloud's enforce mode, and the legacy builtins of a
422
+ * machine still on the migration shim — observe-only ones excluded, `both`
423
+ * assignments counted once. Custom policy files cannot be counted without
424
+ * importing them, so their presence is reported on its own.
397
425
  */
398
426
  export function enforcingPolicyCount(cwd?: string): { count: number; custom: boolean } {
399
427
  const config = readMergedHooksConfig(cwd);
@@ -408,13 +436,7 @@ export function enforcingPolicyCount(cwd?: string): { count: number; custom: boo
408
436
  } catch {
409
437
  // An unreadable manifest counts nothing, as it enforces nothing.
410
438
  }
411
- try {
412
- for (const artifact of readActiveCloudManagedPolicies()) {
413
- if (artifact.effect !== "observe") count++;
414
- }
415
- } catch {
416
- // No deployment.
417
- }
439
+ count += cloudPolicyStatus().count;
418
440
  if (!hasInstalledRegexPacks()) count += config.enabledPolicies.length;
419
441
  const projectDir = resolve(findProjectConfigDir(cwd ?? process.cwd()), ".failproofai", "policies");
420
442
  const custom =
@@ -1245,7 +1267,16 @@ export interface PoliciesScreen {
1245
1267
  attention: { failed: boolean; text: string; fix?: string } | null;
1246
1268
  custom: PoliciesScreenRow[];
1247
1269
  convention: Array<{ scope: string; rows: PoliciesScreenRow[] }>;
1248
- cloud: { deployment: number; rows: PoliciesScreenRow[] } | null;
1270
+ cloud: {
1271
+ deployment: number | null;
1272
+ rows: PoliciesScreenRow[];
1273
+ /** FailproofAI Cloud's Jev mode for this machine, when the deployment sets one. */
1274
+ jevMode?: CloudJevMode | null;
1275
+ /** Problems reading the deployment, each one line. */
1276
+ problems?: string[];
1277
+ /** The manifest names its Jev assignments (`jevPolicies`), so Jev-only policies are among `rows`. */
1278
+ jevListed?: boolean;
1279
+ } | null;
1249
1280
  /**
1250
1281
  * Builtins this build enforces itself because no pack is installed: the
1251
1282
  * migration shim (`handler.ts` registers `enabledPolicies` when
@@ -1376,7 +1407,22 @@ export function renderPoliciesScreen(
1376
1407
  }
1377
1408
  if (screen.custom.length > 0) plain("Custom policies", undefined, screen.custom);
1378
1409
  for (const { scope, rows } of screen.convention) plain("Convention policies", scope, rows);
1379
- if (screen.cloud) plain("Cloud-managed", `deployment ${screen.cloud.deployment}`, screen.cloud.rows);
1410
+ if (screen.cloud) {
1411
+ const { deployment, rows, jevMode, problems } = screen.cloud;
1412
+ plain("Cloud-managed", deployment !== null ? `deployment ${deployment}` : undefined, rows);
1413
+ if (jevMode === "off") {
1414
+ out.push(`${INDENT}Jev mode: off, set by FailproofAI Cloud (overrides jev.json).`);
1415
+ } else if (jevMode) {
1416
+ out.push(`${INDENT}Jev mode: ${jevMode}, set by FailproofAI Cloud. Jev checks run on FailproofAI Cloud; nothing of them is installed here.`);
1417
+ // A manifest from a server that predates `jevPolicies` does not name its
1418
+ // Jev-only policies: say so, or an empty section reads as "Cloud
1419
+ // deployed nothing".
1420
+ if (!screen.cloud.jevListed) {
1421
+ out.push(`${INDENT}Jev-only Cloud policies are not listed here; see them with ${kit.cmd("fp fleet show")} or on the dashboard.`);
1422
+ }
1423
+ }
1424
+ for (const problem of problems ?? []) out.push(kit.caution(problem));
1425
+ }
1380
1426
 
1381
1427
  if (folded > 0) out.push("", `See all ${total} with ${kit.cmd("failproofai policies --all")}`);
1382
1428
  return out;
@@ -1591,20 +1637,114 @@ export async function listHooks(cwd?: string, { all = false }: { all?: boolean }
1591
1637
  // pushed to a fleet were the ones invisible to the person running the
1592
1638
  // command on it. Read-only: they belong to the deployment, which the heading
1593
1639
  // says, and `--uninstall <name>` cannot switch one off.
1640
+ //
1641
+ // Each policy's KIND is shown — `regex` (JS) or `both` (JS whose verdict
1642
+ // its own Jev checks may clear) — with the Jev check names a `both` policy
1643
+ // is reviewed by. Those checks, and every `jev` policy, run on FailproofAI
1644
+ // Cloud (CONTRACT C10): no question is on this machine, but the manifest's
1645
+ // `jevPolicies` names each Jev assignment, so a Jev-only policy gets a row
1646
+ // (tagged `jev`) like the JS ones.
1594
1647
  let cloud: PoliciesScreen["cloud"] = null;
1595
1648
  try {
1596
- const active = readActiveCloudManagedPolicies();
1597
- if (active.length > 0) {
1649
+ // Every assignment, agent-scoped ones included (B4): this lists the
1650
+ // machine's deployment, not what one hook would load.
1651
+ let active: ReturnType<typeof listActiveCloudManagedPolicies> = [];
1652
+ let jsProblem: string | null = null;
1653
+ try {
1654
+ active = listActiveCloudManagedPolicies();
1655
+ } catch (err) {
1656
+ // The Jev mode is read independently below; a machine Cloud set one on
1657
+ // still gets its section, with this said.
1658
+ jsProblem = err instanceof Error ? err.message : String(err);
1659
+ }
1660
+ let jev: ReturnType<typeof readCloudJevState> = { jevMode: null, deployment: null, errors: [] };
1661
+ try {
1662
+ jev = readCloudJevState();
1663
+ } catch {
1664
+ // Never throws by contract; guarded anyway, for the same reason as above.
1665
+ }
1666
+ // A Cloud JS policy that did not load (B5): the hook path records it in
1667
+ // errors.json for the deployment in force, so the row says "not loaded"
1668
+ // rather than ● on.
1669
+ const loadErrors = new Map<string, string>();
1670
+ try {
1671
+ for (const e of readCloudPolicyErrors() ?? []) {
1672
+ if (e.kind !== "regex" && e.kind !== "both") continue;
1673
+ const artifact = active.find((a) => a.id === e.id);
1674
+ if (artifact && (e.version === null || e.version === artifact.version)) loadErrors.set(e.id, e.message);
1675
+ }
1676
+ } catch {
1677
+ // No report: every row reads as the deployment says.
1678
+ }
1679
+ // A policy deployed here is drawn ● on. Only the hook path imports Cloud
1680
+ // JavaScript, so one that fails to load is known only after a tool call,
1681
+ // and then shows as not loaded (errors.json, above) — never as a transient
1682
+ // "not loaded yet" state of its own.
1683
+ // Jev-only assignments (`jevPolicies`): ids, versions, effects and scopes,
1684
+ // never a question. A `both` policy is in both lists and is drawn once,
1685
+ // from its JS row. Null from a server that predates the list.
1686
+ let jevAssignments: ReturnType<typeof readCloudJevAssignments> = null;
1687
+ try {
1688
+ jevAssignments = readCloudJevAssignments();
1689
+ } catch {
1690
+ // Never throws by contract.
1691
+ }
1692
+ const jsIds = new Set(active.map((a) => a.id));
1693
+ const jevOnly = (jevAssignments ?? []).filter((a) => !jsIds.has(a.id));
1694
+ const labels = [...active, ...jevOnly].some((a) => a.agentTargets?.some((tg) => tg.instanceId))
1695
+ ? readRosterLabels()
1696
+ : new Map<string, string>();
1697
+ const targetText = (targets: NonNullable<(typeof active)[number]["agentTargets"]>): string =>
1698
+ `for ${targets
1699
+ .map((tg) => (tg.instanceId ? `${tg.integration} / ${labels.get(tg.instanceId) ?? tg.instanceId}` : tg.integration))
1700
+ .join(", ")}`;
1701
+ if (active.length > 0 || jevOnly.length > 0 || jev.jevMode !== null) {
1702
+ const jevRows: PoliciesScreenRow[] = jevOnly.map((a) => {
1703
+ const tags = [
1704
+ ...(a.effect === "observe" ? ["observe"] : []),
1705
+ "jev",
1706
+ ...(a.agentTargets?.length ? [targetText(a.agentTargets)] : []),
1707
+ ];
1708
+ return { state: "on" as const, name: a.id, description: `v${a.version}`, tag: tags.join(" · ") };
1709
+ });
1598
1710
  cloud = {
1599
- deployment: active[0].deployment,
1600
- rows: active.map((artifact) => ({
1601
- state: "on" as const,
1602
- name: artifact.id,
1603
- description: `v${artifact.version}`,
1604
- // `observe` is evaluated and then has its verdict discarded, so the
1605
- // row says so rather than reading as plain enforcement.
1606
- ...(artifact.effect === "observe" ? { tag: "observe" } : {}),
1607
- })),
1711
+ deployment: active[0]?.deployment ?? jev.deployment ?? null,
1712
+ jevListed: jevAssignments !== null,
1713
+ rows: [...active.map((artifact) => {
1714
+ // Only a `both` policy carries a server-derived reviewer list (C1).
1715
+ const both = artifact.authority === "reviewable" && (artifact.reviewedBy?.length ?? 0) > 0;
1716
+ const checks = both ? (artifact.reviewedBy ?? []).join(", ") : "";
1717
+ const tags = [
1718
+ // `observe` is evaluated and then has its verdict discarded, so the
1719
+ // row says so rather than reading as plain enforcement.
1720
+ ...(artifact.effect === "observe" ? ["observe"] : []),
1721
+ // A plain JS policy stays the plain row it always was.
1722
+ ...(both ? ["both"] : []),
1723
+ // An observe `both` has its Jev half withheld by Cloud (C9.3).
1724
+ ...(both ? [artifact.effect === "observe" ? `${checks} (not asked while observed)` : `Jev checks: ${checks}`] : []),
1725
+ ...(artifact.agentTargets?.length ? [targetText(artifact.agentTargets)] : []),
1726
+ ];
1727
+ const failed = loadErrors.get(artifact.id);
1728
+ if (failed !== undefined) {
1729
+ return {
1730
+ state: "failed" as const,
1731
+ name: artifact.id,
1732
+ description: `v${artifact.version}`,
1733
+ tag: [...tags, `not loaded: ${failed}`].join(" · "),
1734
+ };
1735
+ }
1736
+ return {
1737
+ state: "on" as const,
1738
+ name: artifact.id,
1739
+ description: `v${artifact.version}`,
1740
+ ...(tags.length > 0 ? { tag: tags.join(" · ") } : {}),
1741
+ };
1742
+ }), ...jevRows],
1743
+ jevMode: jev.jevMode,
1744
+ problems: [
1745
+ ...(jsProblem !== null ? [`The Cloud JavaScript policies could not be read, so none of them is enforcing: ${jsProblem}`] : []),
1746
+ ...jev.errors.map((e) => `${e.id}${e.version === null ? "" : ` v${e.version}`}: ${e.message}`),
1747
+ ],
1608
1748
  };
1609
1749
  }
1610
1750
  } catch {
@@ -54,6 +54,8 @@ export interface MissingGuard {
54
54
  */
55
55
  export const PERMANENT_LOAD_FAILURES: ReadonlySet<string> = new Set([
56
56
  "module_not_found",
57
+ // A data file the policy reads at load is as missing on the next call.
58
+ "file_not_found",
57
59
  "syntax_error",
58
60
  "runtime_error",
59
61
  "path_missing",
@@ -738,8 +738,15 @@ export function semanticQuestions(
738
738
  * Per ENTRY, not per pack: one malformed question set must not take the rest of
739
739
  * a publisher's semantic policies with it, for the same reason one malformed
740
740
  * pack does not take the other packs with it.
741
+ *
742
+ * Exported for one reader outside this file: the shared FailproofAI Cloud
743
+ * fixtures test (`cloud-jev-policies.test.ts`). A Cloud Jev declaration is
744
+ * exactly a manifest semantic entry, and Cloud's publish validation mirrors
745
+ * this parser — so the test pins that both accept and refuse the same
746
+ * declarations, and measure their questions alike. The machine itself never
747
+ * parses a Cloud declaration: those checks run on FailproofAI Cloud (C10).
741
748
  */
742
- function parsePackSemantic(packId: string, value: unknown, warnings: string[]): SemanticManifestEntry[] {
749
+ export function parsePackSemantic(packId: string, value: unknown, warnings: string[]): SemanticManifestEntry[] {
743
750
  if (value === undefined) return [];
744
751
  if (!Array.isArray(value)) {
745
752
  warnings.push(`pack ${packId} semantic is not an array, so none of it was loaded`);
@@ -25,6 +25,7 @@ import {
25
25
  type JevReview,
26
26
  type RegexVerdict,
27
27
  type ObserveVerdict,
28
+ type ObservedCloudVerdict,
28
29
  type TwoTierReview,
29
30
  } from "./semantic/combine";
30
31
  // The code, not a copy of the string: the log line below turns on it, and a
@@ -64,6 +65,8 @@ export interface EvaluationResult {
64
65
  jevOrigin?: { packId: string; packVersion?: string };
65
66
  /** Observe mode: Jev's own deny/instruct, recorded as a "would have" (see `ObserveVerdict`). */
66
67
  observeVerdict?: ObserveVerdict;
68
+ /** An `observe` Cloud Jev assignment's would-haves (see `JevReview.observedCloud`). */
69
+ observedCloud?: ReadonlyArray<ObservedCloudVerdict>;
67
70
  };
68
71
  }
69
72
 
@@ -217,6 +220,19 @@ export async function evaluatePolicies(
217
220
  twoTier.releaseRegistry?.();
218
221
  review = await twoTier.review;
219
222
  }
223
+ // Jev asked no check about this call (Cloud's short-circuit, or nothing
224
+ // selected): it took no part, so the row carries no Jev fields at all — the
225
+ // regex result, exactly as without a review.
226
+ if (review.kind === "answered" && review.asked.length === 0 && review.decision === "allow" && !review.observedAsked?.length) {
227
+ return formatVerdict(eventType, session, toolName, regexOnly(collected.verdicts));
228
+ }
229
+ // A hard regex deny decided before FailproofAI Cloud's Jev answered, and the
230
+ // request was dropped: Jev took no part, so the row is the regex (Cloud or
231
+ // local) policy's, with no Jev fields. A local `jev.json` keeps its
232
+ // `not-consulted` row, which `jev status` counts.
233
+ if (review.kind === "not-consulted" && twoTier.cloud) {
234
+ return formatVerdict(eventType, session, toolName, regexOnly(collected.verdicts));
235
+ }
220
236
  const combined = combineTwoTier(collected.verdicts, review, twoTier.mode);
221
237
  if (combined.cleared.length > 0) {
222
238
  hookLogInfo(`jev ${twoTier.mode === "observe" ? "would clear" : "cleared"}: ${combined.cleared.join(", ")}`);
@@ -250,8 +266,13 @@ export async function evaluatePolicies(
250
266
  twoTier: {
251
267
  activity: combined.activity,
252
268
  decidedByJev: combined.decidedByJev,
253
- ...(combined.decidedByJev && review.kind === "answered" && review.origin ? { jevOrigin: review.origin } : {}),
269
+ // The deciding check's origin — and, in observe mode, the origin of the
270
+ // check that WOULD have decided (B12), so that row names its policy too.
271
+ ...((combined.decidedByJev || combined.observeVerdict) && review.kind === "answered" && review.origin
272
+ ? { jevOrigin: review.origin }
273
+ : {}),
254
274
  ...(combined.observeVerdict ? { observeVerdict: combined.observeVerdict } : {}),
275
+ ...(review.kind === "answered" && review.observedCloud?.length ? { observedCloud: review.observedCloud } : {}),
255
276
  },
256
277
  };
257
278
  }
@@ -8,6 +8,7 @@
8
8
  import type { HookEventType } from "./types";
9
9
  import type { PolicyFunction, PolicyMatcher, PolicyParamsSchema, RegisteredPolicy } from "./policy-types";
10
10
  import { effectiveReviewerNames, forgetEffectiveReviewerNames } from "./effective-reviewers";
11
+ import type { AgentIdentity } from "./agent-targets";
11
12
  import { resolvePolicyAuthority, type AuthorityDeclaration } from "./policy-authority";
12
13
 
13
14
  const REGISTRY_KEY = "__FAILPROOFAI_POLICY_REGISTRY__";
@@ -126,14 +127,14 @@ export function getPoliciesForEvent(
126
127
  }
127
128
 
128
129
  /** `cli`: the agent this pass registers for, which scopes the Jev reviewer set. */
129
- export function clearPolicies(cli?: string): void {
130
+ export function clearPolicies(cli?: string, agent: AgentIdentity | null = null): void {
130
131
  const g = globalThis as GlobalWithRegistry;
131
132
  g[REGISTRY_KEY] = [];
132
133
  setIndexCache(null);
133
134
  // The reviewer set describes the policies that are about to be registered, so
134
135
  // it is rebuilt with them. Dropping it here is also what keeps one read per
135
136
  // evaluation instead of one per policy.
136
- forgetEffectiveReviewerNames(cli);
137
+ forgetEffectiveReviewerNames(cli, agent);
137
138
  }
138
139
 
139
140
  /**
@@ -55,7 +55,12 @@
55
55
  * - **A session pause.** It suspends local policy for minutes, and a warning
56
56
  * about a policy set that is coming back shortly would be noise.
57
57
  */
58
- import { readActiveCloudManagedPolicies } from "./cloud-managed-policies";
58
+ import {
59
+ cloudAuthorityDeclaration,
60
+ cloudReviewerNames,
61
+ listActiveCloudManagedPolicies,
62
+ readCloudJevMode,
63
+ } from "./cloud-managed-policies";
59
64
  import { jevPacks } from "./effective-reviewers";
60
65
  import { resolve } from "node:path";
61
66
  import { discoverPolicyFiles } from "./custom-hooks-loader";
@@ -202,9 +207,17 @@ export function surveyReviewableCoverage(cwd?: string): ReviewableCoverage {
202
207
  }
203
208
 
204
209
  try {
205
- for (const assignment of readActiveCloudManagedPolicies()) {
206
- records.push({ authority: assignment.authority, reviewedBy: assignment.reviewedBy });
207
- }
210
+ // Exactly as `handler.ts` registers them: a `both` policy is reviewable
211
+ // only while FailproofAI Cloud's Jev mode asks, and then only by its own
212
+ // Cloud checks (`cloud:<id>/<name>`), which this machine cannot list but
213
+ // Cloud answers for. No mode, `off`, or an `observe` assignment: hard.
214
+ const mode = readCloudJevMode();
215
+ // Listed for every agent: this survey has no agent, and the hook-scoped
216
+ // read with none drops each agent-targeted assignment.
217
+ const assignments = listActiveCloudManagedPolicies();
218
+ const cloudReviewers = cloudReviewerNames(assignments, mode);
219
+ if (cloudReviewers.length > 0) reviewers = new Set([...(reviewers ?? []), ...cloudReviewers]);
220
+ for (const assignment of assignments) records.push(cloudAuthorityDeclaration(assignment, mode));
208
221
  } catch {
209
222
  // Same fail-open as the handler's own read of this file.
210
223
  }
@@ -225,7 +238,12 @@ export function surveyReviewableCoverage(cwd?: string): ReviewableCoverage {
225
238
  customFiles = 0;
226
239
  }
227
240
 
228
- return { ...countReviewable(records, reviewers), customFiles, jevChecks: reviewers?.size ?? 0 };
241
+ return {
242
+ ...countReviewable(records, reviewers),
243
+ customFiles,
244
+ // The checks THIS machine asks; a Cloud policy's checks are FailproofAI Cloud's.
245
+ jevChecks: reviewers ? [...reviewers].filter((name) => !name.startsWith("cloud:")).length : 0,
246
+ };
229
247
  }
230
248
 
231
249
  /**
@@ -0,0 +1,227 @@
1
+ /**
2
+ * FailproofAI Cloud Jev's health as this machine sees it (review M3).
3
+ *
4
+ * Under a Cloud Jev mode every gated tool call is a Cloud call, and every one
5
+ * that fails falls back to the regex alone. That trade is right for the hook
6
+ * path, and it used to be silent. This module adds two things:
7
+ *
8
+ * - **A circuit breaker.** After {@link BREAKER_FAILURES} Cloud Jev failures
9
+ * in a row (a timeout, no connection, a 5xx, a reply that cannot be used),
10
+ * Cloud Jev is not asked for {@link BREAKER_OPEN_MS}. The regex decides at
11
+ * once, instead of every tool call on the machine waiting out the Cloud
12
+ * timeout. Then ONE call goes through (half-open): an answer closes the
13
+ * breaker, and another failure opens it again. A 429 is not a failure here,
14
+ * because the Cloud transport already honours its Retry-After
15
+ * (`cloudRetryAfter` in `jev-client.ts`). Nor is a call this machine
16
+ * abandoned itself, or one its own settings stopped.
17
+ * - **A report** in `errors.json`, machine-level (`id: "jevMode"`, beside
18
+ * `jev_unconfigured`):
19
+ * - `jev_rate_limited: <n> calls fell back to regex in the last 10 min`,
20
+ * while FailproofAI Cloud refuses calls for the organization's Jev rate
21
+ * limit;
22
+ * - `jev_unavailable: …`, while the breaker is open.
23
+ *
24
+ * Both clear as soon as a call is answered again. The count is refreshed at
25
+ * most every {@link REPORT_REFRESH_MS}, so a burst of refusals does not
26
+ * become a burst of writes.
27
+ *
28
+ * The state is module-level, like the Retry-After cool-down and the throttle:
29
+ * the daemon's warm worker is the process that sees every call. A one-shot
30
+ * hook process, on a machine not set up yet, starts clean each time. The state
31
+ * is keyed by where the calls go (endpoint and machine id), so a reconnect
32
+ * starts afresh.
33
+ */
34
+ import { readCloudPolicyErrors, writeCloudPolicyErrors, type CloudPolicyError } from "../cloud-policy-errors";
35
+ import type { JevReview } from "./combine";
36
+
37
+ /** Consecutive failures that open the breaker. */
38
+ export const BREAKER_FAILURES = 3;
39
+ /** How long an open breaker skips FailproofAI Cloud Jev before it lets one call through. */
40
+ export const BREAKER_OPEN_MS = 60_000;
41
+ /** The window `jev_rate_limited` counts over. */
42
+ export const RATE_LIMIT_WINDOW_MS = 10 * 60_000;
43
+ /** A changing count is re-reported at most this often. */
44
+ export const REPORT_REFRESH_MS = 30_000;
45
+
46
+ /** Reported while FailproofAI Cloud refuses this machine's calls for the org's Jev rate limit. */
47
+ export const JEV_RATE_LIMITED = "jev_rate_limited";
48
+ /** Reported while the breaker is open. */
49
+ export const JEV_UNAVAILABLE = "jev_unavailable";
50
+
51
+ /** The fallback reason of a call the open breaker did not send (a known activity code). */
52
+ export const BREAKER_SKIP_REASON = "unavailable";
53
+
54
+ interface State {
55
+ scope: string;
56
+ failures: number;
57
+ lastFailure: string | null;
58
+ /** When an open breaker lets its next call through; null = closed. */
59
+ openUntil: number | null;
60
+ /** The half-open trial call is in flight: the others still skip. */
61
+ probing: boolean;
62
+ /** When each rate-limited fallback happened, within the window. */
63
+ rateLimited: number[];
64
+ /** The count last reported, and when, so the report changes at most every REPORT_REFRESH_MS. */
65
+ reportedRate: { n: number; at: number } | null;
66
+ /** The entries last merged into `errors.json` by {@link reportCloudJevHealth}. */
67
+ lastReport: string;
68
+ }
69
+
70
+ const fresh = (scope: string): State => ({
71
+ scope,
72
+ failures: 0,
73
+ lastFailure: null,
74
+ openUntil: null,
75
+ probing: false,
76
+ rateLimited: [],
77
+ reportedRate: null,
78
+ lastReport: "[]",
79
+ });
80
+
81
+ let state: State = fresh("");
82
+ const clock = (): number => performance.now();
83
+
84
+ /** Tests only. */
85
+ export function _resetCloudJevHealthForTest(): void {
86
+ state = fresh("");
87
+ }
88
+
89
+ function forScope(scope: string): State {
90
+ if (state.scope !== scope) state = fresh(scope);
91
+ return state;
92
+ }
93
+
94
+ /** Whether a call may go to FailproofAI Cloud now; `probe` marks the half-open trial. */
95
+ export type CloudJevGate = { ask: true; probe: boolean } | { ask: false };
96
+
97
+ /** Ask the breaker before sending a call. Taking the trial (`probe: true`) must be followed by {@link recordCloudJevResult}. */
98
+ export function cloudJevGate(scope: string, at: number = clock()): CloudJevGate {
99
+ const s = forScope(scope);
100
+ if (s.openUntil === null) return { ask: true, probe: false };
101
+ if (at < s.openUntil || s.probing) return { ask: false };
102
+ s.probing = true;
103
+ return { ask: true, probe: true };
104
+ }
105
+
106
+ /** What one Cloud call's review says about Cloud's health. */
107
+ export type CloudJevResult = "answered" | "rate-limited" | "failed" | "neutral";
108
+
109
+ /** Fallback codes that say FailproofAI Cloud Jev could not be used; `http-5xx` too. */
110
+ const FAILURES: ReadonlySet<string> = new Set(["timeout", "network", "malformed", "model-mismatch", "upstream-error", "error"]);
111
+
112
+ /**
113
+ * - `answered`: Jev's verdict arrived.
114
+ * - `rate-limited`: FailproofAI Cloud refused the call for a rate limit, or its
115
+ * Retry-After held the call (`http-429`); or the machine's own request budget
116
+ * stopped it (`rate-limited`), which a Cloud 429 empties so the calls right
117
+ * behind it do not go out (`jev-throttle.ts`).
118
+ * - `failed`: timeout, no connection, a 5xx, or a reply that could not be used.
119
+ * - `neutral`: anything that says nothing about Cloud. That covers a call this
120
+ * machine aborted (the regex decided first), its own config, and Cloud
121
+ * refusing THIS request (4xx): Cloud answered, so it is up.
122
+ */
123
+ export function classifyCloudJevReview(
124
+ review: JevReview,
125
+ opts: { reachedCloud: boolean } = { reachedCloud: true },
126
+ ): CloudJevResult {
127
+ if (review.kind === "answered") return opts.reachedCloud ? "answered" : "neutral";
128
+ if (review.kind !== "fallback") return "neutral";
129
+ const code = review.reason;
130
+ if (code === "http-429" || code === "rate-limited") return "rate-limited";
131
+ if (FAILURES.has(code) || /^http-5\d\d$/.test(code)) return "failed";
132
+ return "neutral";
133
+ }
134
+
135
+ /** Record one call's result against the breaker and the report. */
136
+ export function recordCloudJevResult(
137
+ scope: string,
138
+ result: CloudJevResult,
139
+ code: string | null,
140
+ probe: boolean,
141
+ at: number = clock(),
142
+ ): void {
143
+ if (state.scope !== scope) return; // A call to a connection this machine no longer has.
144
+ const s = state;
145
+ if (probe) s.probing = false;
146
+ switch (result) {
147
+ case "answered":
148
+ s.failures = 0;
149
+ s.lastFailure = null;
150
+ s.openUntil = null;
151
+ s.rateLimited = [];
152
+ break;
153
+ case "rate-limited":
154
+ s.rateLimited.push(at);
155
+ // A 429 is Cloud answering, so it is up: the breaker's streak ends, and
156
+ // its Retry-After, not the breaker, holds the calls behind this one. The
157
+ // machine's own budget says nothing about Cloud either way.
158
+ if (code === "http-429") {
159
+ s.failures = 0;
160
+ s.openUntil = null;
161
+ }
162
+ break;
163
+ case "failed":
164
+ s.failures += 1;
165
+ s.lastFailure = code;
166
+ if (probe || s.failures >= BREAKER_FAILURES) s.openUntil = at + BREAKER_OPEN_MS;
167
+ break;
168
+ case "neutral":
169
+ break;
170
+ }
171
+ }
172
+
173
+ /** Is `message` one of this module's reports? */
174
+ export function isCloudJevHealthMessage(message: string): boolean {
175
+ return message.startsWith(JEV_RATE_LIMITED) || message.startsWith(JEV_UNAVAILABLE);
176
+ }
177
+
178
+ /** The report as it stands: `errors.json` entries, none when all is well. */
179
+ export function cloudJevHealthErrors(at: number = clock()): CloudPolicyError[] {
180
+ const s = state;
181
+ const out: CloudPolicyError[] = [];
182
+ s.rateLimited = s.rateLimited.filter((t) => at - t < RATE_LIMIT_WINDOW_MS);
183
+ const n = s.rateLimited.length;
184
+ if (n === 0) {
185
+ s.reportedRate = null;
186
+ } else {
187
+ if (s.reportedRate === null || at - s.reportedRate.at >= REPORT_REFRESH_MS) s.reportedRate = { n, at };
188
+ const minutes = Math.round(RATE_LIMIT_WINDOW_MS / 60_000);
189
+ const count = s.reportedRate.n;
190
+ out.push({
191
+ id: "jevMode",
192
+ version: null,
193
+ kind: "daemon",
194
+ message: `${JEV_RATE_LIMITED}: ${count} call${count === 1 ? "" : "s"} fell back to regex in the last ${minutes} min`,
195
+ });
196
+ }
197
+ if (s.openUntil !== null) {
198
+ out.push({
199
+ id: "jevMode",
200
+ version: null,
201
+ kind: "daemon",
202
+ message:
203
+ `${JEV_UNAVAILABLE}: FailproofAI Cloud Jev failed ${BREAKER_FAILURES} or more calls in a row ` +
204
+ `(last: ${s.lastFailure ?? "error"}), so tool calls skip it for ${BREAKER_OPEN_MS / 1000} s at a time ` +
205
+ "and the regex decides alone",
206
+ });
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /**
212
+ * Merge the report into `errors.json` now, when it changed since this process
213
+ * last merged it, so the daemon's next poll carries it without waiting for the
214
+ * next hook. Every other entry is kept. Never throws.
215
+ */
216
+ export function reportCloudJevHealth(at: number = clock()): void {
217
+ try {
218
+ const entries = cloudJevHealthErrors(at);
219
+ const text = JSON.stringify(entries);
220
+ if (text === state.lastReport) return;
221
+ state.lastReport = text;
222
+ const report = (readCloudPolicyErrors() ?? []).filter((e) => !(e.id === "jevMode" && isCloudJevHealthMessage(e.message)));
223
+ writeCloudPolicyErrors([...report, ...entries]);
224
+ } catch {
225
+ // A report must not cost a hook its answer; the next call reports again.
226
+ }
227
+ }