failproofai 1.0.0-beta.2 → 1.0.0-beta.4
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 +6 -6
- 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/build-manifest.json +3 -3
- 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.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 +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- 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.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/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- 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 +2 -2
- package/.next/standalone/.next/server/app/api/audit/run/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/reminder/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 +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/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- 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 +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- 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/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- 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/build-manifest.json +3 -3
- 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 +1 -1
- 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/build-manifest.json +3 -3
- 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 +1 -1
- 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/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +1 -1
- 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/chunks/[externals]__0h9_83k._.js +3 -0
- package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
- 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/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_1q9xxca.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- 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 +32 -20
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
- package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
- package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
- package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +15 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2255 -779
- package/dist/worker.mjs +365 -52
- package/package.json +15 -9
- package/scripts/build-daemon-packages.mjs +210 -0
- package/scripts/daemon-platforms.mjs +42 -0
- package/scripts/prune-standalone.mjs +6 -0
- package/scripts/publish-aliases.mjs +62 -41
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/daemon-download.ts +137 -27
- package/src/hooks/daemon-service.ts +23 -8
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
- package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One connection to Failproof Cloud, with two capabilities.
|
|
3
|
+
*
|
|
4
|
+
* # Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Enrolment (#632) and collection (#640) were built independently and each
|
|
7
|
+
* arrived with its own credential file, its own URL and its own setup step:
|
|
8
|
+
*
|
|
9
|
+
* pull policy -> ~/.failproofai/cloud.json <base>/enforcement/v1/...
|
|
10
|
+
* push events -> ~/.failproofai/ingest.json <base>/events
|
|
11
|
+
*
|
|
12
|
+
* Same server, same organisation, usually the same key. Asked twice, a user
|
|
13
|
+
* who connects for policy sees an empty dashboard and reasonably concludes the
|
|
14
|
+
* product is broken — the second setup step is invisible until someone goes
|
|
15
|
+
* looking for it.
|
|
16
|
+
*
|
|
17
|
+
* So the URL and token are asked for ONCE and both capabilities are configured
|
|
18
|
+
* from them. The two files stay separate on disk — that is a real security
|
|
19
|
+
* boundary, since `cloud.json` and `ingest.json` can hold different keys with
|
|
20
|
+
* different permissions, and the daemon reads them independently — but nothing
|
|
21
|
+
* above this module has to know that.
|
|
22
|
+
*
|
|
23
|
+
* # Capabilities are verified and reported INDEPENDENTLY
|
|
24
|
+
*
|
|
25
|
+
* A key may carry `policies:pull` and not `events:add`, or the reverse. The
|
|
26
|
+
* honest outcome is then a partial connection with a precise reason, not an
|
|
27
|
+
* all-or-nothing failure: refusing to enrol for policy because the dashboard
|
|
28
|
+
* would be empty protects nothing, and silently skipping ingest reproduces the
|
|
29
|
+
* exact confusion this module removes.
|
|
30
|
+
*/
|
|
31
|
+
import {
|
|
32
|
+
cloudCredentialPath,
|
|
33
|
+
verifyCloudCredentials,
|
|
34
|
+
writeCloudCredentials,
|
|
35
|
+
type CloudCredentials,
|
|
36
|
+
} from "./cloud-enrollment";
|
|
37
|
+
import {
|
|
38
|
+
ingestPath,
|
|
39
|
+
validateIngestKey,
|
|
40
|
+
writeCollectorSettings,
|
|
41
|
+
writeIngestCredential,
|
|
42
|
+
type CollectorSettings,
|
|
43
|
+
} from "./collector-config";
|
|
44
|
+
|
|
45
|
+
/** The path ingest is served on, appended to the cloud base URL. */
|
|
46
|
+
export const INGEST_PATH = "/events";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The ingest endpoint for a cloud base URL.
|
|
50
|
+
*
|
|
51
|
+
* Ingest wants a COMPLETE endpoint while enrolment wants a base, which is the
|
|
52
|
+
* whole reason two URLs were being asked for. One is derivable from the other,
|
|
53
|
+
* so only the base is ever asked for.
|
|
54
|
+
*/
|
|
55
|
+
export function ingestUrlFor(baseUrl: string): string {
|
|
56
|
+
return `${baseUrl.replace(/\/$/, "")}${INGEST_PATH}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The cloud base for whatever URL someone actually typed.
|
|
61
|
+
*
|
|
62
|
+
* Accepts either form. People paste the ingest endpoint — it is what the older
|
|
63
|
+
* prompt asked for, and what any existing runbook or `ingest.json` contains —
|
|
64
|
+
* and rejecting it as "not a base URL" would be pedantry about a URL we can
|
|
65
|
+
* read perfectly well.
|
|
66
|
+
*/
|
|
67
|
+
export function cloudBaseFor(url: string): string {
|
|
68
|
+
const trimmed = url.replace(/\/$/, "");
|
|
69
|
+
return trimmed.endsWith(INGEST_PATH) ? trimmed.slice(0, -INGEST_PATH.length) : trimmed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ConnectInput {
|
|
73
|
+
/** Cloud base URL, already validated by `validateCloudUrl`. */
|
|
74
|
+
url: string;
|
|
75
|
+
token: string;
|
|
76
|
+
machineId: string;
|
|
77
|
+
/** Transcripts are a separate, explicit opt-in. Never defaulted on. */
|
|
78
|
+
sessions?: boolean;
|
|
79
|
+
environment?: string;
|
|
80
|
+
/** Injected by tests so nothing reaches the network. */
|
|
81
|
+
verifyPolicy?: typeof verifyCloudCredentials;
|
|
82
|
+
verifyIngest?: typeof validateIngestKey;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CapabilityOutcome {
|
|
86
|
+
ok: boolean;
|
|
87
|
+
reason?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ConnectOutcome {
|
|
91
|
+
policy: CapabilityOutcome & { policyCount?: number; generation?: number };
|
|
92
|
+
ingest: CapabilityOutcome;
|
|
93
|
+
/** True when at least one capability was configured. */
|
|
94
|
+
anyConfigured: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Verify both capabilities, then write only what actually works.
|
|
99
|
+
*
|
|
100
|
+
* Verification happens BEFORE any write, per capability. A credential file that
|
|
101
|
+
* does not work is worse than none, because `--status` then reports a
|
|
102
|
+
* connection the machine does not have.
|
|
103
|
+
*/
|
|
104
|
+
export async function connectToCloud(input: ConnectInput): Promise<ConnectOutcome> {
|
|
105
|
+
const creds: CloudCredentials = {
|
|
106
|
+
url: input.url,
|
|
107
|
+
machineId: input.machineId,
|
|
108
|
+
token: input.token,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const verifyPolicy = input.verifyPolicy ?? verifyCloudCredentials;
|
|
112
|
+
const verifyIngest = input.verifyIngest ?? validateIngestKey;
|
|
113
|
+
const ingestUrl = ingestUrlFor(input.url);
|
|
114
|
+
|
|
115
|
+
// Both checks run regardless of the other's result: reporting "cannot pull
|
|
116
|
+
// policy" while staying silent about ingest would send someone to fix one
|
|
117
|
+
// permission and hit the next one afterwards.
|
|
118
|
+
const [policyResult, ingestResult] = await Promise.all([
|
|
119
|
+
verifyPolicy(creds),
|
|
120
|
+
verifyIngest({ url: ingestUrl, key: input.token }),
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const outcome: ConnectOutcome = {
|
|
124
|
+
policy: policyResult.ok
|
|
125
|
+
? { ok: true, policyCount: policyResult.policyCount, generation: policyResult.generation }
|
|
126
|
+
: { ok: false, reason: policyResult.reason },
|
|
127
|
+
ingest: ingestResult.ok ? { ok: true } : { ok: false, reason: ingestResult.reason },
|
|
128
|
+
anyConfigured: false,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (policyResult.ok) {
|
|
132
|
+
writeCloudCredentials(creds);
|
|
133
|
+
outcome.anyConfigured = true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (ingestResult.ok) {
|
|
137
|
+
writeIngestCredential({ url: ingestUrl, key: input.token });
|
|
138
|
+
const settings: CollectorSettings = {
|
|
139
|
+
// Hook activity is what makes the dashboard show anything at all, and it
|
|
140
|
+
// carries decisions and tool names — never file contents.
|
|
141
|
+
hooks: true,
|
|
142
|
+
// Transcripts carry prompts, file contents and whatever was pasted into
|
|
143
|
+
// a terminal, so they stay off unless explicitly asked for.
|
|
144
|
+
sessions: input.sessions === true,
|
|
145
|
+
environment: input.environment,
|
|
146
|
+
};
|
|
147
|
+
writeCollectorSettings(settings);
|
|
148
|
+
outcome.anyConfigured = true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return outcome;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* How to describe the outcome to someone at a terminal.
|
|
156
|
+
*
|
|
157
|
+
* The partial cases are the ones worth wording carefully: each names the
|
|
158
|
+
* missing permission, because "connected, but the dashboard will stay empty"
|
|
159
|
+
* without a cause is the message that sends people to support.
|
|
160
|
+
*/
|
|
161
|
+
export function describeOutcome(outcome: ConnectOutcome, machineId: string, url: string): string[] {
|
|
162
|
+
const lines: string[] = [];
|
|
163
|
+
|
|
164
|
+
if (outcome.policy.ok && outcome.ingest.ok) {
|
|
165
|
+
lines.push(`Connected to ${url} as ${machineId}.`);
|
|
166
|
+
const n = outcome.policy.policyCount ?? 0;
|
|
167
|
+
lines.push(` Policy ${n} polic${n === 1 ? "y" : "ies"} assigned (generation ${outcome.policy.generation ?? 0}).`);
|
|
168
|
+
lines.push(` Dashboard hook activity will be sent to ${ingestUrlFor(url)}.`);
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (outcome.policy.ok && !outcome.ingest.ok) {
|
|
173
|
+
const n = outcome.policy.policyCount ?? 0;
|
|
174
|
+
lines.push(`Connected to ${url} as ${machineId} for policy only.`);
|
|
175
|
+
lines.push(` Policy ${n} polic${n === 1 ? "y" : "ies"} assigned (generation ${outcome.policy.generation ?? 0}).`);
|
|
176
|
+
lines.push(` Dashboard NOT configured: ${outcome.ingest.reason}`);
|
|
177
|
+
lines.push("");
|
|
178
|
+
lines.push(" Enforcement works. Nothing will appear in the dashboard until this");
|
|
179
|
+
lines.push(" key also carries `events:add`, or you re-run --connect with one that does.");
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!outcome.policy.ok && outcome.ingest.ok) {
|
|
184
|
+
lines.push(`Connected to ${url} as ${machineId} for dashboard reporting only.`);
|
|
185
|
+
lines.push(` Dashboard hook activity will be sent to ${ingestUrlFor(url)}.`);
|
|
186
|
+
lines.push(` Cloud policy NOT configured: ${outcome.policy.reason}`);
|
|
187
|
+
lines.push("");
|
|
188
|
+
lines.push(" This machine keeps enforcing its LOCAL policies and will report what");
|
|
189
|
+
lines.push(" they decide, but will not receive centrally-managed ones.");
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
lines.push(`Could not connect to ${url}.`);
|
|
194
|
+
lines.push(` Policy: ${outcome.policy.reason}`);
|
|
195
|
+
lines.push(` Dashboard: ${outcome.ingest.reason}`);
|
|
196
|
+
return lines;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Paths written, for a closing note. Only those that exist. */
|
|
200
|
+
export function configuredPaths(outcome: ConnectOutcome): string[] {
|
|
201
|
+
const paths: string[] = [];
|
|
202
|
+
if (outcome.policy.ok) paths.push(cloudCredentialPath());
|
|
203
|
+
if (outcome.ingest.ok) paths.push(ingestPath());
|
|
204
|
+
return paths;
|
|
205
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `failproofai config --connect | --disconnect`, and the connection half of
|
|
3
|
+
* `--status`.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
clearCloudCredentials,
|
|
7
|
+
maskToken,
|
|
8
|
+
readCloudCredentials,
|
|
9
|
+
validateCloudUrl,
|
|
10
|
+
verifyCloudCredentials,
|
|
11
|
+
writeCloudCredentials,
|
|
12
|
+
cloudCredentialPath,
|
|
13
|
+
} from "./cloud-enrollment";
|
|
14
|
+
import { daemonServiceStatus } from "./daemon-service";
|
|
15
|
+
import { daemonSocketPresent } from "./daemon-client";
|
|
16
|
+
import {
|
|
17
|
+
cloudBaseFor,
|
|
18
|
+
configuredPaths,
|
|
19
|
+
connectToCloud,
|
|
20
|
+
describeOutcome,
|
|
21
|
+
ingestUrlFor,
|
|
22
|
+
} from "./cloud-connection";
|
|
23
|
+
import {
|
|
24
|
+
clearIngestCredential,
|
|
25
|
+
hasIngestCredential,
|
|
26
|
+
readIngestCredential,
|
|
27
|
+
validateIngestKey,
|
|
28
|
+
} from "./collector-config";
|
|
29
|
+
|
|
30
|
+
export interface ConnectOptions {
|
|
31
|
+
url?: string;
|
|
32
|
+
token?: string;
|
|
33
|
+
machineId?: string;
|
|
34
|
+
/** Defaults to the hostname; injected for tests. */
|
|
35
|
+
defaultMachineId?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Send full session transcripts as well as hook decisions. Off unless asked
|
|
38
|
+
* for: a transcript carries prompts, file contents and whatever was pasted
|
|
39
|
+
* into a terminal, so it can never be a side effect of connecting.
|
|
40
|
+
*/
|
|
41
|
+
sessions?: boolean;
|
|
42
|
+
environment?: string;
|
|
43
|
+
/** Injected for tests so nothing reaches the network. */
|
|
44
|
+
verify?: typeof verifyCloudCredentials;
|
|
45
|
+
verifyIngest?: typeof validateIngestKey;
|
|
46
|
+
daemonStatus?: () => ReturnType<typeof daemonServiceStatus>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CommandResult {
|
|
50
|
+
exitCode: number;
|
|
51
|
+
lines: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Warn rather than refuse when there is no daemon.
|
|
56
|
+
*
|
|
57
|
+
* Cloud policy is evaluated by failproofaid, so credentials alone pull
|
|
58
|
+
* nothing — but refusing would break baking a machine image where the daemon
|
|
59
|
+
* is installed later, and enrolment is genuinely independent of it. Say so
|
|
60
|
+
* loudly instead of failing.
|
|
61
|
+
*/
|
|
62
|
+
function daemonWarning(status: ReturnType<typeof daemonServiceStatus>): string[] {
|
|
63
|
+
if (status === "running") return [];
|
|
64
|
+
// A daemon started by hand is invisible to the service manager but is
|
|
65
|
+
// running and pulling. Telling someone whose machine is actively enforcing
|
|
66
|
+
// that "nothing will be pulled" is false, and it is exactly the state a
|
|
67
|
+
// developer testing locally is in.
|
|
68
|
+
if (daemonSocketPresent()) {
|
|
69
|
+
return [
|
|
70
|
+
"",
|
|
71
|
+
" A daemon is running outside the service manager, so policy is being pulled.",
|
|
72
|
+
" Install it as a service to survive reboot and logout: failproofai config",
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
if (status === "unsupported-platform") {
|
|
76
|
+
return [
|
|
77
|
+
"",
|
|
78
|
+
"! failproofaid does not run on this platform yet, so nothing will be pulled.",
|
|
79
|
+
" The credentials are stored and will work on a supported machine.",
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
if (status === "not-installed") {
|
|
83
|
+
// "as a service", not "at all": this reads the service manager, so a daemon
|
|
84
|
+
// someone is running by hand during development is reported as absent. The
|
|
85
|
+
// wording has to be true in both cases.
|
|
86
|
+
return [
|
|
87
|
+
"",
|
|
88
|
+
"! failproofaid is not installed as a service, so nothing will be pulled yet.",
|
|
89
|
+
" Install it with: failproofai config",
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
return [
|
|
93
|
+
"",
|
|
94
|
+
"! failproofaid is installed but not running, so nothing will be pulled yet.",
|
|
95
|
+
" Check it with: failproofai config --status",
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function runConnectCommand(opts: ConnectOptions): Promise<CommandResult> {
|
|
100
|
+
if (!opts.url) return { exitCode: 1, lines: ["--connect needs a URL, e.g. --connect https://be.failproof.ai"] };
|
|
101
|
+
if (!opts.token) {
|
|
102
|
+
return {
|
|
103
|
+
exitCode: 1,
|
|
104
|
+
lines: [
|
|
105
|
+
"--connect needs a machine token: --token <key>",
|
|
106
|
+
"",
|
|
107
|
+
"Create an API key carrying only the `policies:pull` permission for this",
|
|
108
|
+
"machine. Do not use an admin key.",
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Accepts the ingest endpoint too. People paste what the older setup asked
|
|
114
|
+
// for, or what is already in their `ingest.json`, and refusing a URL we can
|
|
115
|
+
// read perfectly well is pedantry.
|
|
116
|
+
const validated = validateCloudUrl(cloudBaseFor(opts.url));
|
|
117
|
+
if (!validated.ok) return { exitCode: 1, lines: [validated.reason] };
|
|
118
|
+
|
|
119
|
+
const machineId = (opts.machineId ?? opts.defaultMachineId ?? "").trim();
|
|
120
|
+
if (!machineId) {
|
|
121
|
+
return { exitCode: 1, lines: ["Could not determine a machine id — pass one with --machine-id <id>."] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ONE connection, both capabilities. Each is verified before anything is
|
|
125
|
+
// written and reported on its own, because a key can carry `policies:pull`
|
|
126
|
+
// without `events:add` and the useful message names which one is missing.
|
|
127
|
+
const outcome = await connectToCloud({
|
|
128
|
+
url: validated.url,
|
|
129
|
+
token: opts.token,
|
|
130
|
+
machineId,
|
|
131
|
+
sessions: opts.sessions,
|
|
132
|
+
environment: opts.environment,
|
|
133
|
+
verifyPolicy: opts.verify,
|
|
134
|
+
verifyIngest: opts.verifyIngest,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!outcome.anyConfigured) {
|
|
138
|
+
return { exitCode: 1, lines: describeOutcome(outcome, machineId, validated.url) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The exit code tracks the PRIMARY purpose — enrolling this machine for
|
|
142
|
+
// cloud-managed policy. Configuring the dashboard is worth doing and worth
|
|
143
|
+
// reporting, but it must not turn a failed enrolment into a success: a fleet
|
|
144
|
+
// provisioning script running `--connect … && …` has to stop when the machine
|
|
145
|
+
// will not receive policy. The ingest credential still gets written, because
|
|
146
|
+
// it works and discarding it helps nobody.
|
|
147
|
+
const exitCode = outcome.policy.ok ? 0 : 1;
|
|
148
|
+
|
|
149
|
+
const status = (opts.daemonStatus ?? daemonServiceStatus)();
|
|
150
|
+
const paths = configuredPaths(outcome);
|
|
151
|
+
const lines = [
|
|
152
|
+
...describeOutcome(outcome, machineId, validated.url),
|
|
153
|
+
` Token ${maskToken(opts.token)} stored in ${paths.join(" and ")} (owner-only).`,
|
|
154
|
+
];
|
|
155
|
+
if (outcome.ingest.ok && opts.sessions !== true) {
|
|
156
|
+
// Said out loud rather than left as a default: someone who wanted
|
|
157
|
+
// transcripts should not discover months later that none were sent.
|
|
158
|
+
lines.push(" Session transcripts are NOT being sent. Add --send-transcripts to include them.");
|
|
159
|
+
}
|
|
160
|
+
lines.push(...daemonWarning(status));
|
|
161
|
+
|
|
162
|
+
return { exitCode, lines };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function runDisconnectCommand(): CommandResult {
|
|
166
|
+
const existing = readCloudCredentials();
|
|
167
|
+
const hadIngest = hasIngestCredential();
|
|
168
|
+
const removed = clearCloudCredentials();
|
|
169
|
+
// Disconnect means disconnect. Clearing only the policy credential would
|
|
170
|
+
// leave the machine still shipping activity to a cloud the user believes
|
|
171
|
+
// they have left — the one outcome nobody expects from this command.
|
|
172
|
+
const removedIngest = clearIngestCredential();
|
|
173
|
+
|
|
174
|
+
if (!removed && !existing && !hadIngest) {
|
|
175
|
+
return { exitCode: 0, lines: ["This machine is not connected to Failproof Cloud."] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const lines = [
|
|
179
|
+
"Disconnected from Failproof Cloud.",
|
|
180
|
+
"",
|
|
181
|
+
" Cloud-managed policies already on disk stop being refreshed. Local",
|
|
182
|
+
" builtin, custom and convention policies are unaffected.",
|
|
183
|
+
];
|
|
184
|
+
if (removedIngest) {
|
|
185
|
+
lines.push(" Hook activity and transcripts stop being sent.");
|
|
186
|
+
}
|
|
187
|
+
return { exitCode: 0, lines };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The connection half of `--status`. Never prints the token. */
|
|
191
|
+
export function connectionStatusLines(daemonStatus = daemonServiceStatus): string[] {
|
|
192
|
+
const envUrl = process.env.FAILPROOFAI_CLOUD_URL;
|
|
193
|
+
if (envUrl) {
|
|
194
|
+
// Env wins over the file in the daemon, so reporting the file here would
|
|
195
|
+
// describe a configuration that is not in effect.
|
|
196
|
+
return [
|
|
197
|
+
`Cloud: configured by environment (${envUrl}), which takes precedence over ${cloudCredentialPath()}.`,
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
const creds = readCloudCredentials();
|
|
201
|
+
const ingest = readIngestCredential();
|
|
202
|
+
|
|
203
|
+
// Reported as ONE connection with two capabilities. A machine sending
|
|
204
|
+
// activity but pulling no policy, or the reverse, is a state worth seeing at
|
|
205
|
+
// a glance — it is exactly the half-configured case this command exists to
|
|
206
|
+
// catch, and two unrelated lines made it easy to miss.
|
|
207
|
+
if (!creds && !ingest) return ["Cloud: not connected."];
|
|
208
|
+
|
|
209
|
+
const lines: string[] = [];
|
|
210
|
+
if (creds) {
|
|
211
|
+
lines.push(`Cloud: connected to ${creds.url} as ${creds.machineId} (token ${maskToken(creds.token)}).`);
|
|
212
|
+
lines.push(` Policy pulling centrally-managed policies.`);
|
|
213
|
+
} else {
|
|
214
|
+
lines.push(`Cloud: connected to ${cloudBaseFor(ingest!.url)} for reporting only.`);
|
|
215
|
+
lines.push(` Policy NOT pulling — this machine enforces only its local policies.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (ingest) {
|
|
219
|
+
lines.push(` Dashboard sending hook activity to ${ingest.url}.`);
|
|
220
|
+
} else {
|
|
221
|
+
lines.push(` Dashboard NOT sending — nothing from this machine appears in the dashboard.`);
|
|
222
|
+
lines.push(` Re-run: failproofai config --connect ${creds!.url} --token <key>`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
lines.push(...daemonWarning(daemonStatus()).map((l) => (l === "" ? "" : ` ${l.trim()}`)));
|
|
226
|
+
return lines;
|
|
227
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connecting a machine to Failproof Cloud.
|
|
3
|
+
*
|
|
4
|
+
* The credential lives in a file the daemon reads, NOT in the service unit.
|
|
5
|
+
* `daemon-service.ts` installs that unit at mode 0644 into
|
|
6
|
+
* /etc/systemd/system (and the launchd plist likewise), both root-owned and
|
|
7
|
+
* world-readable — so an `Environment="FAILPROOFAI_CLOUD_TOKEN=…"` line would
|
|
8
|
+
* hand an organization-scoped bearer key to every local user, and
|
|
9
|
+
* `systemctl show` would print it back without any privilege at all.
|
|
10
|
+
*
|
|
11
|
+
* Keeping it out of the unit also means enrolment, re-enrolment, token
|
|
12
|
+
* rotation and disconnect need no root — none of them touch a privileged
|
|
13
|
+
* path — and enrolment stops being welded to service installation, so an
|
|
14
|
+
* already-running daemon can be connected without reinstalling anything.
|
|
15
|
+
*
|
|
16
|
+
* Environment variables still win over this file, so CI, containers and the
|
|
17
|
+
* existing tests are unaffected.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { writeJsonAtomically } from "../../lib/atomic-write";
|
|
23
|
+
import { fetchWithTimeout, isAbortError } from "../../lib/fetch-with-timeout";
|
|
24
|
+
|
|
25
|
+
const SCHEMA_VERSION = 1;
|
|
26
|
+
const VERIFY_TIMEOUT_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
export interface CloudCredentials {
|
|
29
|
+
url: string;
|
|
30
|
+
machineId: string;
|
|
31
|
+
token: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface StoredCredentials extends CloudCredentials {
|
|
35
|
+
schemaVersion: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `~/.failproofai/cloud.json`. The override is honoured by the daemon too. */
|
|
39
|
+
export function cloudCredentialPath(): string {
|
|
40
|
+
const override = process.env.FAILPROOFAI_CLOUD_CREDENTIALS;
|
|
41
|
+
if (override) return override;
|
|
42
|
+
return join(homedir(), ".failproofai", "cloud.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rejects a URL that would put a bearer token on the wire in clear.
|
|
47
|
+
*
|
|
48
|
+
* Loopback stays allowed over http, because that is exactly what the
|
|
49
|
+
* enterprise doc's local walkthrough does (`http://localhost:8080`) and there
|
|
50
|
+
* is no network to intercept.
|
|
51
|
+
*/
|
|
52
|
+
export function validateCloudUrl(raw: string): { ok: true; url: string } | { ok: false; reason: string } {
|
|
53
|
+
let parsed: URL;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
return { ok: false, reason: `"${raw}" is not a valid URL. Expected something like https://be.failproof.ai` };
|
|
58
|
+
}
|
|
59
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
60
|
+
return { ok: false, reason: `Unsupported scheme "${parsed.protocol}" — use https (or http for localhost).` };
|
|
61
|
+
}
|
|
62
|
+
if (parsed.protocol === "http:") {
|
|
63
|
+
const host = parsed.hostname;
|
|
64
|
+
const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
65
|
+
if (!loopback) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason:
|
|
69
|
+
`Refusing to send the machine token to ${parsed.origin} over plain http. ` +
|
|
70
|
+
`Use https, or http only for localhost during development.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, url: parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "")) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Last four characters only — enough to tell two keys apart, useless if leaked. */
|
|
78
|
+
export function maskToken(token: string): string {
|
|
79
|
+
if (token.length <= 4) return "****";
|
|
80
|
+
return `****${token.slice(-4)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readCloudCredentials(): CloudCredentials | null {
|
|
84
|
+
const path = cloudCredentialPath();
|
|
85
|
+
if (!existsSync(path)) return null;
|
|
86
|
+
try {
|
|
87
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<StoredCredentials>;
|
|
88
|
+
if (raw.schemaVersion !== SCHEMA_VERSION) return null;
|
|
89
|
+
if (typeof raw.url !== "string" || typeof raw.machineId !== "string" || typeof raw.token !== "string") {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (!raw.url || !raw.machineId || !raw.token) return null;
|
|
93
|
+
return { url: raw.url, machineId: raw.machineId, token: raw.token };
|
|
94
|
+
} catch {
|
|
95
|
+
// Unreadable or malformed reads as "not connected" rather than throwing:
|
|
96
|
+
// this is consulted by `--status`, and a corrupt file should be reported
|
|
97
|
+
// as disconnected, not crash the command.
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function writeCloudCredentials(creds: CloudCredentials): void {
|
|
103
|
+
// 0600 inside a 0700 dir — the default for writeJsonAtomically, and the
|
|
104
|
+
// whole reason this is a file of ours rather than a line in the unit.
|
|
105
|
+
writeJsonAtomically(cloudCredentialPath(), { schemaVersion: SCHEMA_VERSION, ...creds });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True if there was something to remove. */
|
|
109
|
+
export function clearCloudCredentials(): boolean {
|
|
110
|
+
const path = cloudCredentialPath();
|
|
111
|
+
if (!existsSync(path)) return false;
|
|
112
|
+
try {
|
|
113
|
+
rmSync(path);
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type VerifyResult =
|
|
121
|
+
| { ok: true; policyCount: number; generation: number }
|
|
122
|
+
| { ok: false; reason: string };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Prove the credentials work before reporting success.
|
|
126
|
+
*
|
|
127
|
+
* Writing a credential file and saying nothing means a typo, a revoked key or
|
|
128
|
+
* a key missing `policies:pull` surfaces much later — as a policy that
|
|
129
|
+
* mysteriously never arrives, which is the worst moment to discover it. So
|
|
130
|
+
* enrolment makes exactly the request the daemon will make and reports what
|
|
131
|
+
* came back.
|
|
132
|
+
*/
|
|
133
|
+
export async function verifyCloudCredentials(creds: CloudCredentials): Promise<VerifyResult> {
|
|
134
|
+
const url = `${creds.url}/enforcement/v1/desired-state?machineId=${encodeURIComponent(creds.machineId)}`;
|
|
135
|
+
let response: Response;
|
|
136
|
+
try {
|
|
137
|
+
response = await fetchWithTimeout(
|
|
138
|
+
url,
|
|
139
|
+
{ headers: { Authorization: `Bearer ${creds.token}` } },
|
|
140
|
+
VERIFY_TIMEOUT_MS,
|
|
141
|
+
);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (isAbortError(err)) return { ok: false, reason: `No response from ${creds.url} within ${VERIFY_TIMEOUT_MS / 1000}s.` };
|
|
144
|
+
return { ok: false, reason: `Could not reach ${creds.url}: ${err instanceof Error ? err.message : String(err)}` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (response.status === 401) {
|
|
148
|
+
return { ok: false, reason: "The server rejected this token (401). Check the key was copied whole." };
|
|
149
|
+
}
|
|
150
|
+
if (response.status === 403) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
reason: "This key is valid but lacks the `policies:pull` permission (403). Create a machine key with that permission.",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
return { ok: false, reason: `The server answered ${response.status} ${response.statusText}.` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const body = (await response.json()) as { policies?: unknown[]; generation?: number };
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
policyCount: Array.isArray(body.policies) ? body.policies.length : 0,
|
|
165
|
+
generation: typeof body.generation === "number" ? body.generation : 0,
|
|
166
|
+
};
|
|
167
|
+
} catch {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: `${creds.url} answered, but not with a desired-state document. Is this a Failproof Cloud URL?`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|