failproofai 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +4 -4
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +4 -4
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +8 -7
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +9 -7
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +6 -6
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +11 -10
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +8 -7
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +6 -6
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +7 -6
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +52 -8
- package/.next/standalone/.next/server/app/settings/page.js +12 -7
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1msfs-h._.js → [externals]__19_pzeq._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1_bftcl._.js → [externals]__20kzpkf._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ttrwnd._.js +22 -0
- package/.next/standalone/.next/server/chunks/_09dz7xv._.js +21 -21
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +16 -16
- package/.next/standalone/.next/server/chunks/{_1c3k-8x._.js → _1q5i8mb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/lib_16xa545._.js +3 -0
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +5 -0
- package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +3 -0
- package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +4 -2
- package/.next/standalone/.next/server/chunks/ssr/[externals]__0ohnuzs._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01bmjsj._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__02npjtd._.js → [root-of-the-server]__056wjo4._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__059yza8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06pflha._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0eip4_k._.js +22 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0p-5p8u._.js → [root-of-the-server]__0n0xg95._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qcb0mg._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__013jr2b._.js → [root-of-the-server]__0rwtwpm._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0yrsbd_._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0da85px._.js → [root-of-the-server]__11mayhe._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12e7nhs._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13d-wb6._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13t1zkw._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19evfi8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1dinjii._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0cxe_2_._.js → [root-of-the-server]__1mf3zp6._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ftmoxc._.js → [root-of-the-server]__1pprgri._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1p2otjt._.js → [root-of-the-server]__1q4p5b8._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qiz0e4._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_166t73i._.js → _0-vcssj._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0uyu3jf._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_13kfn90._.js +23 -0
- package/.next/standalone/.next/server/chunks/ssr/{_1es2j7i._.js → _1gb0ifp._.js} +5 -5
- package/.next/standalone/.next/server/chunks/ssr/{_1_qswah._.js → _1q46vxx._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +13 -13
- package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_next_dist_0drixxt._.js → node_modules_next_dist_0w6mzq5._.js} +4 -4
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_18_d8l1._.js +151 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0-q0umm._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9d._.js +12 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_0g194sy._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +4 -2
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1kx9e0d._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +67 -23
- package/.next/standalone/.next/static/chunks/06dnzbolj00mc.js +6 -0
- package/.next/standalone/.next/static/chunks/0bhidk90e-07f.css +2 -0
- package/.next/standalone/.next/static/chunks/{2mdh397ghgnvv.js → 0m-9d6yn9hx4j.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0__8a7m868fvf.js → 0wbjy0zo-m7is.js} +1 -1
- package/.next/standalone/.next/static/chunks/16f3fa-lx38hk.js +1 -0
- package/.next/standalone/.next/static/chunks/{0o6qlkgubtoex.js → 21uv-uusw329x.js} +1 -1
- package/.next/standalone/.next/static/chunks/2g2tki08kdhie.js +1 -0
- package/.next/standalone/.next/static/chunks/2qdpj67x6ifk_.js +69 -0
- package/.next/standalone/.next/static/chunks/2qv4hshejedtx.css +1 -0
- package/.next/standalone/.next/static/chunks/3-nbtkhg9y-1j.js +1 -0
- package/.next/standalone/.next/static/chunks/355km0ihuqo1p.js +1 -0
- package/.next/standalone/.next/static/chunks/{13i7-9is-vhys.js → 3c3qbmosdjl6w.js} +1 -1
- package/.next/standalone/PROBE-FOLLOWUP.md +186 -0
- package/.next/standalone/app/actions/get-jev-config.ts +604 -0
- package/.next/standalone/app/actions/pack-actions.ts +12 -0
- package/.next/standalone/app/actions/update-jev-config.ts +569 -0
- package/.next/standalone/app/components/jev-notices.tsx +96 -0
- package/.next/standalone/app/policies/hooks-client.tsx +14 -3
- package/.next/standalone/app/settings/jev-panel.tsx +630 -0
- package/.next/standalone/app/settings/page.tsx +20 -1
- package/.next/standalone/app/settings/settings-client.tsx +27 -1
- package/.next/standalone/app/settings/settings.css +79 -0
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +22 -4
- package/.next/standalone/fp-cloud-cli/fp_cli/client.py +14 -1
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +17 -3
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +3 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +22 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +169 -0
- package/.next/standalone/fp-cloud-cli/skill/references/commands.md +1 -1
- package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +65 -0
- package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +61 -0
- package/.next/standalone/package.json +10 -10
- package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
- package/.next/standalone/sdk/typescript/CHANGELOG.md +18 -1
- package/.next/standalone/sdk/typescript/integration/fixtures/nextjs/package-lock.json +0 -274
- package/.next/standalone/sdk/typescript/scripts/release.mjs +30 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +3 -2
- package/bin/failproofai.mjs +148 -4
- package/dist/cli.mjs +18599 -9478
- package/dist/index.js +19 -1
- package/dist/worker.mjs +10500 -3773
- package/package.json +10 -10
- package/pi-extension/index.ts +11 -0
- package/scripts/build-policy-pack.mjs +53 -2
- package/src/audit/features.ts +3 -2
- package/src/hooks/builtin-policies.ts +195 -8
- package/src/hooks/cloud-connection.ts +190 -1
- package/src/hooks/cloud-enrollment-cli.ts +57 -8
- package/src/hooks/cloud-introspect.ts +6 -0
- package/src/hooks/cloud-managed-policies.ts +22 -0
- package/src/hooks/configure-wizard.ts +26 -6
- package/src/hooks/custom-hooks-loader.ts +98 -10
- package/src/hooks/custom-hooks-registry.ts +45 -1
- package/src/hooks/effective-reviewers.ts +243 -0
- package/src/hooks/first-run-gate.ts +5 -0
- package/src/hooks/flush-cli.ts +35 -8
- package/src/hooks/fp-config.ts +266 -1
- package/src/hooks/fp-home.ts +23 -0
- package/src/hooks/fp-reset.ts +22 -4
- package/src/hooks/handler.ts +303 -7
- package/src/hooks/hook-activity-store.ts +118 -6
- package/src/hooks/hook-telemetry.ts +41 -0
- package/src/hooks/jev-activity.ts +385 -0
- package/src/hooks/jev-cli.ts +2172 -0
- package/src/hooks/jev-cloud-connection.ts +392 -0
- package/src/hooks/loader-utils.ts +6 -0
- package/src/hooks/manager.ts +45 -7
- package/src/hooks/pack-cli.ts +568 -37
- package/src/hooks/pack-failclosed.ts +3 -0
- package/src/hooks/pack-manifest.ts +489 -7
- package/src/hooks/pack-store.ts +194 -11
- package/src/hooks/policy-authority.ts +386 -0
- package/src/hooks/policy-catalog.ts +206 -0
- package/src/hooks/policy-evaluator.ts +963 -796
- package/src/hooks/policy-registry.ts +27 -1
- package/src/hooks/policy-reviewability.ts +258 -0
- package/src/hooks/policy-types.ts +128 -0
- package/src/hooks/semantic/combine.ts +581 -0
- package/src/hooks/semantic/compile.ts +176 -0
- package/src/hooks/semantic/decide.ts +513 -0
- package/src/hooks/semantic/envelope.ts +1255 -0
- package/src/hooks/semantic/evaluator.ts +556 -0
- package/src/hooks/semantic/facts.ts +387 -0
- package/src/hooks/semantic/intent.ts +1190 -0
- package/src/hooks/semantic/jev-client.ts +1155 -0
- package/src/hooks/semantic/jev-config.ts +1142 -0
- package/src/hooks/semantic/jev-review.ts +389 -0
- package/src/hooks/semantic/jev-stats.ts +289 -0
- package/src/hooks/semantic/jev-throttle.ts +427 -0
- package/src/hooks/semantic/pack-policies.ts +294 -0
- package/src/hooks/semantic/policies.ts +597 -0
- package/src/hooks/semantic/precondition-names.ts +60 -0
- package/src/hooks/semantic/preconditions.ts +58 -0
- package/src/hooks/semantic/redact.ts +2937 -0
- package/src/hooks/semantic/session-root.ts +116 -0
- package/src/hooks/semantic/types.ts +162 -0
- package/src/hooks/semver-precedence.ts +128 -0
- package/src/hooks/tui.ts +4 -0
- package/src/hooks/types.ts +1 -1
- package/src/hooks/worker-server.ts +121 -27
- package/src/index.ts +6 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cuho4x._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1_r2rbg._.js +0 -24
- package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-_ki57._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bd3mje._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cg-bgc._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0cpu_mj._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u3w0ll._.js +0 -22
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17d_ffl._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19d9tgz._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1jiwfsj._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1phc187._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_06imw3p._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/_0h_douw._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1-i_gzc._.js +0 -23
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +0 -3
- package/.next/standalone/.next/static/chunks/094xgi4owxaqf.js +0 -1
- package/.next/standalone/.next/static/chunks/1rz20_pz828f3.js +0 -6
- package/.next/standalone/.next/static/chunks/2k9f4tyv04809.css +0 -1
- package/.next/standalone/.next/static/chunks/2klitrtzpaoe0.js +0 -1
- package/.next/standalone/.next/static/chunks/2rshywgeqsyzk.css +0 -2
- package/.next/standalone/.next/static/chunks/3pzx4chkhko9k.js +0 -1
- package/.next/standalone/.next/static/chunks/3rh5o7e16irrm.js +0 -69
- package/.next/standalone/.next/static/chunks/43ufqrz8qo3h-.js +0 -1
- /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → 5yKM8NoTOpUI0MoQl8eo5}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → 5yKM8NoTOpUI0MoQl8eo5}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{PgeWCHmyVbjRznv2VO7KF → 5yKM8NoTOpUI0MoQl8eo5}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The customer's own Jev configuration (BYOK) — the single opt-in to the
|
|
3
|
+
* two-tier evaluator.
|
|
4
|
+
*
|
|
5
|
+
* `loadJevConfig()` returning null means Jev is off and the regex engine runs
|
|
6
|
+
* exactly as it does without this module.
|
|
7
|
+
*
|
|
8
|
+
* # Global only
|
|
9
|
+
*
|
|
10
|
+
* The file is `~/.failproofai/jev.json` and nothing else. A repository's config
|
|
11
|
+
* can never set or override it, because a cloned repo choosing the endpoint
|
|
12
|
+
* that judges its own tool calls would be choosing its own verdict. So:
|
|
13
|
+
*
|
|
14
|
+
* - no project-scope file is ever read (`<cwd>/.failproofai/jev.json` is inert);
|
|
15
|
+
* - `provider`, `baseUrl`, `model` and `accountId` come from the file at
|
|
16
|
+
* `failproofaiHome()/jev.json` only, never from the environment — a repo's
|
|
17
|
+
* `.claude/settings.json` can set env vars for a session. (`FAILPROOFAI_HOME`
|
|
18
|
+
* is not an exception to that: it relocates the ENTIRE failproofai layout,
|
|
19
|
+
* policies and all, rather than redirecting Jev in particular, and a layout
|
|
20
|
+
* pointed somewhere new has no policies to evaluate and no 0600 jev.json a
|
|
21
|
+
* checkout could have produced.)
|
|
22
|
+
* - `FAILPROOFAI_JEV_API_KEY` may supply the KEY, and only when the file has
|
|
23
|
+
* none. It exists for single-session use and for people who keep keys off
|
|
24
|
+
* disk. It can never switch Jev on by itself (no file → null), never replace
|
|
25
|
+
* a key the file carries, and never change where requests go. Note the daemon
|
|
26
|
+
* forwards a hook's stdin and cwd, not its environment, so on a
|
|
27
|
+
* daemon-configured machine the file is the only channel that works.
|
|
28
|
+
*
|
|
29
|
+
* # Owner-only
|
|
30
|
+
*
|
|
31
|
+
* The file holds a credential, so it is written at 0600 and a copy that is
|
|
32
|
+
* group- or world-accessible is REFUSED rather than read: returning null there
|
|
33
|
+
* falls back to the regex engine, which is the safe direction (see §4 of the
|
|
34
|
+
* design — without a config the path is today's, byte for byte). Any group or
|
|
35
|
+
* other bit counts, not just read: a group-writable file lets someone else
|
|
36
|
+
* choose the endpoint.
|
|
37
|
+
*
|
|
38
|
+
* Its DIRECTORY is checked too, for write bits only. A directory another user
|
|
39
|
+
* can write into gives them the file's power by another route — unlink it and
|
|
40
|
+
* create their own 0600 one, which every check above would then read as the
|
|
41
|
+
* owner's. `jev setup` takes those bits off, so a home that some older code
|
|
42
|
+
* path created at the umask is fixed the first time Jev is configured. Read
|
|
43
|
+
* bits are left alone at both ends: they give nobody that power, and
|
|
44
|
+
* `config.json` beside it is world-readable by design.
|
|
45
|
+
*
|
|
46
|
+
* # Read on every hook event, on purpose
|
|
47
|
+
*
|
|
48
|
+
* Deliberately uncached: one open + fstat + read of a file capped at 64 KiB is
|
|
49
|
+
* microseconds, and it means `failproofai jev setup` / `remove` take effect on
|
|
50
|
+
* the very next tool call — including inside the long-lived daemon worker —
|
|
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.
|
|
89
|
+
*/
|
|
90
|
+
import { closeSync, constants as fsConstants, fstatSync, openSync, readSync, statSync } from "node:fs";
|
|
91
|
+
import { dirname } from "node:path";
|
|
92
|
+
import { readJevCloudCredential, type JevCloudCredential } from "../fp-config";
|
|
93
|
+
import { jevConfigFile } from "../fp-home";
|
|
94
|
+
|
|
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";
|
|
101
|
+
|
|
102
|
+
export interface JevConfig {
|
|
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
|
+
*/
|
|
108
|
+
apiKey: string;
|
|
109
|
+
/** Required for `custom` and `failproofai`; an optional override otherwise. */
|
|
110
|
+
baseUrl?: string;
|
|
111
|
+
/** `cloudflare` only. */
|
|
112
|
+
accountId?: string;
|
|
113
|
+
/** Provider-specific model id; each provider has a default. */
|
|
114
|
+
model?: string;
|
|
115
|
+
/** Default 3000; the reasoning is on `DEFAULT_JEV_TIMEOUT_MS` in `evaluator.ts`. */
|
|
116
|
+
timeoutMs?: number;
|
|
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;
|
|
131
|
+
}
|
|
132
|
+
|
|
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";
|
|
146
|
+
|
|
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
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The env var that may supply the key (and nothing else) when the file carries none. */
|
|
162
|
+
export const JEV_API_KEY_ENV = "FAILPROOFAI_JEV_API_KEY";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The same 3000 ms as `DEFAULT_JEV_TIMEOUT_MS` in `evaluator.ts`, which is
|
|
166
|
+
* where the measurements and the tradeoff behind the number are written out.
|
|
167
|
+
* In short: pooled p95 over 1,449 answered calls is 1692 ms and the tail runs
|
|
168
|
+
* to 3624 ms, so 1500 ms aborted 8.4% of answers (43% of cold-process calls in
|
|
169
|
+
* the worst session) and silently fell back to regex; 3000 ms aborts 0.28%.
|
|
170
|
+
* Kept separate from the evaluator's copy so the config layer does not import
|
|
171
|
+
* the evaluator; the two are pinned equal by a test.
|
|
172
|
+
*/
|
|
173
|
+
export const JEV_CONFIG_DEFAULT_TIMEOUT_MS = 3_000;
|
|
174
|
+
/** Bounds on `timeoutMs`. Every millisecond of it can be added to a tool call. */
|
|
175
|
+
export const MIN_JEV_TIMEOUT_MS = 100;
|
|
176
|
+
export const MAX_JEV_TIMEOUT_MS = 10_000;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The Jev family the decision thresholds were calibrated against. A reported
|
|
180
|
+
* version with a different major.minor is treated as degraded (the caller falls
|
|
181
|
+
* back to regex), and a configured model id naming another family is refused
|
|
182
|
+
* outright: every call it made would fall back anyway.
|
|
183
|
+
*/
|
|
184
|
+
export const JEV_CALIBRATED_FAMILY = { major: 1, minor: 13 } as const;
|
|
185
|
+
|
|
186
|
+
/** No legitimate config is anywhere near this; the hook path never reads more. */
|
|
187
|
+
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Non-blocking open: a FIFO in the file's place would otherwise hang every
|
|
191
|
+
* hook in `open()` until something wrote to it. For a regular file the flag
|
|
192
|
+
* changes nothing, and the fstat below refuses anything that is not one.
|
|
193
|
+
*/
|
|
194
|
+
const OPEN_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0);
|
|
195
|
+
|
|
196
|
+
export function jevConfigPath(): string {
|
|
197
|
+
return jevConfigFile();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Model ids and versions ───────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `jev-1.13.0`, `jev-1.13`, `typesafe/jev-1.13`, `typesafe/jev-1.13-20260917`,
|
|
204
|
+
* `typesafe-ai/jev-1.13.0`. Aliases (`typesafe/jev`, `typesafe-ai/jev`,
|
|
205
|
+
* `~typesafe/jev-latest`) carry no version and do not match.
|
|
206
|
+
*/
|
|
207
|
+
const VERSIONED_MODEL_RE = /^(?:(?:typesafe|typesafe-ai)\/)?jev-(\d{1,4})\.(\d{1,4})(?:\.(\d{1,6}))?(?:-(\d{8}))?$/;
|
|
208
|
+
|
|
209
|
+
export interface JevModelVersion {
|
|
210
|
+
major: number;
|
|
211
|
+
minor: number;
|
|
212
|
+
patch: number | null;
|
|
213
|
+
/** OpenRouter's snapshot date, `YYYYMMDD`. */
|
|
214
|
+
date: string | null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The Jev version a model id names, or null for an alias / anything else. */
|
|
218
|
+
export function jevModelVersion(model: string): JevModelVersion | null {
|
|
219
|
+
const m = VERSIONED_MODEL_RE.exec(model);
|
|
220
|
+
if (!m) return null;
|
|
221
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: m[3] === undefined ? null : Number(m[3]), date: m[4] ?? null };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** True when the id names the calibrated family (any patch, any snapshot date). */
|
|
225
|
+
export function isCalibratedJevModel(model: string): boolean {
|
|
226
|
+
const v = jevModelVersion(model);
|
|
227
|
+
return v !== null && v.major === JEV_CALIBRATED_FAMILY.major && v.minor === JEV_CALIBRATED_FAMILY.minor;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Validation ───────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/** A Cloudflare account id. It is interpolated into a URL path, so anything else is refused. */
|
|
233
|
+
export const CLOUDFLARE_ACCOUNT_ID_RE = /^[0-9a-f]{32}$/;
|
|
234
|
+
/** Visible ASCII only: a key goes into an HTTP header, so whitespace or a CR/LF is refused, not trimmed into shape. */
|
|
235
|
+
const API_KEY_RE = /^[\x21-\x7e]{1,4096}$/;
|
|
236
|
+
const MODEL_RE = /^[A-Za-z0-9._:/@~+-]{1,200}$/;
|
|
237
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
238
|
+
|
|
239
|
+
export type ValidationResult<T> =
|
|
240
|
+
| { ok: true; value: T }
|
|
241
|
+
/**
|
|
242
|
+
* `missingKey` marks the one failure that is not a fault in the file: it
|
|
243
|
+
* carries no `apiKey` and `FAILPROOFAI_JEV_API_KEY` is unset here. Callers
|
|
244
|
+
* that can tell those apart (`inspectJevConfig`, and `jev status` through it)
|
|
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.
|
|
249
|
+
*/
|
|
250
|
+
| { ok: false; problem: string; missingKey?: true; notConnected?: true };
|
|
251
|
+
|
|
252
|
+
/** A key is visible ASCII, one line, at most 4 KiB. The message never includes the key. */
|
|
253
|
+
export function validateApiKey(key: unknown): string | null {
|
|
254
|
+
if (typeof key !== "string" || key.length === 0) return "the API key is empty";
|
|
255
|
+
if (!API_KEY_RE.test(key)) return "the API key must be one line of visible ASCII with no spaces (at most 4096 characters)";
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
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
|
+
|
|
309
|
+
/**
|
|
310
|
+
* An endpoint base URL: https, or http to a loopback host only (a local proxy).
|
|
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.
|
|
315
|
+
*/
|
|
316
|
+
export function validateBaseUrl(raw: unknown): ValidationResult<string> {
|
|
317
|
+
if (typeof raw !== "string" || raw.trim() === "") return { ok: false, problem: "baseUrl must be a non-empty string" };
|
|
318
|
+
let url: URL;
|
|
319
|
+
try {
|
|
320
|
+
url = new URL(raw.trim());
|
|
321
|
+
} catch {
|
|
322
|
+
return { ok: false, problem: "baseUrl is not a valid URL" };
|
|
323
|
+
}
|
|
324
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
|
|
325
|
+
return { ok: false, problem: "baseUrl must use https (plain http is accepted only for localhost)" };
|
|
326
|
+
}
|
|
327
|
+
if (url.username || url.password) return { ok: false, problem: "baseUrl must not carry credentials; put the key in the key field" };
|
|
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 };
|
|
331
|
+
// Trailing slashes come off the PATH, never the string: a query string is legal
|
|
332
|
+
// (some proxies want `?api-version=`), and the endpoint path is appended to
|
|
333
|
+
// the pathname, so it must never end up inside the query.
|
|
334
|
+
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
335
|
+
return { ok: true, value: url.toString() };
|
|
336
|
+
}
|
|
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
|
+
|
|
469
|
+
function isPlainHttp(url: string): boolean {
|
|
470
|
+
try {
|
|
471
|
+
return new URL(url).protocol === "http:";
|
|
472
|
+
} catch {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
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
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Prefixes real credentials carry: OpenAI / OpenRouter / Anthropic / Stripe
|
|
494
|
+
* (`sk-`, `sk_`, `rk_`), GitHub, GitLab, Slack, AWS, Google, Hugging Face and
|
|
495
|
+
* Vercel AI Gateway (`vck_`). No Jev model id starts with any of them.
|
|
496
|
+
*/
|
|
497
|
+
const CREDENTIAL_PREFIX_RE = /^(?:(?:sk|rk)[-_]|gh[pousr]_|github_pat_|glpat-|xox[abprs]-|AKIA[0-9A-Z]{12}|AIza[0-9A-Za-z_-]{20}|hf_[A-Za-z0-9]{16}|vck_)/;
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Whether a would-be model id is shaped like a credential: a known key prefix,
|
|
501
|
+
* or 32+ characters mixing letters and digits with no `/` and no "jev" — which
|
|
502
|
+
* describes a pasted token, and no model id any Jev route knows. The check
|
|
503
|
+
* exists so a key pasted into `--model` is refused rather than written to the
|
|
504
|
+
* file, printed by `jev status`, and sent to the provider as the model.
|
|
505
|
+
*/
|
|
506
|
+
export function looksLikeCredential(s: string): boolean {
|
|
507
|
+
if (CREDENTIAL_PREFIX_RE.test(s)) return true;
|
|
508
|
+
return s.length >= 32 && !s.includes("/") && !/jev/i.test(s) && /[A-Za-z]/.test(s) && /[0-9]/.test(s);
|
|
509
|
+
}
|
|
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
|
+
|
|
521
|
+
function validateModel(raw: unknown): ValidationResult<string> {
|
|
522
|
+
if (typeof raw !== "string" || !MODEL_RE.test(raw)) {
|
|
523
|
+
return { ok: false, problem: "model must be 1–200 characters of letters, digits and . _ : / @ ~ + -" };
|
|
524
|
+
}
|
|
525
|
+
if (looksLikeCredential(raw)) {
|
|
526
|
+
// Never quoted: it may well be a key.
|
|
527
|
+
return {
|
|
528
|
+
ok: false,
|
|
529
|
+
problem: "model looks like an API key, not a model id (not repeated here). The key goes on stdin: failproofai jev setup --key-stdin",
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const v = jevModelVersion(raw);
|
|
533
|
+
if (v && (v.major !== JEV_CALIBRATED_FAMILY.major || v.minor !== JEV_CALIBRATED_FAMILY.minor)) {
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
problem:
|
|
537
|
+
`model ${raw} names Jev ${v.major}.${v.minor}, but the thresholds are calibrated for Jev ` +
|
|
538
|
+
`${JEV_CALIBRATED_FAMILY.major}.${JEV_CALIBRATED_FAMILY.minor} — every call would fall back to regex`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return { ok: true, value: raw };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Validate a parsed `jev.json` (or a config assembled by `jev setup`) into a
|
|
546
|
+
* normalized `JevConfig`: `mode` and `timeoutMs` filled with their defaults,
|
|
547
|
+
* `baseUrl` normalized, and only the fields this provider uses kept.
|
|
548
|
+
*
|
|
549
|
+
* `envKey` is the value of `FAILPROOFAI_JEV_API_KEY`, used only when the object
|
|
550
|
+
* has no `apiKey`. Unknown top-level keys are ignored so a newer failproofai's
|
|
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.
|
|
557
|
+
*/
|
|
558
|
+
export function validateJevConfig(
|
|
559
|
+
raw: unknown,
|
|
560
|
+
envKey?: string | null,
|
|
561
|
+
cloud?: JevCloudCredential | null,
|
|
562
|
+
): ValidationResult<JevConfig> {
|
|
563
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, problem: "the file must hold a JSON object" };
|
|
564
|
+
const o = raw as Record<string, unknown>;
|
|
565
|
+
|
|
566
|
+
const provider = o.provider;
|
|
567
|
+
if (typeof provider !== "string" || !(JEV_PROVIDER_KINDS as readonly string[]).includes(provider)) {
|
|
568
|
+
return { ok: false, problem: `provider must be one of ${JEV_PROVIDER_KINDS.join(", ")}` };
|
|
569
|
+
}
|
|
570
|
+
const kind = provider as JevProviderKind;
|
|
571
|
+
|
|
572
|
+
let apiKey: string;
|
|
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) {
|
|
598
|
+
const bad = validateApiKey(o.apiKey);
|
|
599
|
+
if (bad) return { ok: false, problem: bad };
|
|
600
|
+
apiKey = o.apiKey as string;
|
|
601
|
+
} else if (envKey !== undefined && envKey !== null && envKey !== "") {
|
|
602
|
+
const bad = validateApiKey(envKey);
|
|
603
|
+
if (bad) return { ok: false, problem: `${JEV_API_KEY_ENV} is set but invalid: ${bad}` };
|
|
604
|
+
apiKey = envKey;
|
|
605
|
+
} else {
|
|
606
|
+
return {
|
|
607
|
+
ok: false,
|
|
608
|
+
problem: `no API key: set apiKey in the file (failproofai jev setup), or ${JEV_API_KEY_ENV} for this session`,
|
|
609
|
+
missingKey: true,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const cfg: JevConfig = { provider: kind, apiKey, mode: DEFAULT_JEV_MODE, timeoutMs: JEV_CONFIG_DEFAULT_TIMEOUT_MS };
|
|
614
|
+
|
|
615
|
+
if (o.baseUrl !== undefined) {
|
|
616
|
+
const r = validateBaseUrl(o.baseUrl);
|
|
617
|
+
if (!r.ok) return r;
|
|
618
|
+
cfg.baseUrl = r.value;
|
|
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;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (kind === "cloudflare") {
|
|
646
|
+
if (typeof o.accountId !== "string" || !CLOUDFLARE_ACCOUNT_ID_RE.test(o.accountId)) {
|
|
647
|
+
// It is interpolated into a URL path, so it is refused rather than escaped.
|
|
648
|
+
return { ok: false, problem: "provider cloudflare needs accountId: 32 lowercase hex characters" };
|
|
649
|
+
}
|
|
650
|
+
cfg.accountId = o.accountId;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (o.model !== undefined) {
|
|
654
|
+
const r = validateModel(o.model);
|
|
655
|
+
if (!r.ok) return r;
|
|
656
|
+
// The key pasted twice — once on stdin, once as --model — whatever its shape.
|
|
657
|
+
if (r.value === apiKey) return { ok: false, problem: "model is the API key (not repeated here); give the model id, or leave --model out for the provider's default" };
|
|
658
|
+
cfg.model = r.value;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (o.timeoutMs !== undefined) {
|
|
662
|
+
const t = o.timeoutMs;
|
|
663
|
+
if (typeof t !== "number" || !Number.isInteger(t) || t < MIN_JEV_TIMEOUT_MS || t > MAX_JEV_TIMEOUT_MS) {
|
|
664
|
+
return { ok: false, problem: `timeoutMs must be a whole number of milliseconds from ${MIN_JEV_TIMEOUT_MS} to ${MAX_JEV_TIMEOUT_MS}` };
|
|
665
|
+
}
|
|
666
|
+
cfg.timeoutMs = t;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (o.mode !== undefined) {
|
|
670
|
+
if (o.mode !== "off" && o.mode !== "shadow" && o.mode !== "enforce") return { ok: false, problem: 'mode must be "off", "shadow" or "enforce"' };
|
|
671
|
+
cfg.mode = o.mode;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Plain http reaches only a loopback host (`validateBaseUrl`), and nothing
|
|
675
|
+
// authenticates the server there: while the local proxy is down, any process
|
|
676
|
+
// of this user — the agent being judged included — can bind its port and
|
|
677
|
+
// answer "none" to every question. In enforce mode that answer clears
|
|
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") {
|
|
681
|
+
return {
|
|
682
|
+
ok: false,
|
|
683
|
+
problem:
|
|
684
|
+
"plain http (to localhost) is accepted only with mode shadow: in enforce mode Jev's answers can clear a deny, " +
|
|
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"),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return { ok: true, value: cfg };
|
|
693
|
+
}
|
|
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
|
+
|
|
723
|
+
// ── Loading ──────────────────────────────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
export type JevConfigInspection =
|
|
726
|
+
| { status: "absent"; path: string }
|
|
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
|
+
}
|
|
779
|
+
| {
|
|
780
|
+
/**
|
|
781
|
+
* The file is sound and names `FAILPROOFAI_JEV_API_KEY` as the key's
|
|
782
|
+
* source (`jev setup --key-from-env` writes exactly this), but the
|
|
783
|
+
* variable is not set in THIS process. Jev is off here — `loadJevConfig`
|
|
784
|
+
* returns null, as for every status but `ok` — and on wherever the
|
|
785
|
+
* variable is set. Kept apart from `refused` because nothing is wrong
|
|
786
|
+
* with the file: telling the owner to write a valid one would have them
|
|
787
|
+
* overwrite the config they chose, and a provisioning check reading
|
|
788
|
+
* `status --json` should see a configured machine, not a broken one.
|
|
789
|
+
*/
|
|
790
|
+
status: "key-missing";
|
|
791
|
+
path: string;
|
|
792
|
+
mode: number | null;
|
|
793
|
+
/** Everything the file says except the key, so `jev status` can show the route. */
|
|
794
|
+
routing: Omit<JevConfig, "apiKey">;
|
|
795
|
+
problem: string;
|
|
796
|
+
}
|
|
797
|
+
| {
|
|
798
|
+
status: "refused";
|
|
799
|
+
path: string;
|
|
800
|
+
/** The file's permission bits, when they are the reason. */
|
|
801
|
+
mode: number | null;
|
|
802
|
+
reason: "too-open" | "unreadable" | "too-large" | "not-json" | "invalid";
|
|
803
|
+
problem: string;
|
|
804
|
+
/** The command that fixes it, when one does. */
|
|
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;
|
|
810
|
+
};
|
|
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
|
+
|
|
822
|
+
function readEnvKey(): string | null {
|
|
823
|
+
const v = process.env[JEV_API_KEY_ENV];
|
|
824
|
+
return v === undefined || v === "" ? null : v;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* A placeholder key, used only to answer "is this file sound apart from the
|
|
829
|
+
* key?" when the variable that would supply it is unset in this process.
|
|
830
|
+
* Validation stops at the missing key, so the fields after it — baseUrl,
|
|
831
|
+
* accountId, model, timeoutMs, mode — are otherwise never checked, and a file
|
|
832
|
+
* that is also malformed further down must still be reported as malformed. It
|
|
833
|
+
* is never written, never sent, and never returned: `inspectJevConfig` copies
|
|
834
|
+
* the routing fields out of the config it validates and drops the rest.
|
|
835
|
+
*/
|
|
836
|
+
const KEY_STAND_IN = "supplied-by-the-environment";
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Group- or world-WRITE bits on the directory holding the config. Anyone who
|
|
840
|
+
* can write there can unlink the owner's file and leave their own 0600 one,
|
|
841
|
+
* which every check on the file itself then passes. Read bits are deliberately
|
|
842
|
+
* not included: a 0755 home is ordinary and gives nobody that power, while the
|
|
843
|
+
* file inside it is checked for read bits too.
|
|
844
|
+
*/
|
|
845
|
+
const DIR_WRITABLE_BY_OTHERS = 0o022;
|
|
846
|
+
|
|
847
|
+
/** The config directory's mode when others can write to it, else null. */
|
|
848
|
+
function looseConfigDirMode(path: string): number | null {
|
|
849
|
+
if (!modesAreMeaningful()) return null;
|
|
850
|
+
try {
|
|
851
|
+
const mode = statSync(dirname(path)).mode & 0o777;
|
|
852
|
+
return (mode & DIR_WRITABLE_BY_OTHERS) !== 0 ? mode : null;
|
|
853
|
+
} catch {
|
|
854
|
+
// No directory, or no permission to stat it: the file read reports that.
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Whether permission bits can be trusted to mean anything here. */
|
|
860
|
+
function modesAreMeaningful(): boolean {
|
|
861
|
+
// On Windows `stat().mode` is synthesized (0o666 for any writable file), so
|
|
862
|
+
// the check would refuse every file without protecting anything. Setup is
|
|
863
|
+
// refused on Windows (see `isDaemonSupportedPlatform`), so this is the
|
|
864
|
+
// in-process dev path only.
|
|
865
|
+
return process.platform !== "win32";
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Everything `failproofai jev status` needs to say about the file, without the
|
|
870
|
+
* key. `loadJevConfig()` is this with the reasons thrown away.
|
|
871
|
+
*
|
|
872
|
+
* The file is opened once and every check runs against that descriptor, so the
|
|
873
|
+
* permissions checked are the permissions of the bytes read.
|
|
874
|
+
*/
|
|
875
|
+
export function inspectJevConfig(): JevConfigInspection {
|
|
876
|
+
const path = jevConfigPath();
|
|
877
|
+
let fd: number;
|
|
878
|
+
try {
|
|
879
|
+
fd = openSync(path, OPEN_FLAGS);
|
|
880
|
+
} catch (err) {
|
|
881
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
882
|
+
if (code === "ENOENT" || code === "ENOTDIR") return { status: "absent", path };
|
|
883
|
+
return { status: "refused", path, mode: null, reason: "unreadable", problem: `cannot open it (${code ?? "error"})` };
|
|
884
|
+
}
|
|
885
|
+
let text: string;
|
|
886
|
+
let mode: number | null = null;
|
|
887
|
+
try {
|
|
888
|
+
const st = fstatSync(fd);
|
|
889
|
+
if (!st.isFile()) return { status: "refused", path, mode: null, reason: "unreadable", problem: "it is not a regular file" };
|
|
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
|
+
};
|
|
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");
|
|
913
|
+
return {
|
|
914
|
+
status: "refused",
|
|
915
|
+
path,
|
|
916
|
+
mode,
|
|
917
|
+
reason: "too-open",
|
|
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})`,
|
|
923
|
+
fix: `chmod 600 ${path}`,
|
|
924
|
+
...(keyless ? { keyless: true as const } : {}),
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const dirMode = looseConfigDirMode(path);
|
|
928
|
+
if (dirMode !== null) {
|
|
929
|
+
// 0600 on the file means nothing while another user can replace the file.
|
|
930
|
+
return {
|
|
931
|
+
status: "refused",
|
|
932
|
+
path,
|
|
933
|
+
mode,
|
|
934
|
+
reason: "too-open",
|
|
935
|
+
problem:
|
|
936
|
+
`its directory ${dirname(path)} is ${dirMode.toString(8).padStart(4, "0")} — other users can write there, ` +
|
|
937
|
+
`so they can put their own config in its place; it must be owner-only (chmod 700 ${dirname(path)})`,
|
|
938
|
+
fix: `chmod 700 ${dirname(path)}`,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
if (st.size > MAX_CONFIG_BYTES) return { status: "refused", path, mode, reason: "too-large", problem: `it is larger than ${MAX_CONFIG_BYTES} bytes` };
|
|
942
|
+
text = readText();
|
|
943
|
+
} catch (err) {
|
|
944
|
+
return { status: "refused", path, mode, reason: "unreadable", problem: `cannot read it (${(err as NodeJS.ErrnoException).code ?? "error"})` };
|
|
945
|
+
} finally {
|
|
946
|
+
try {
|
|
947
|
+
closeSync(fd);
|
|
948
|
+
} catch {
|
|
949
|
+
// Nothing useful to do.
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
let parsed: unknown;
|
|
954
|
+
try {
|
|
955
|
+
parsed = JSON.parse(text);
|
|
956
|
+
} catch {
|
|
957
|
+
return { status: "refused", path, mode, reason: "not-json", problem: "it is not valid JSON" };
|
|
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
|
+
|
|
1003
|
+
const envKey = readEnvKey();
|
|
1004
|
+
const r = validateJevConfig(parsed, envKey);
|
|
1005
|
+
if (!r.ok) {
|
|
1006
|
+
if (r.missingKey === true) {
|
|
1007
|
+
// Nothing is wrong with the file unless the REST of it is wrong too, and
|
|
1008
|
+
// validation never got that far, so ask again with a stand-in key.
|
|
1009
|
+
const rest = validateJevConfig(parsed, KEY_STAND_IN);
|
|
1010
|
+
if (rest.ok) {
|
|
1011
|
+
return {
|
|
1012
|
+
status: "key-missing",
|
|
1013
|
+
path,
|
|
1014
|
+
mode,
|
|
1015
|
+
routing: routingOf(rest.value),
|
|
1016
|
+
problem: `it carries no apiKey, so the key comes from ${JEV_API_KEY_ENV} — which is not set in this environment`,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
return { status: "refused", path, mode, reason: "invalid", problem: rest.problem };
|
|
1020
|
+
}
|
|
1021
|
+
return { status: "refused", path, mode, reason: "invalid", problem: r.problem };
|
|
1022
|
+
}
|
|
1023
|
+
const keySource = (parsed as Record<string, unknown>).apiKey !== undefined ? "file" : "env";
|
|
1024
|
+
return { status: "ok", path, mode, keySource, config: r.value };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
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`.
|
|
1074
|
+
*/
|
|
1075
|
+
export function loadJevConfig(): JevConfig | null {
|
|
1076
|
+
try {
|
|
1077
|
+
const r = inspectJevConfig();
|
|
1078
|
+
return r.status === "ok" && r.config.mode !== "off" ? r.config : null;
|
|
1079
|
+
} catch {
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
export interface JevConfigFileForUpdate {
|
|
1085
|
+
raw: Record<string, unknown>;
|
|
1086
|
+
/** The file's permission bits (null where they mean nothing). */
|
|
1087
|
+
mode: number | null;
|
|
1088
|
+
/**
|
|
1089
|
+
* Group or other bits were set on the file, or write bits on its directory:
|
|
1090
|
+
* the loader refuses this file, and someone other than its owner may have
|
|
1091
|
+
* written it — so the endpoint it names is not trusted with the key it holds
|
|
1092
|
+
* (see `jev setup`). A directory others can write into means the same thing,
|
|
1093
|
+
* because the file in it can simply be replaced.
|
|
1094
|
+
*/
|
|
1095
|
+
tooOpen: boolean;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* The file's raw JSON object regardless of its permissions, with those
|
|
1100
|
+
* permissions, for `jev setup` to update in place (carry the key over a mode
|
|
1101
|
+
* switch, or re-save a file that is too open at 0600) and for `jev status` to
|
|
1102
|
+
* show what a refused file names. Never used on the hook path.
|
|
1103
|
+
*/
|
|
1104
|
+
export function readJevConfigFileForUpdate(): JevConfigFileForUpdate | null {
|
|
1105
|
+
const path = jevConfigPath();
|
|
1106
|
+
let fd: number;
|
|
1107
|
+
try {
|
|
1108
|
+
fd = openSync(path, OPEN_FLAGS);
|
|
1109
|
+
} catch {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
const st = fstatSync(fd);
|
|
1114
|
+
if (!st.isFile() || st.size > MAX_CONFIG_BYTES) return null;
|
|
1115
|
+
const buf = Buffer.alloc(st.size);
|
|
1116
|
+
let off = 0;
|
|
1117
|
+
while (off < buf.length) {
|
|
1118
|
+
const n = readSync(fd, buf, off, buf.length - off, off);
|
|
1119
|
+
if (n === 0) break;
|
|
1120
|
+
off += n;
|
|
1121
|
+
}
|
|
1122
|
+
const parsed: unknown = JSON.parse(buf.subarray(0, off).toString("utf8"));
|
|
1123
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
1124
|
+
const meaningful = modesAreMeaningful();
|
|
1125
|
+
const mode = meaningful ? st.mode & 0o777 : null;
|
|
1126
|
+
const tooOpen = mode !== null && ((mode & 0o077) !== 0 || looseConfigDirMode(path) !== null);
|
|
1127
|
+
return { raw: parsed as Record<string, unknown>, mode, tooOpen };
|
|
1128
|
+
} catch {
|
|
1129
|
+
return null;
|
|
1130
|
+
} finally {
|
|
1131
|
+
try {
|
|
1132
|
+
closeSync(fd);
|
|
1133
|
+
} catch {
|
|
1134
|
+
// Nothing useful to do.
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** `readJevConfigFileForUpdate()` without the permissions. */
|
|
1140
|
+
export function readJevConfigForUpdate(): Record<string, unknown> | null {
|
|
1141
|
+
return readJevConfigFileForUpdate()?.raw ?? null;
|
|
1142
|
+
}
|