failproofai 1.0.7-beta.2 → 1.0.8-beta.0

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 (225) 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 +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  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 +15 -15
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  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 +5 -5
  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 +4 -4
  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 +15 -15
  36. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  37. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  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 +6 -7
  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 +2 -2
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  55. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +18 -7
  58. package/.next/standalone/.next/server/app/settings/page.js +9 -7
  59. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/chunks/{[externals]__1j-zsg5._.js → [externals]__20kzpkf._.js} +1 -1
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l3yhx4._.js +2 -2
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +22 -0
  65. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +20 -20
  66. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +9 -9
  69. package/.next/standalone/.next/server/chunks/_1q5i8mb._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/lib_16xa545._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +5 -0
  73. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[externals]__0ohnuzs._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0qxnccm._.js → [root-of-the-server]__06pflha._.js} +2 -4
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0soxz2z._.js → [root-of-the-server]__12e7nhs._.js} +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0l44ual._.js → [root-of-the-server]__14o3ek1._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +4 -0
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19evfi8._.js +3 -0
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +4 -0
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/{_1v-jvrv._.js → _0o4xkpl._.js} +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/{_1mel6y1._.js → _1-7sqrb._.js} +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +13 -13
  97. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_0aiy-os._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0-q0umm._.js +3 -0
  104. package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9d._.js +12 -0
  105. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0g194sy._.js +3 -0
  106. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +12 -0
  107. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1kx9e0d._.js +3 -0
  108. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +5 -0
  109. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +3 -0
  113. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  114. package/.next/standalone/.next/server/pages/404.html +1 -1
  115. package/.next/standalone/.next/server/pages/500.html +1 -1
  116. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  117. package/.next/standalone/.next/server/server-reference-manifest.json +33 -22
  118. package/.next/standalone/.next/static/chunks/078gnymqoh3r4.js +1 -0
  119. package/.next/standalone/.next/static/chunks/{3brze37td_wnc.js → 0ahfwmbkpgfw4.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/{043j99m8ykg__.css → 0bhidk90e-07f.css} +1 -1
  121. package/.next/standalone/.next/static/chunks/1bu2-nv59ed6i.js +1 -0
  122. package/.next/standalone/.next/static/chunks/{3ugmd_7dyn0id.js → 1v_tp3hm8wyhe.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/2a405_e1o26ol.js +1 -0
  124. package/.next/standalone/.next/static/chunks/{3yxro_r2_o9ad.js → 2vo7qbdbvnc0o.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{0fqd7m_u81mi5.js → 2z7i-yg59w4pn.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/36uhh9el_oz8e.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{129ag2bw93bdh.js → 3bgot5v6f6s3c.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{3otmypm6j_xfo.js → 3o3f1ibfci0p7.js} +2 -2
  129. package/.next/standalone/SECURITY.md +53 -0
  130. package/.next/standalone/app/actions/get-jev-config.ts +194 -14
  131. package/.next/standalone/app/actions/update-jev-config.ts +160 -11
  132. package/.next/standalone/app/policies/hooks-client.tsx +5 -3
  133. package/.next/standalone/app/settings/jev-panel.tsx +169 -12
  134. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +22 -4
  135. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +14 -1
  136. package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +17 -3
  137. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +3 -0
  138. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +22 -0
  139. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +169 -0
  140. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +1 -1
  141. package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +65 -0
  142. package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +61 -0
  143. package/.next/standalone/package.json +9 -9
  144. package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
  145. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  146. package/.next/standalone/sdk/typescript/CHANGELOG.md +15 -1
  147. package/.next/standalone/sdk/typescript/integration/fixtures/ai-4/package-lock.json +10 -30
  148. package/.next/standalone/sdk/typescript/integration/fixtures/ai-4/package.json +3 -0
  149. package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package-lock.json +4 -16
  150. package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package.json +3 -0
  151. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-0.3/package-lock.json +13 -132
  152. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-0.3/package.json +4 -0
  153. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-dup-core/package-lock.json +4 -142
  154. package/.next/standalone/sdk/typescript/integration/fixtures/langchain-dup-core/package.json +4 -0
  155. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package-lock.json +1396 -1016
  156. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package.json +9 -0
  157. package/.next/standalone/sdk/typescript/scripts/release.mjs +30 -0
  158. package/.next/standalone/server.js +1 -1
  159. package/README.md +1 -0
  160. package/bin/failproofai.mjs +37 -6
  161. package/dist/cli.mjs +2861 -1145
  162. package/dist/worker.mjs +815 -197
  163. package/package.json +9 -9
  164. package/src/hooks/builtin-policies.ts +24 -6
  165. package/src/hooks/cloud-connection.ts +190 -1
  166. package/src/hooks/cloud-enrollment-cli.ts +56 -7
  167. package/src/hooks/cloud-introspect.ts +6 -0
  168. package/src/hooks/configure-wizard.ts +26 -6
  169. package/src/hooks/custom-hooks-loader.ts +58 -8
  170. package/src/hooks/effective-reviewers.ts +172 -8
  171. package/src/hooks/flush-cli.ts +35 -8
  172. package/src/hooks/fp-config.ts +266 -1
  173. package/src/hooks/fp-reset.ts +22 -4
  174. package/src/hooks/handler.ts +44 -7
  175. package/src/hooks/hook-activity-store.ts +13 -5
  176. package/src/hooks/jev-cli.ts +1009 -86
  177. package/src/hooks/jev-cloud-connection.ts +392 -0
  178. package/src/hooks/manager.ts +44 -6
  179. package/src/hooks/pack-cli.ts +175 -37
  180. package/src/hooks/pack-failclosed.ts +3 -0
  181. package/src/hooks/pack-manifest.ts +11 -1
  182. package/src/hooks/pack-store.ts +47 -10
  183. package/src/hooks/policy-authority.ts +73 -20
  184. package/src/hooks/policy-catalog.ts +10 -8
  185. package/src/hooks/policy-evaluator.ts +31 -8
  186. package/src/hooks/policy-registry.ts +3 -2
  187. package/src/hooks/policy-reviewability.ts +41 -12
  188. package/src/hooks/policy-types.ts +5 -3
  189. package/src/hooks/semantic/combine.ts +124 -84
  190. package/src/hooks/semantic/decide.ts +16 -0
  191. package/src/hooks/semantic/envelope.ts +18 -59
  192. package/src/hooks/semantic/evaluator.ts +11 -2
  193. package/src/hooks/semantic/facts.ts +7 -1
  194. package/src/hooks/semantic/intent.ts +8 -8
  195. package/src/hooks/semantic/jev-client.ts +540 -28
  196. package/src/hooks/semantic/jev-config.ts +586 -38
  197. package/src/hooks/semantic/jev-review.ts +26 -11
  198. package/src/hooks/semantic/jev-throttle.ts +8 -2
  199. package/src/hooks/semantic/pack-policies.ts +58 -15
  200. package/src/hooks/semantic/policies.ts +4 -3
  201. package/src/hooks/semantic/redact.ts +28 -1
  202. package/src/hooks/semantic/session-root.ts +116 -0
  203. package/src/hooks/semantic/types.ts +11 -0
  204. package/src/hooks/tui.ts +4 -0
  205. package/src/hooks/worker-server.ts +2 -1
  206. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cuho4x._.js +0 -3
  207. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1bf34x4._.js +0 -22
  208. package/.next/standalone/.next/server/chunks/src_hooks_01frwmb._.js +0 -5
  209. package/.next/standalone/.next/server/chunks/src_hooks_18qtd42._.js +0 -3
  210. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s_yomn._.js +0 -4
  211. package/.next/standalone/.next/server/chunks/ssr/_1feuvhb._.js +0 -5
  212. package/.next/standalone/.next/server/chunks/ssr/_1w_5l7t._.js +0 -3
  213. package/.next/standalone/.next/server/chunks/ssr/src_hooks_095a_79._.js +0 -5
  214. package/.next/standalone/.next/server/chunks/ssr/src_hooks_15t8kqj._.js +0 -3
  215. package/.next/standalone/.next/server/chunks/ssr/src_hooks_18k8rl0._.js +0 -12
  216. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1fm2w5z._.js +0 -3
  217. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1j0zy3v._.js +0 -3
  218. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +0 -3
  219. package/.next/standalone/.next/static/chunks/1qd741hzlmjbo.js +0 -1
  220. package/.next/standalone/.next/static/chunks/2_pltstd8-xgs.js +0 -1
  221. package/.next/standalone/.next/static/chunks/2c8j9l6j_b1ci.js +0 -1
  222. package/.next/standalone/.next/static/chunks/3-k569wzcli8q.js +0 -1
  223. /package/.next/standalone/.next/static/{gbEOjBgZAxF2UIUwZVHNu → O_b5R1axf5cb4NxIXq2kS}/_buildManifest.js +0 -0
  224. /package/.next/standalone/.next/static/{gbEOjBgZAxF2UIUwZVHNu → O_b5R1axf5cb4NxIXq2kS}/_clientMiddlewareManifest.js +0 -0
  225. /package/.next/standalone/.next/static/{gbEOjBgZAxF2UIUwZVHNu → O_b5R1axf5cb4NxIXq2kS}/_ssgManifest.js +0 -0
@@ -49,17 +49,64 @@
49
49
  * microseconds, and it means `failproofai jev setup` / `remove` take effect on
50
50
  * the very next tool call — including inside the long-lived daemon worker —
51
51
  * with no restart and no stale state to reason about.
52
+ *
53
+ * # FailproofAI Cloud (`provider: "failproofai"`)
54
+ *
55
+ * The one provider whose key is NOT in this file. `failproofai config --token`
56
+ * with a key carrying `jev:evaluate` stores that key in the `jev` slot of
57
+ * `credentials.json` and, when there is no `jev.json` yet, writes one naming
58
+ * this provider, the Cloud origin + `/enforcement/v1/jev` and `mode: "shadow"`.
59
+ * So for this provider:
60
+ *
61
+ * - the key comes from `credentials.json` (`readJevCloudCredential`), read with
62
+ * the same owner-only file, directory and size checks this file gets — a
63
+ * loose credentials file is refused, not read, and Jev is off;
64
+ * - an `apiKey` in `jev.json` makes the file invalid, and
65
+ * `FAILPROOFAI_JEV_API_KEY` is ignored: the Cloud key has exactly one home,
66
+ * so disconnecting or rotating it can never leave a second copy steering Jev;
67
+ * - `baseUrl` is required, and its ORIGIN must equal the origin the credential
68
+ * was verified against (`jev.url`). The key is only ever sent where it was
69
+ * issued, and both halves of that decision live in global files.
70
+ * - the credential counts only while the connection it came with is still on
71
+ * this machine: a policy or reporting credential on the SAME origin holding
72
+ * the SAME key, in the same file (`readJevCloudCredential`). A slot an older
73
+ * build's disconnect left behind is not a connection, and neither is one an
74
+ * older build's reconnect with another key (on hosted FailproofAI Cloud,
75
+ * possibly another org's, on the same origin) left beside it.
76
+ * - no usable credential is `not-connected` when the machine has no FailproofAI
77
+ * Cloud connection at all, and `key-lacks-jev` when it has one whose key does
78
+ * not carry `jev:evaluate` (connected with such a key, or the slot was
79
+ * cleared on a reconnect). Jev is off either way; the two remedies differ.
80
+ *
81
+ * # `mode: "off"`
82
+ *
83
+ * Every provider accepts `off | shadow | enforce`. `off` keeps the file — the
84
+ * endpoint, and for BYOK the key — while Jev does not run at all:
85
+ * `loadJevConfig` returns null exactly as for an absent file. It exists so the
86
+ * dashboard can switch the Cloud route off without deleting the file that
87
+ * `config --token` will never rewrite. An older build reads `off` as an
88
+ * invalid mode, which is also off.
52
89
  */
53
90
  import { closeSync, constants as fsConstants, fstatSync, openSync, readSync, statSync } from "node:fs";
54
91
  import { dirname } from "node:path";
92
+ import { readJevCloudCredential, type JevCloudCredential } from "../fp-config";
55
93
  import { jevConfigFile } from "../fp-home";
56
94
 
57
- export type JevProviderKind = "typesafe" | "openrouter" | "vercel" | "cloudflare" | "custom";
95
+ export type { JevCloudCredential } from "../fp-config";
96
+
97
+ export type JevProviderKind = "typesafe" | "openrouter" | "vercel" | "cloudflare" | "custom" | "failproofai";
98
+
99
+ /** `off` does not run Jev at all; `shadow` logs Jev and enforces regex; `enforce` applies the combine rules. */
100
+ export type JevConfigMode = "off" | "shadow" | "enforce";
58
101
 
59
102
  export interface JevConfig {
60
103
  provider: JevProviderKind;
104
+ /**
105
+ * The bearer key. For `failproofai` it is never in the FILE: the loader fills
106
+ * it from the `jev` slot of `credentials.json` (see the header).
107
+ */
61
108
  apiKey: string;
62
- /** Required for `custom`; an optional override otherwise. */
109
+ /** Required for `custom` and `failproofai`; an optional override otherwise. */
63
110
  baseUrl?: string;
64
111
  /** `cloudflare` only. */
65
112
  accountId?: string;
@@ -67,13 +114,49 @@ export interface JevConfig {
67
114
  model?: string;
68
115
  /** Default 3000; the reasoning is on `DEFAULT_JEV_TIMEOUT_MS` in `evaluator.ts`. */
69
116
  timeoutMs?: number;
70
- /** `shadow` logs Jev and enforces regex; `enforce` applies the combine rules. Default `enforce`. */
71
- mode?: "shadow" | "enforce";
117
+ /**
118
+ * Default `enforce`. `off` is accepted in a file but never reaches the hook
119
+ * path: `loadJevConfig` returns null for it.
120
+ */
121
+ mode?: JevConfigMode;
122
+ /**
123
+ * `failproofai` only: the origin of the Cloud credential this config was
124
+ * validated against — the one place its key may be sent. Set by
125
+ * `validateJevConfig` from the credential it was handed, never read from a
126
+ * file (a `credentialOrigin` in `jev.json` is an unknown key, ignored), and
127
+ * checked again by `validateLoadedJevConfig` before any route or transport
128
+ * is built. A Cloud config assembled by hand without it is refused there.
129
+ */
130
+ credentialOrigin?: string;
72
131
  }
73
132
 
74
- export const DEFAULT_JEV_MODE: NonNullable<JevConfig["mode"]> = "enforce";
133
+ export const DEFAULT_JEV_MODE: "shadow" | "enforce" = "enforce";
134
+
135
+ export const JEV_PROVIDER_KINDS: readonly JevProviderKind[] = ["typesafe", "openrouter", "vercel", "cloudflare", "custom", "failproofai"];
136
+
137
+ /** The provider whose key comes from this machine's FailproofAI Cloud connection. */
138
+ export const JEV_CLOUD_PROVIDER = "failproofai" as const satisfies JevProviderKind;
139
+
140
+ /**
141
+ * Where FailproofAI Cloud serves Jev, under the Cloud origin. The transport
142
+ * appends `/systemone` (`nativeEndpoint`), so the route is
143
+ * `POST <origin>/enforcement/v1/jev/systemone`.
144
+ */
145
+ export const JEV_CLOUD_BASE_PATH = "/enforcement/v1/jev";
75
146
 
76
- export const JEV_PROVIDER_KINDS: readonly JevProviderKind[] = ["typesafe", "openrouter", "vercel", "cloudflare", "custom"];
147
+ /**
148
+ * The `baseUrl` `config --token` writes for the Cloud base it connected to.
149
+ *
150
+ * Built the way every other Cloud route is — `<base>/enforcement/v1/…`, as the
151
+ * desired-state pull does (`cloud-enrollment.ts`) — so a self-hosted Cloud
152
+ * served under a path prefix gets its Jev route under the same prefix. For the
153
+ * hosted product the base IS the origin. Query and fragment are dropped: a base
154
+ * never carries either (`validateCloudUrl`).
155
+ */
156
+ export function jevCloudBaseUrl(cloudBase: string): string {
157
+ const url = new URL(cloudBase);
158
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}${JEV_CLOUD_BASE_PATH}`;
159
+ }
77
160
 
78
161
  /** The env var that may supply the key (and nothing else) when the file carries none. */
79
162
  export const JEV_API_KEY_ENV = "FAILPROOFAI_JEV_API_KEY";
@@ -160,8 +243,11 @@ export type ValidationResult<T> =
160
243
  * carries no `apiKey` and `FAILPROOFAI_JEV_API_KEY` is unset here. Callers
161
244
  * that can tell those apart (`inspectJevConfig`, and `jev status` through it)
162
245
  * use the flag rather than matching on `problem`.
246
+ *
247
+ * `notConnected` is its FailproofAI Cloud twin: the file names the Cloud
248
+ * provider and this machine holds no Jev credential for it.
163
249
  */
164
- | { ok: false; problem: string; missingKey?: true };
250
+ | { ok: false; problem: string; missingKey?: true; notConnected?: true };
165
251
 
166
252
  /** A key is visible ASCII, one line, at most 4 KiB. The message never includes the key. */
167
253
  export function validateApiKey(key: unknown): string | null {
@@ -170,11 +256,62 @@ export function validateApiKey(key: unknown): string | null {
170
256
  return null;
171
257
  }
172
258
 
259
+ /**
260
+ * Query parameter names that carry a CREDENTIAL rather than a routing choice.
261
+ *
262
+ * Matched on the parameter's name with everything but letters and digits
263
+ * removed, so `api_key`, `api-key`, `X-Api-Key` and `apiKey` are one rule
264
+ * rather than four — and `?api-version=`, the parameter the query string is
265
+ * permitted for in the first place, matches none of them.
266
+ *
267
+ * Substrings, because the real names are compounds: `access_token`,
268
+ * `subscription-key`, `authToken`, `x-functions-key`. The short ones that have
269
+ * no word to be a substring of are matched whole instead.
270
+ */
271
+ const CREDENTIAL_PARAM_PARTS = ["key", "token", "secret", "password", "passwd", "credential", "auth", "signature"] as const;
272
+ const CREDENTIAL_PARAM_NAMES = new Set(["pw", "pass", "sig", "sid", "jwt", "bearer", "session", "code", "access"]);
273
+
274
+ /** A parameter name safe to quote back: it goes into a message the CLI prints. */
275
+ const QUOTABLE_PARAM_RE = /^[A-Za-z0-9._-]{1,40}$/;
276
+
277
+ /**
278
+ * Why a base URL's query string may not be stored, or null when it may.
279
+ *
280
+ * A credential in a base URL is a mistake wherever it ends up, because the
281
+ * config file already has an `apiKey` field that is sent as a bearer and never
282
+ * printed — while the endpoint DERIVED from the base URL is logged, printed by
283
+ * `jev status`, put in error messages and returned to the dashboard. So this
284
+ * refuses the credential at the door rather than eliding it on the way out:
285
+ * eliding leaves the secret in the file and in everything the file feeds.
286
+ *
287
+ * It refuses on the parameter NAME, not on the presence of a query, because
288
+ * some proxies genuinely route on one (`?api-version=`) — see the comment on
289
+ * the normalization below. The VALUE is checked too, through the same
290
+ * `looksLikeCredential` that refuses a key pasted into `--model`: a parameter
291
+ * called `t` whose value is `sk-…` is the same mistake under a name no list
292
+ * can carry. A false refusal there turns Jev off and leaves the regex tier
293
+ * enforcing, which is the direction this whole module errs in.
294
+ */
295
+ function credentialQueryProblem(url: URL): string | null {
296
+ for (const [name, value] of url.searchParams) {
297
+ const shown = QUOTABLE_PARAM_RE.test(name) ? `?${name}=` : "one of its query parameters";
298
+ const flat = name.toLowerCase().replace(/[^a-z0-9]/g, "");
299
+ if (CREDENTIAL_PARAM_PARTS.some((part) => flat.includes(part)) || CREDENTIAL_PARAM_NAMES.has(flat)) {
300
+ return `baseUrl must not carry a credential in its query string, and ${shown} is one; put the key in the key field`;
301
+ }
302
+ if (looksLikeCredential(value)) {
303
+ return `baseUrl's ${shown} value is shaped like a credential (not repeated here); put the key in the key field`;
304
+ }
305
+ }
306
+ return null;
307
+ }
308
+
173
309
  /**
174
310
  * An endpoint base URL: https, or http to a loopback host only (a local proxy).
175
- * No credentials in the URL and no fragment — a key belongs in the key field,
176
- * where it is sent as a bearer and never printed. `validateJevConfig` further
177
- * accepts the loopback http form only in shadow mode.
311
+ * No credentials in the URL — not as userinfo, not as a query parameter — and no
312
+ * fragment, because a key belongs in the key field, where it is sent as a bearer
313
+ * and never printed. `validateJevConfig` further accepts the loopback http form
314
+ * only in shadow mode.
178
315
  */
179
316
  export function validateBaseUrl(raw: unknown): ValidationResult<string> {
180
317
  if (typeof raw !== "string" || raw.trim() === "") return { ok: false, problem: "baseUrl must be a non-empty string" };
@@ -189,6 +326,8 @@ export function validateBaseUrl(raw: unknown): ValidationResult<string> {
189
326
  }
190
327
  if (url.username || url.password) return { ok: false, problem: "baseUrl must not carry credentials; put the key in the key field" };
191
328
  if (url.hash) return { ok: false, problem: "baseUrl must not have a #fragment" };
329
+ const credentialQuery = credentialQueryProblem(url);
330
+ if (credentialQuery) return { ok: false, problem: credentialQuery };
192
331
  // Trailing slashes come off the PATH, never the string: a query string is legal
193
332
  // (some proxies want `?api-version=`), and the endpoint path is appended to
194
333
  // the pathname, so it must never end up inside the query.
@@ -196,6 +335,137 @@ export function validateBaseUrl(raw: unknown): ValidationResult<string> {
196
335
  return { ok: true, value: url.toString() };
197
336
  }
198
337
 
338
+ export interface BaseUrlWithoutQuery {
339
+ /** The URL with any `?query` taken off. */
340
+ url: string;
341
+ /** Whether there was one to take off. */
342
+ hadQuery: boolean;
343
+ }
344
+
345
+ /**
346
+ * A stored base URL with its query string (and any userinfo) removed, and
347
+ * whether it had a query.
348
+ *
349
+ * The refusal above is the source fix, and this is what makes the way OUT safe
350
+ * regardless: a file written by an older build can already carry `?token=`, and
351
+ * that file is exactly the one whose owner is being shown the URL so they can
352
+ * repair it. So nothing that crosses the wire to the dashboard, and nothing the
353
+ * CLI prints, carries a query string off a base URL — `displayEndpoint` does it
354
+ * for the derived endpoint, and this does it for the base URL itself.
355
+ *
356
+ * Takes a string rather than a `URL` because the callers that need it most are
357
+ * reading a REFUSED file, where the value may not parse at all. An unparseable
358
+ * one is cut at the first `?`, which is strictly more aggressive than parsing.
359
+ */
360
+ export function baseUrlWithoutQuery(raw: string): BaseUrlWithoutQuery {
361
+ // Userinfo (`https://svc:<key>@host`) comes off as well: it is the other place
362
+ // a URL carries a credential, and `validateBaseUrl` refuses it for that reason.
363
+ try {
364
+ const url = new URL(raw);
365
+ if (!url.search && !url.username && !url.password) return { url: raw, hadQuery: false };
366
+ const hadQuery = url.search !== "";
367
+ url.search = "";
368
+ url.username = "";
369
+ url.password = "";
370
+ return { url: url.toString(), hadQuery };
371
+ } catch {
372
+ const cut = raw.indexOf("?");
373
+ const head = (cut < 0 ? raw : raw.slice(0, cut)).replace(/^([^:/?#]+:\/\/)[^/?#]*@/, "$1");
374
+ return { url: head, hadQuery: cut >= 0 };
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Path suffixes that are an ENDPOINT, never a base. Longest first, so
380
+ * `/v1/chat/completions` strips back to `/v1` rather than to `/v1/chat`.
381
+ *
382
+ * `/systemone` is this product's own suffix; the other four are what an OpenAI-
383
+ * compatible base serves. Every one of them is the LAST segment of a request URL
384
+ * whose base is its parent, which is what makes the repair unambiguous.
385
+ */
386
+ const ENDPOINT_SUFFIXES = ["/chat/completions", "/systemone", "/completions", "/embeddings", "/models"] as const;
387
+
388
+ export interface EndpointGivenAsBase {
389
+ /** The suffix that gave it away, e.g. `/models`. */
390
+ suffix: string;
391
+ /** The base that was probably meant: the same URL with the suffix taken off. */
392
+ base: string;
393
+ }
394
+
395
+ /**
396
+ * Whether a URL is obviously an endpoint rather than an API base, and the base it
397
+ * implies.
398
+ *
399
+ * This is checked where a URL is being saved (`jev setup`, the dashboard's save),
400
+ * and deliberately not in `validateBaseUrl`: a config already on disk that names
401
+ * an endpoint as its base keeps working
402
+ * (`nativeEndpoint` does not append a second `/systemone`), and turning Jev off on
403
+ * an upgrade for a file that was routing correctly would be a worse failure than
404
+ * the one this prevents.
405
+ *
406
+ * What it prevents: `--url https://…/typesafe/v1/models` was saved without
407
+ * complaint, requests then went to `…/v1/models/systemone`, and the only signal
408
+ * was `http-404: Not Found` from `jev test`. The CLI knows `/systemone` is its own
409
+ * suffix and that a provider's base ends at a version root, so it can say so
410
+ * before anything is written.
411
+ */
412
+ export function endpointGivenAsBase(url: string): EndpointGivenAsBase | null {
413
+ let parsed: URL;
414
+ try {
415
+ parsed = new URL(url);
416
+ } catch {
417
+ return null;
418
+ }
419
+ const path = parsed.pathname.replace(/\/+$/, "");
420
+ const suffix = ENDPOINT_SUFFIXES.find((s) => path.toLowerCase().endsWith(s));
421
+ if (suffix === undefined) return null;
422
+ parsed.pathname = path.slice(0, path.length - suffix.length) || "/";
423
+ return { suffix, base: parsed.toString() };
424
+ }
425
+
426
+ /**
427
+ * The provider each known host IS, so `failproofai jev --url <url>` needs no
428
+ * `--provider`. Exact hosts only: a neighbouring name (`eu.api.typesafe.ai`, a
429
+ * corporate proxy) is `custom`, which reaches the same TypeSafe-compatible API
430
+ * at the URL given, and `--provider` is there to say otherwise.
431
+ */
432
+ const PROVIDER_BY_HOST: Readonly<Record<string, JevProviderKind>> = {
433
+ "api.typesafe.ai": "typesafe",
434
+ "openrouter.ai": "openrouter",
435
+ "ai-gateway.vercel.sh": "vercel",
436
+ "api.cloudflare.com": "cloudflare",
437
+ };
438
+
439
+ /** The provider a validated URL names, or `custom` for a host that is nobody's. */
440
+ export function providerForUrl(url: string): JevProviderKind {
441
+ try {
442
+ return PROVIDER_BY_HOST[new URL(url).hostname.toLowerCase()] ?? "custom";
443
+ } catch {
444
+ return "custom";
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Why `provider` cannot be saved with a base on `url`'s host, or null.
450
+ *
451
+ * Two pairs: a provider the host says it is not (`openrouter` on Vercel's
452
+ * gateway — the two disagree about where the key is sent), and `custom` on
453
+ * Cloudflare, whose per-account, wrapped endpoint a custom route cannot speak.
454
+ * `custom` on any other known host is "treat this URL as itself", not a
455
+ * disagreement. Checked where a URL is saved (`jev --url`, `jev setup
456
+ * --base-url`, the dashboard's save) and, like `endpointGivenAsBase`, not by
457
+ * the loader: a file already on disk keeps working.
458
+ */
459
+ export function providerHostConflict(provider: string, url: string): string | null {
460
+ const known = providerForUrl(url);
461
+ if (known === "custom" || known === provider) return null;
462
+ const host = new URL(url).host;
463
+ if (provider !== "custom") return `${host} is ${known}'s endpoint, not ${provider}'s`;
464
+ return known === "cloudflare"
465
+ ? `${host} is Cloudflare Workers AI, which provider custom cannot reach — its endpoint is per-account and its answers are wrapped, and a custom endpoint is asked in TypeSafe's own shape at <url>/systemone`
466
+ : null;
467
+ }
468
+
199
469
  function isPlainHttp(url: string): boolean {
200
470
  try {
201
471
  return new URL(url).protocol === "http:";
@@ -204,6 +474,21 @@ function isPlainHttp(url: string): boolean {
204
474
  }
205
475
  }
206
476
 
477
+ /**
478
+ * The origin a bearer token would be sent to: https, or http to a loopback host
479
+ * — the same schemes `validateBaseUrl` admits. Null for anything else, so a
480
+ * credential naming some other scheme matches no base URL at all.
481
+ */
482
+ function originOf(raw: string): string | null {
483
+ try {
484
+ const url = new URL(raw);
485
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) return null;
486
+ return url.origin;
487
+ } catch {
488
+ return null;
489
+ }
490
+ }
491
+
207
492
  /**
208
493
  * Prefixes real credentials carry: OpenAI / OpenRouter / Anthropic / Stripe
209
494
  * (`sk-`, `sk_`, `rk_`), GitHub, GitLab, Slack, AWS, Google, Hugging Face and
@@ -223,6 +508,16 @@ export function looksLikeCredential(s: string): boolean {
223
508
  return s.length >= 32 && !s.includes("/") && !/jev/i.test(s) && /[A-Za-z]/.test(s) && /[0-9]/.test(s);
224
509
  }
225
510
 
511
+ /**
512
+ * Whether a string is shaped like a model id. Used on names that came back from
513
+ * a provider's `/models` endpoint and are about to be printed: those are remote
514
+ * strings, so anything carrying a control character, an ANSI escape or a
515
+ * kilobyte of padding is dropped rather than rendered.
516
+ */
517
+ export function isModelIdShaped(s: string): boolean {
518
+ return MODEL_RE.test(s);
519
+ }
520
+
226
521
  function validateModel(raw: unknown): ValidationResult<string> {
227
522
  if (typeof raw !== "string" || !MODEL_RE.test(raw)) {
228
523
  return { ok: false, problem: "model must be 1–200 characters of letters, digits and . _ : / @ ~ + -" };
@@ -254,8 +549,17 @@ function validateModel(raw: unknown): ValidationResult<string> {
254
549
  * `envKey` is the value of `FAILPROOFAI_JEV_API_KEY`, used only when the object
255
550
  * has no `apiKey`. Unknown top-level keys are ignored so a newer failproofai's
256
551
  * file does not switch Jev off on an older one. Problems never quote the key.
552
+ *
553
+ * `cloud` is the `jev` slot of `credentials.json`, and is read ONLY for the
554
+ * FailproofAI Cloud provider, which takes its key from nowhere else: not the
555
+ * file (an `apiKey` there is refused) and not `envKey` (ignored). The file's
556
+ * `baseUrl` must sit on the origin the credential was verified against.
257
557
  */
258
- export function validateJevConfig(raw: unknown, envKey?: string | null): ValidationResult<JevConfig> {
558
+ export function validateJevConfig(
559
+ raw: unknown,
560
+ envKey?: string | null,
561
+ cloud?: JevCloudCredential | null,
562
+ ): ValidationResult<JevConfig> {
259
563
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, problem: "the file must hold a JSON object" };
260
564
  const o = raw as Record<string, unknown>;
261
565
 
@@ -266,7 +570,31 @@ export function validateJevConfig(raw: unknown, envKey?: string | null): Validat
266
570
  const kind = provider as JevProviderKind;
267
571
 
268
572
  let apiKey: string;
269
- if (o.apiKey !== undefined) {
573
+ if (kind === JEV_CLOUD_PROVIDER) {
574
+ // One home for the Cloud key, so revoking it at disconnect cannot leave a
575
+ // copy behind that keeps Jev spending — and so nothing a session can set
576
+ // (the environment) or a second file can hold decides which key is sent.
577
+ if (o.apiKey !== undefined) {
578
+ return {
579
+ ok: false,
580
+ problem:
581
+ "provider failproofai takes its key from this machine's FailproofAI Cloud connection (credentials.json), " +
582
+ "never from jev.json — remove apiKey from the file (not repeated here)",
583
+ };
584
+ }
585
+ if (!cloud) {
586
+ return {
587
+ ok: false,
588
+ problem:
589
+ "this machine is not connected to FailproofAI Cloud with a key that carries jev:evaluate — " +
590
+ "connect it with: failproofai config --token <key>",
591
+ notConnected: true,
592
+ };
593
+ }
594
+ const bad = validateApiKey(cloud.key);
595
+ if (bad) return { ok: false, problem: `the FailproofAI Cloud key in credentials.json is unusable: ${bad}` };
596
+ apiKey = cloud.key;
597
+ } else if (o.apiKey !== undefined) {
270
598
  const bad = validateApiKey(o.apiKey);
271
599
  if (bad) return { ok: false, problem: bad };
272
600
  apiKey = o.apiKey as string;
@@ -288,8 +616,30 @@ export function validateJevConfig(raw: unknown, envKey?: string | null): Validat
288
616
  const r = validateBaseUrl(o.baseUrl);
289
617
  if (!r.ok) return r;
290
618
  cfg.baseUrl = r.value;
291
- } else if (kind === "custom") {
292
- return { ok: false, problem: "provider custom needs a baseUrl" };
619
+ } else if (kind === "custom" || kind === JEV_CLOUD_PROVIDER) {
620
+ return { ok: false, problem: `provider ${kind} needs a baseUrl` };
621
+ }
622
+
623
+ if (kind === JEV_CLOUD_PROVIDER && cloud) {
624
+ // The key goes only to the origin it was issued for. Compared as origins
625
+ // (scheme + host + port), which is what decides where a bearer token lands;
626
+ // the path under it is the Cloud's own routing.
627
+ const credentialOrigin = originOf(cloud.url);
628
+ const baseOrigin = originOf(cfg.baseUrl as string);
629
+ if (credentialOrigin === null) {
630
+ return { ok: false, problem: "the FailproofAI Cloud credential in credentials.json names no usable origin — reconnect: failproofai config --token <key>" };
631
+ }
632
+ if (baseOrigin !== credentialOrigin) {
633
+ return {
634
+ ok: false,
635
+ problem:
636
+ `baseUrl is on ${baseOrigin ?? "an unusable origin"}, but this machine's FailproofAI Cloud key was issued for ${credentialOrigin} — ` +
637
+ "the key is only ever sent to the origin it was issued for. Point jev.json back at it: failproofai jev setup --provider failproofai",
638
+ };
639
+ }
640
+ // Carried with the config, so the check can be made again — for real —
641
+ // wherever the config is used (`validateLoadedJevConfig`).
642
+ cfg.credentialOrigin = credentialOrigin;
293
643
  }
294
644
 
295
645
  if (kind === "cloudflare") {
@@ -317,7 +667,7 @@ export function validateJevConfig(raw: unknown, envKey?: string | null): Validat
317
667
  }
318
668
 
319
669
  if (o.mode !== undefined) {
320
- if (o.mode !== "shadow" && o.mode !== "enforce") return { ok: false, problem: 'mode must be "shadow" or "enforce"' };
670
+ if (o.mode !== "off" && o.mode !== "shadow" && o.mode !== "enforce") return { ok: false, problem: 'mode must be "off", "shadow" or "enforce"' };
321
671
  cfg.mode = o.mode;
322
672
  }
323
673
 
@@ -325,25 +675,107 @@ export function validateJevConfig(raw: unknown, envKey?: string | null): Validat
325
675
  // authenticates the server there: while the local proxy is down, any process
326
676
  // of this user — the agent being judged included — can bind its port and
327
677
  // answer "none" to every question. In enforce mode that answer clears
328
- // reviewable denies; in shadow mode it changes nothing, so that is the only
329
- // mode it is accepted in.
330
- if (cfg.baseUrl !== undefined && isPlainHttp(cfg.baseUrl) && cfg.mode !== "shadow") {
678
+ // reviewable denies; in shadow mode it changes nothing, and `off` sends
679
+ // nothing at all, so enforce is the one mode it is refused in.
680
+ if (cfg.baseUrl !== undefined && isPlainHttp(cfg.baseUrl) && cfg.mode === "enforce") {
331
681
  return {
332
682
  ok: false,
333
683
  problem:
334
684
  "plain http (to localhost) is accepted only with mode shadow: in enforce mode Jev's answers can clear a deny, " +
335
- "and while the local proxy is down any process on this machine could take its port and answer. Use https, or mode shadow",
685
+ "and while the local proxy is down any process on this machine could take its port and answer. " +
686
+ (cfg.provider === JEV_CLOUD_PROVIDER
687
+ ? "Reconnect to an https FailproofAI Cloud URL (failproofai config --token <key> --url https://…), or keep mode shadow"
688
+ : "Use https, or mode shadow"),
336
689
  };
337
690
  }
338
691
 
339
692
  return { ok: true, value: cfg };
340
693
  }
341
694
 
695
+ /**
696
+ * Re-validate a config that was already LOADED — by `loadJevConfig` or
697
+ * `inspectJevConfig` — before a route or a transport is built from it.
698
+ *
699
+ * For every BYOK provider that is `validateJevConfig` as it always was. The
700
+ * FailproofAI Cloud provider is the one whose in-memory `apiKey` is not a file
701
+ * field: the loader filled it from `credentials.json`, and `validateJevConfig`
702
+ * would refuse the very key it put there as a file's `apiKey`. So the key goes
703
+ * back in the credential slot — under `credentialOrigin`, the origin of the
704
+ * credential the loader validated against, which the loader recorded on the
705
+ * config. The origin check therefore runs again, for real: a config whose
706
+ * `baseUrl` has since moved to another origin is refused, and so is a Cloud
707
+ * config assembled by hand with no `credentialOrigin` at all, so a future
708
+ * caller cannot build one and skip the check. (Display-only callers that build
709
+ * a stand-in on purpose name the origin they mean explicitly.)
710
+ */
711
+ export function validateLoadedJevConfig(cfg: JevConfig): ValidationResult<JevConfig> {
712
+ if (cfg.provider !== JEV_CLOUD_PROVIDER) return validateJevConfig(cfg);
713
+ const { apiKey, credentialOrigin, ...rest } = cfg;
714
+ if (typeof credentialOrigin !== "string" || credentialOrigin === "") {
715
+ return {
716
+ ok: false,
717
+ problem: "this FailproofAI Cloud config names no credential origin — it did not come from the loader, so its key is not sent anywhere",
718
+ };
719
+ }
720
+ return validateJevConfig(rest, null, { url: credentialOrigin, key: apiKey });
721
+ }
722
+
342
723
  // ── Loading ──────────────────────────────────────────────────────────────────
343
724
 
344
725
  export type JevConfigInspection =
345
726
  | { status: "absent"; path: string }
346
- | { status: "ok"; path: string; mode: number | null; keySource: "file" | "env"; config: JevConfig }
727
+ | {
728
+ status: "ok";
729
+ path: string;
730
+ mode: number | null;
731
+ /** `cloud`: the `jev` slot of `credentials.json` (the FailproofAI Cloud provider). */
732
+ keySource: "file" | "env" | "cloud";
733
+ config: JevConfig;
734
+ }
735
+ | {
736
+ /**
737
+ * A sound file that says `mode: "off"`. Jev does not run — `loadJevConfig`
738
+ * returns null — and nothing is wrong: the owner switched it off and kept
739
+ * the endpoint (and, for BYOK, the key) for later. Reported apart from
740
+ * `absent` so `jev status` and the dashboard can say "switched off"
741
+ * rather than "not configured", and apart from `ok` so no reader that
742
+ * takes `ok` to mean "on" can be wrong about it.
743
+ */
744
+ status: "off";
745
+ path: string;
746
+ mode: number | null;
747
+ /** Everything the file says except the key. */
748
+ routing: Omit<JevConfig, "apiKey">;
749
+ }
750
+ | {
751
+ /**
752
+ * The file names the FailproofAI Cloud provider and is sound, but this
753
+ * machine holds no Jev credential (never connected with a `jev:evaluate`
754
+ * key, or disconnected since). Jev is off. The FailproofAI Cloud twin of
755
+ * `key-missing`: nothing is wrong with the FILE, so no reader should tell
756
+ * its owner to rewrite it.
757
+ */
758
+ status: "not-connected";
759
+ path: string;
760
+ mode: number | null;
761
+ routing: Omit<JevConfig, "apiKey">;
762
+ problem: string;
763
+ }
764
+ | {
765
+ /**
766
+ * The file names the FailproofAI Cloud provider and is sound, and this
767
+ * machine IS connected to FailproofAI Cloud — but with a key that does not
768
+ * carry `jev:evaluate` (connected with one, or reconnected with one since,
769
+ * which clears the slot). Jev is off. Apart from `not-connected` because
770
+ * telling a connected machine it is "not connected" sends its owner to
771
+ * reconnect with the same key, when the remedy is a key with Jev.
772
+ */
773
+ status: "key-lacks-jev";
774
+ path: string;
775
+ mode: number | null;
776
+ routing: Omit<JevConfig, "apiKey">;
777
+ problem: string;
778
+ }
347
779
  | {
348
780
  /**
349
781
  * The file is sound and names `FAILPROOFAI_JEV_API_KEY` as the key's
@@ -371,8 +803,22 @@ export type JevConfigInspection =
371
803
  problem: string;
372
804
  /** The command that fixes it, when one does. */
373
805
  fix?: string;
806
+ /** When the refusal is about credentials.json: that file's permission bits (`mode` stays jev.json's). */
807
+ credentialsMode?: number | null;
808
+ /** A too-open file with no key in it: refused as firmly, but no key was exposed. */
809
+ keyless?: true;
374
810
  };
375
811
 
812
+ /** Whether config text is an object with no `apiKey`; anything else may hold one. */
813
+ function namesNoKey(text: string): boolean {
814
+ try {
815
+ const o: unknown = JSON.parse(text);
816
+ return o !== null && typeof o === "object" && !Array.isArray(o) && (o as Record<string, unknown>).apiKey === undefined;
817
+ } catch {
818
+ return false;
819
+ }
820
+ }
821
+
376
822
  function readEnvKey(): string | null {
377
823
  const v = process.env[JEV_API_KEY_ENV];
378
824
  return v === undefined || v === "" ? null : v;
@@ -442,14 +888,40 @@ export function inspectJevConfig(): JevConfigInspection {
442
888
  const st = fstatSync(fd);
443
889
  if (!st.isFile()) return { status: "refused", path, mode: null, reason: "unreadable", problem: "it is not a regular file" };
444
890
  mode = st.mode & 0o777;
891
+ const readText = (): string => {
892
+ const buf = Buffer.alloc(st.size);
893
+ let off = 0;
894
+ while (off < buf.length) {
895
+ const n = readSync(fd, buf, off, buf.length - off, off);
896
+ if (n === 0) break;
897
+ off += n;
898
+ }
899
+ return buf.subarray(0, off).toString("utf8");
900
+ };
445
901
  if (modesAreMeaningful() && (mode & 0o077) !== 0) {
902
+ // Read only to word the refusal: a file with no key in it (the Cloud's,
903
+ // whose key is in credentials.json, or a key-from-env one) is refused as
904
+ // firmly, but telling its owner a key leaked sends them to rotate one
905
+ // that never did.
906
+ let keyless = false;
907
+ try {
908
+ keyless = st.size <= MAX_CONFIG_BYTES && namesNoKey(readText());
909
+ } catch {
910
+ // Unreadable: it may hold one.
911
+ }
912
+ const perms = mode.toString(8).padStart(4, "0");
446
913
  return {
447
914
  status: "refused",
448
915
  path,
449
916
  mode,
450
917
  reason: "too-open",
451
- problem: `its permissions are ${mode.toString(8).padStart(4, "0")}; it holds a key, so it must be owner-only (chmod 600 ${path})`,
918
+ problem: !keyless
919
+ ? `its permissions are ${perms}; it holds a key, so it must be owner-only (chmod 600 ${path})`
920
+ : (mode & 0o022) !== 0
921
+ ? `its permissions are ${perms}; other users could change it, and with it where Jev sends tool calls and prompts, so it must be owner-only (chmod 600 ${path})`
922
+ : `its permissions are ${perms}; it must be owner-only (chmod 600 ${path})`,
452
923
  fix: `chmod 600 ${path}`,
924
+ ...(keyless ? { keyless: true as const } : {}),
453
925
  };
454
926
  }
455
927
  const dirMode = looseConfigDirMode(path);
@@ -467,14 +939,7 @@ export function inspectJevConfig(): JevConfigInspection {
467
939
  };
468
940
  }
469
941
  if (st.size > MAX_CONFIG_BYTES) return { status: "refused", path, mode, reason: "too-large", problem: `it is larger than ${MAX_CONFIG_BYTES} bytes` };
470
- const buf = Buffer.alloc(st.size);
471
- let off = 0;
472
- while (off < buf.length) {
473
- const n = readSync(fd, buf, off, buf.length - off, off);
474
- if (n === 0) break;
475
- off += n;
476
- }
477
- text = buf.subarray(0, off).toString("utf8");
942
+ text = readText();
478
943
  } catch (err) {
479
944
  return { status: "refused", path, mode, reason: "unreadable", problem: `cannot read it (${(err as NodeJS.ErrnoException).code ?? "error"})` };
480
945
  } finally {
@@ -491,6 +956,50 @@ export function inspectJevConfig(): JevConfigInspection {
491
956
  } catch {
492
957
  return { status: "refused", path, mode, reason: "not-json", problem: "it is not valid JSON" };
493
958
  }
959
+ const isObject = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
960
+ const fields = isObject ? (parsed as Record<string, unknown>) : null;
961
+
962
+ // Switched off. Checked before any key is looked for, because nothing will be
963
+ // sent: a BYOK file whose key lives in the environment, and a Cloud file on a
964
+ // machine that has since disconnected, are both simply "off". The rest of the
965
+ // file is still validated — with stand-ins in the key slots, which are never
966
+ // sent anywhere — so a file that is off AND broken is reported as broken.
967
+ if (fields?.mode === "off") {
968
+ const r = validateJevConfig(parsed, KEY_STAND_IN, standInCloudCredential(fields));
969
+ if (!r.ok) return { status: "refused", path, mode, reason: "invalid", problem: r.problem };
970
+ return { status: "off", path, mode, routing: routingOf(r.value) };
971
+ }
972
+
973
+ if (fields?.provider === JEV_CLOUD_PROVIDER) {
974
+ // The key lives in credentials.json, which is read with the same owner-only
975
+ // checks as this file: a credentials file someone else could have written
976
+ // is refused, not read, and Jev is off.
977
+ const credential = readJevCloudCredential();
978
+ if (credential.status === "refused") {
979
+ return {
980
+ status: "refused",
981
+ path,
982
+ mode,
983
+ reason: credential.reason === "too-open" ? "too-open" : "unreadable",
984
+ problem: `the FailproofAI Cloud credential was refused: ${credential.problem}`,
985
+ ...(credential.fix ? { fix: credential.fix } : {}),
986
+ credentialsMode: credential.mode,
987
+ };
988
+ }
989
+ // `FAILPROOFAI_JEV_API_KEY` is deliberately not passed: see the header.
990
+ const r = validateJevConfig(parsed, null, credential.status === "ok" ? credential.credential : null);
991
+ if (r.ok) return { status: "ok", path, mode, keySource: "cloud", config: r.value };
992
+ if (r.notConnected === true) {
993
+ const rest = validateJevConfig(parsed, null, standInCloudCredential(fields));
994
+ if (!rest.ok) return { status: "refused", path, mode, reason: "invalid", problem: rest.problem };
995
+ if (credential.status === "absent" && credential.connected) {
996
+ return { status: "key-lacks-jev", path, mode, routing: routingOf(rest.value), problem: KEY_LACKS_JEV_PROBLEM };
997
+ }
998
+ return { status: "not-connected", path, mode, routing: routingOf(rest.value), problem: r.problem };
999
+ }
1000
+ return { status: "refused", path, mode, reason: "invalid", problem: r.problem };
1001
+ }
1002
+
494
1003
  const envKey = readEnvKey();
495
1004
  const r = validateJevConfig(parsed, envKey);
496
1005
  if (!r.ok) {
@@ -499,15 +1008,11 @@ export function inspectJevConfig(): JevConfigInspection {
499
1008
  // validation never got that far, so ask again with a stand-in key.
500
1009
  const rest = validateJevConfig(parsed, KEY_STAND_IN);
501
1010
  if (rest.ok) {
502
- const routing: Omit<JevConfig, "apiKey"> = { provider: rest.value.provider, mode: rest.value.mode, timeoutMs: rest.value.timeoutMs };
503
- if (rest.value.baseUrl !== undefined) routing.baseUrl = rest.value.baseUrl;
504
- if (rest.value.accountId !== undefined) routing.accountId = rest.value.accountId;
505
- if (rest.value.model !== undefined) routing.model = rest.value.model;
506
1011
  return {
507
1012
  status: "key-missing",
508
1013
  path,
509
1014
  mode,
510
- routing,
1015
+ routing: routingOf(rest.value),
511
1016
  problem: `it carries no apiKey, so the key comes from ${JEV_API_KEY_ENV} — which is not set in this environment`,
512
1017
  };
513
1018
  }
@@ -520,14 +1025,57 @@ export function inspectJevConfig(): JevConfigInspection {
520
1025
  }
521
1026
 
522
1027
  /**
523
- * The validated global config, or null — absent, refused, invalid, or a
524
- * key-from-the-environment file in an environment that does not set it all
525
- * mean Jev is off and the regex path runs unchanged. Never throws.
1028
+ * Why a Cloud file on a connected machine is off when no Jev key is stored for
1029
+ * the connection (`key-lacks-jev`). Hedged: a connect whose introspect went
1030
+ * unanswered clears the slot too, so nothing on disk says what the key carries.
1031
+ */
1032
+ export const KEY_LACKS_JEV_PROBLEM =
1033
+ "this machine is connected to FailproofAI Cloud, but no Jev key is stored for that connection (its key lacks jev:evaluate, or the connect could not confirm it)";
1034
+
1035
+ /** Everything a validated config says except its key — copied field by field, so the key cannot ride along. */
1036
+ function routingOf(cfg: JevConfig): Omit<JevConfig, "apiKey"> {
1037
+ const routing: Omit<JevConfig, "apiKey"> = { provider: cfg.provider, mode: cfg.mode, timeoutMs: cfg.timeoutMs };
1038
+ if (cfg.baseUrl !== undefined) routing.baseUrl = cfg.baseUrl;
1039
+ if (cfg.accountId !== undefined) routing.accountId = cfg.accountId;
1040
+ if (cfg.model !== undefined) routing.model = cfg.model;
1041
+ // Never `credentialOrigin`: every status that reports routing is one with no
1042
+ // usable credential behind it, so there is no origin to report — only the
1043
+ // stand-in it was validated with.
1044
+ return routing;
1045
+ }
1046
+
1047
+ /**
1048
+ * A Cloud credential that stands in for a missing one, on the file's own origin,
1049
+ * so the rest of a Cloud file can be validated when there is no key to check it
1050
+ * with. Like `KEY_STAND_IN` it is never written and never sent; an unparseable
1051
+ * `baseUrl` gets a placeholder origin and is refused by `validateBaseUrl` first.
1052
+ */
1053
+ function standInCloudCredential(fields: Record<string, unknown>): JevCloudCredential {
1054
+ let url = "https://stand-in.invalid";
1055
+ if (typeof fields.baseUrl === "string") {
1056
+ try {
1057
+ url = new URL(fields.baseUrl.trim()).origin;
1058
+ } catch {
1059
+ // Keep the placeholder.
1060
+ }
1061
+ }
1062
+ return { url, key: KEY_STAND_IN };
1063
+ }
1064
+
1065
+ /**
1066
+ * The validated global config, or null — absent, refused, invalid, switched
1067
+ * off, a key-from-the-environment file in an environment that does not set it,
1068
+ * or a FailproofAI Cloud file on a machine with no Cloud credential all mean
1069
+ * Jev is off and the regex path runs unchanged. Never throws.
1070
+ *
1071
+ * `mode: "off"` never comes out of here: `inspectJevConfig` reports it as its
1072
+ * own status, and the check below is the belt to that brace — an off config
1073
+ * reaching the hook path would be read as the default mode, `enforce`.
526
1074
  */
527
1075
  export function loadJevConfig(): JevConfig | null {
528
1076
  try {
529
1077
  const r = inspectJevConfig();
530
- return r.status === "ok" ? r.config : null;
1078
+ return r.status === "ok" && r.config.mode !== "off" ? r.config : null;
531
1079
  } catch {
532
1080
  return null;
533
1081
  }