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
|
@@ -12,29 +12,40 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { resolve, isAbsolute, basename } from "node:path";
|
|
14
14
|
import { existsSync, readdirSync } from "node:fs";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
15
16
|
import { pathToFileURL } from "node:url";
|
|
16
17
|
import { homedir } from "node:os";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
17
19
|
import { hookLogWarn, hookLogError, hookLogInfo } from "./hook-logger";
|
|
18
|
-
import { getCustomHooks, clearCustomHooks } from "./custom-hooks-registry";
|
|
20
|
+
import { customPolicies, getCustomHooks, clearCustomHooks } from "./custom-hooks-registry";
|
|
19
21
|
import { findDistIndex, rewriteFileTree, TMP_SUFFIX, cleanupTmpFiles } from "./loader-utils";
|
|
20
22
|
import { findProjectConfigDir } from "./hooks-config";
|
|
21
23
|
import { trackHookEvent } from "./hook-telemetry";
|
|
22
24
|
import { getInstanceId } from "../../lib/telemetry-id";
|
|
23
25
|
import type { CustomHook } from "./policy-types";
|
|
26
|
+
import type { CloudManagedPolicyArtifact } from "./cloud-managed-policies";
|
|
24
27
|
|
|
25
28
|
const LOADING_KEY = "__FAILPROOFAI_LOADING_HOOKS__";
|
|
26
29
|
|
|
27
30
|
/** Regex matching convention policy filenames: *policies.{js,mjs,ts} */
|
|
28
31
|
const CONVENTION_FILE_RE = /policies\.(js|mjs|ts)$/;
|
|
29
32
|
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* lifetime cost is bounded by the process, not by uptime.
|
|
35
|
-
*/
|
|
33
|
+
/** Monotonic suffix for physically distinct temporary module trees. Bun
|
|
34
|
+
* ignores URL query strings when caching imports, so a query-only cache
|
|
35
|
+
* buster silently drops custom policies after the first request in the warm
|
|
36
|
+
* daemon worker. A unique filename is honored by both Bun and Node. */
|
|
36
37
|
let loadSequence = 0;
|
|
37
38
|
|
|
39
|
+
interface CachedPolicyModule {
|
|
40
|
+
fingerprint: string;
|
|
41
|
+
hooks: CustomHook[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One retained module instance per source entry. Reusing its registered hook
|
|
45
|
+
* functions prevents an unbounded ESM module-cache leak in the resident worker.
|
|
46
|
+
* A source-tree change replaces this entry and imports one new module. */
|
|
47
|
+
const policyModuleCache = new Map<string, CachedPolicyModule>();
|
|
48
|
+
|
|
38
49
|
/** Script extensions we could load, used to spot near-miss filenames. */
|
|
39
50
|
const LOADABLE_EXT_RE = /\.(js|mjs|ts)$/;
|
|
40
51
|
|
|
@@ -101,23 +112,27 @@ async function loadSingleFile(
|
|
|
101
112
|
const distIndex = await findDistIndex();
|
|
102
113
|
const distUrl = distIndex ? pathToFileURL(distIndex).href : null;
|
|
103
114
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// A distinct query string makes it a distinct module to the loader.
|
|
119
|
-
const fileUrl = `${pathToFileURL(entryTmp).href}?v=${++loadSequence}`;
|
|
115
|
+
const sequence = ++loadSequence;
|
|
116
|
+
const tmpSuffix = `${TMP_SUFFIX}.${process.pid}.${sequence}.mjs`;
|
|
117
|
+
tmpFiles = await rewriteFileTree(absPath, distUrl, distIndex, tmpSuffix);
|
|
118
|
+
|
|
119
|
+
const fingerprint = await fingerprintTemporaryTree(tmpFiles, tmpSuffix);
|
|
120
|
+
const cached = policyModuleCache.get(absPath);
|
|
121
|
+
if (cached?.fingerprint === fingerprint) {
|
|
122
|
+
for (const hook of cached.hooks) customPolicies.add({ ...hook });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const entryTmp = absPath + tmpSuffix;
|
|
127
|
+
const fileUrl = pathToFileURL(entryTmp).href;
|
|
128
|
+
const hooksBefore = getCustomHooks().length;
|
|
120
129
|
await import(/* webpackIgnore: true */ fileUrl);
|
|
130
|
+
policyModuleCache.set(absPath, {
|
|
131
|
+
fingerprint,
|
|
132
|
+
hooks: getCustomHooks()
|
|
133
|
+
.slice(hooksBefore)
|
|
134
|
+
.map((hook) => ({ ...hook })),
|
|
135
|
+
});
|
|
121
136
|
} catch (err) {
|
|
122
137
|
const msg = err instanceof Error ? err.message : String(err);
|
|
123
138
|
const errorType = /Cannot find module|MODULE_NOT_FOUND|ENOENT/i.test(msg)
|
|
@@ -139,6 +154,18 @@ async function loadSingleFile(
|
|
|
139
154
|
}
|
|
140
155
|
}
|
|
141
156
|
|
|
157
|
+
async function fingerprintTemporaryTree(tmpFiles: string[], tmpSuffix: string): Promise<string> {
|
|
158
|
+
const hash = createHash("sha256");
|
|
159
|
+
for (const tmpFile of [...tmpFiles].sort()) {
|
|
160
|
+
// The generated filenames change per load. Normalize both the logical path
|
|
161
|
+
// and rewritten import specifiers so identical source graphs hash equally.
|
|
162
|
+
hash.update(tmpFile.replaceAll(tmpSuffix, TMP_SUFFIX));
|
|
163
|
+
const contents = await readFile(tmpFile, "utf8");
|
|
164
|
+
hash.update(contents.replaceAll(tmpSuffix, TMP_SUFFIX));
|
|
165
|
+
}
|
|
166
|
+
return hash.digest("hex");
|
|
167
|
+
}
|
|
168
|
+
|
|
142
169
|
/**
|
|
143
170
|
* Load a single explicit custom hooks file (legacy API).
|
|
144
171
|
* Clears the registry, loads the file, returns registered hooks.
|
|
@@ -214,7 +241,11 @@ function warnSkippedPolicyFiles(dir: string, scope: "project" | "user"): void {
|
|
|
214
241
|
|
|
215
242
|
export async function loadAllCustomHooks(
|
|
216
243
|
customPoliciesPaths: string | string[] | undefined,
|
|
217
|
-
opts?: {
|
|
244
|
+
opts?: {
|
|
245
|
+
sessionCwd?: string;
|
|
246
|
+
customPoliciesEnabled?: boolean;
|
|
247
|
+
cloudManagedPolicies?: CloudManagedPolicyArtifact[];
|
|
248
|
+
},
|
|
218
249
|
): Promise<LoadAllResult> {
|
|
219
250
|
clearCustomHooks();
|
|
220
251
|
|
|
@@ -235,6 +266,9 @@ export async function loadAllCustomHooks(
|
|
|
235
266
|
// twice per event. Seeded by the explicit path below and consulted by both
|
|
236
267
|
// convention passes.
|
|
237
268
|
const loadedPaths = new Set<string>();
|
|
269
|
+
const cloudManagedByPath = new Map(
|
|
270
|
+
(opts?.cloudManagedPolicies ?? []).map((policy) => [resolve(policy.path), policy]),
|
|
271
|
+
);
|
|
238
272
|
|
|
239
273
|
// 1. Explicit custom policy paths. Accept a string for callers/configs using
|
|
240
274
|
// the legacy singular form.
|
|
@@ -250,7 +284,17 @@ export async function loadAllCustomHooks(
|
|
|
250
284
|
const hooksBefore = getCustomHooks().length;
|
|
251
285
|
await loadSingleFile(absPath);
|
|
252
286
|
for (const hook of getCustomHooks().slice(hooksBefore)) {
|
|
253
|
-
|
|
287
|
+
const cloudManaged = cloudManagedByPath.get(absPath);
|
|
288
|
+
const tagged = hook as CustomHook & {
|
|
289
|
+
__policyId?: string;
|
|
290
|
+
__cloudManaged?: CloudManagedPolicyArtifact;
|
|
291
|
+
};
|
|
292
|
+
if (cloudManaged) {
|
|
293
|
+
tagged.__cloudManaged = cloudManaged;
|
|
294
|
+
tagged.__policyId = `cloud:${cloudManaged.id}@${cloudManaged.revision}:${hook.name}`;
|
|
295
|
+
} else {
|
|
296
|
+
tagged.__policyId = customPolicyId(absPath, hook.name);
|
|
297
|
+
}
|
|
254
298
|
}
|
|
255
299
|
}
|
|
256
300
|
} else {
|
|
@@ -17,6 +17,7 @@ import { resolve } from "node:path";
|
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import type { IntegrationType } from "./types";
|
|
19
19
|
import { readHooksConfig } from "./hooks-config";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
20
21
|
|
|
21
22
|
const PROTOCOL_VERSION = 1;
|
|
22
23
|
|
|
@@ -66,6 +67,23 @@ export interface DaemonHookResponse {
|
|
|
66
67
|
stderr: string;
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Whether a daemon is listening, regardless of how it was started.
|
|
72
|
+
*
|
|
73
|
+
* Distinct from `daemonServiceStatus()`, which asks the service manager. A
|
|
74
|
+
* daemon run by hand — during development, or from a container — is invisible
|
|
75
|
+
* to that check but is very much running and pulling policy, so reporting "not
|
|
76
|
+
* installed, so nothing will be pulled" at someone whose machine is actively
|
|
77
|
+
* enforcing is simply false.
|
|
78
|
+
*/
|
|
79
|
+
export function daemonSocketPresent(): boolean {
|
|
80
|
+
try {
|
|
81
|
+
return existsSync(socketPath());
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
function socketPath(): string {
|
|
70
88
|
if (process.env.FAILPROOFAI_DAEMON_SOCKET) return process.env.FAILPROOFAI_DAEMON_SOCKET;
|
|
71
89
|
return resolve(homedir(), ".failproofai", "run", "failproofaid.sock");
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Gets the compiled `failproofaid` binary for this platform onto disk, from
|
|
3
|
+
* whichever of its two channels can supply it.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* The release assets have to exist for standalone installs regardless, so
|
|
12
|
-
* pointing the CLI at the same artifacts leaves exactly one channel.
|
|
5
|
+
* **npm first.** The four binaries publish as
|
|
6
|
+
* `@failproofai/failproofaid-<os>-<arch>` platform packages, pinned as
|
|
7
|
+
* `optionalDependencies` of this package, so `npm install failproofai` has
|
|
8
|
+
* usually already brought down the one matching this machine — `os`/`cpu`
|
|
9
|
+
* make npm skip the other three. Copying that file costs no network at all,
|
|
10
|
+
* which is the only thing that works behind a proxy or air-gapped.
|
|
13
11
|
*
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* **The GitHub Release second.** `failproofaid-<os>-<arch>.gz` plus a
|
|
13
|
+
* `SHA256SUMS` manifest are attached to the release tagged with this CLI's own
|
|
14
|
+
* version. That channel has to exist regardless — it is how anyone installs
|
|
15
|
+
* the daemon standalone, and how an install that skipped optional dependencies
|
|
16
|
+
* still gets one.
|
|
17
|
+
*
|
|
18
|
+
* The pair failed once before in a way worth keeping in mind: the platform
|
|
19
|
+
* packages were declared as `optionalDependencies` and never published, so
|
|
20
|
+
* every install resolved four 404s (see CHANGELOG 1.0.0-beta.3). What makes
|
|
21
|
+
* them safe now is that `scripts/build-daemon-packages.mjs` publishes them
|
|
22
|
+
* *before* the root package that pins them, and fails the release if it
|
|
23
|
+
* cannot — the names always exist by the time anything depends on them.
|
|
24
|
+
*
|
|
25
|
+
* The download URL is *constructed*, never discovered — no API call, no
|
|
26
|
+
* `releases/latest` redirect, no rate limit, and no chance of fetching a daemon
|
|
27
|
+
* built from different source than the CLI talking to it: the tag is this
|
|
28
|
+
* package's own version, so a CLI upgrade fetches its own matching binary.
|
|
18
29
|
*
|
|
19
30
|
* Everything here fails closed and never throws. A daemon that cannot be
|
|
20
31
|
* fetched leaves the machine on the in-process path it was already using;
|
|
@@ -22,9 +33,10 @@
|
|
|
22
33
|
* this writes an executable that a service manager will then run at login.
|
|
23
34
|
*/
|
|
24
35
|
import { createHash } from "node:crypto";
|
|
25
|
-
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
36
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
37
|
+
import { createRequire } from "node:module";
|
|
26
38
|
import { homedir } from "node:os";
|
|
27
|
-
import { resolve } from "node:path";
|
|
39
|
+
import { dirname, resolve } from "node:path";
|
|
28
40
|
import { gunzipSync } from "node:zlib";
|
|
29
41
|
import { version } from "../../package.json";
|
|
30
42
|
import type { PlatformKey } from "./daemon-service";
|
|
@@ -82,6 +94,114 @@ export interface DaemonDownloadResult {
|
|
|
82
94
|
error?: string;
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
/** The npm package carrying the daemon binary for one platform. */
|
|
98
|
+
export function platformPackageName(key: PlatformKey): string {
|
|
99
|
+
return `@failproofai/failproofaid-${key}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Locates the binary inside the installed platform package, or null.
|
|
104
|
+
*
|
|
105
|
+
* Resolution is anchored at `FAILPROOFAI_PACKAGE_ROOT` (set by
|
|
106
|
+
* `bin/failproofai.mjs` and the bundled `dist/cli.mjs`) rather than at
|
|
107
|
+
* `import.meta.url`, which does not survive the CJS bundle. Node's algorithm
|
|
108
|
+
* walks up from there, which covers both real layouts: a global install nests
|
|
109
|
+
* its dependencies at `<root>/node_modules/@failproofai/…`, a local one hoists
|
|
110
|
+
* them to `<project>/node_modules/@failproofai/…`.
|
|
111
|
+
*
|
|
112
|
+
* The specifier is *computed*, deliberately. A literal would make the bundler
|
|
113
|
+
* try to resolve a package that is optional, platform-specific, and absent on
|
|
114
|
+
* three machines out of four at build time.
|
|
115
|
+
*/
|
|
116
|
+
export function npmPlatformBinaryPath(key: PlatformKey): string | null {
|
|
117
|
+
const packageRoot = process.env.FAILPROOFAI_PACKAGE_ROOT;
|
|
118
|
+
if (!packageRoot) return null;
|
|
119
|
+
const name = platformPackageName(key);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const requireFromRoot = createRequire(resolve(packageRoot, "package.json"));
|
|
123
|
+
const found = platformPackageBinary(dirname(requireFromRoot.resolve(`${name}/package.json`)));
|
|
124
|
+
if (found) return found;
|
|
125
|
+
} catch {
|
|
126
|
+
// Not installed (the common case on a machine that skipped optional
|
|
127
|
+
// dependencies), or a resolver that cannot see it. The direct check below
|
|
128
|
+
// is the fallback, not a failure.
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return platformPackageBinary(resolve(packageRoot, "node_modules", name));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The binary inside one candidate platform-package directory, if it is the
|
|
136
|
+
* right one.
|
|
137
|
+
*
|
|
138
|
+
* The version check is not belt-and-braces. npm pins the exact version, but a
|
|
139
|
+
* workspace holding two `failproofai` versions can hoist the other one's
|
|
140
|
+
* platform package to the top of the tree — and installing that binary under
|
|
141
|
+
* *this* version's filename would put a daemon built from different source
|
|
142
|
+
* behind a CLI that believes it matches, which is the single thing the
|
|
143
|
+
* versioned install path exists to prevent. A mismatch falls through to the
|
|
144
|
+
* download, whose URL is pinned to this version.
|
|
145
|
+
*/
|
|
146
|
+
function platformPackageBinary(dir: string): string | null {
|
|
147
|
+
try {
|
|
148
|
+
const manifest = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf8")) as {
|
|
149
|
+
version?: string;
|
|
150
|
+
};
|
|
151
|
+
if (manifest.version !== version) return null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const candidate = resolve(dir, "bin", "failproofaid");
|
|
156
|
+
return existsSync(candidate) ? candidate : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Writes the daemon binary to its versioned path.
|
|
161
|
+
*
|
|
162
|
+
* Shared by both channels so a binary lands identically however it arrived:
|
|
163
|
+
* staged in the same directory as the target (so the install is an atomic
|
|
164
|
+
* rename within one filesystem, never a half-written executable a service
|
|
165
|
+
* manager could pick up), mode 0755, pid-suffixed so two concurrent wizards
|
|
166
|
+
* stay off each other's file.
|
|
167
|
+
*/
|
|
168
|
+
function installBinaryBytes(binary: Buffer): string {
|
|
169
|
+
const target = installedBinaryPath();
|
|
170
|
+
const tempPath = `${target}.${process.pid}.tmp`;
|
|
171
|
+
try {
|
|
172
|
+
mkdirSync(daemonBinaryDir(), { recursive: true });
|
|
173
|
+
writeFileSync(tempPath, binary);
|
|
174
|
+
chmodSync(tempPath, 0o755);
|
|
175
|
+
renameSync(tempPath, target);
|
|
176
|
+
return target;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
rmSync(tempPath, { force: true });
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Installs the daemon from the npm platform package, if one is present.
|
|
185
|
+
*
|
|
186
|
+
* No checksum step, unlike the download: npm verified the tarball's integrity
|
|
187
|
+
* against the registry when it installed it, and this file never crossed the
|
|
188
|
+
* network on its own.
|
|
189
|
+
*
|
|
190
|
+
* Deliberately NOT gated on `FAILPROOFAI_NO_DOWNLOAD` — that variable exists
|
|
191
|
+
* so an air-gapped machine does not reach out, and this is a local copy. On
|
|
192
|
+
* exactly those machines it is the only channel that can work.
|
|
193
|
+
*/
|
|
194
|
+
export function installFromNpmPackage(key: PlatformKey): DaemonDownloadResult {
|
|
195
|
+
const source = npmPlatformBinaryPath(key);
|
|
196
|
+
if (!source) return { error: `${platformPackageName(key)} is not installed` };
|
|
197
|
+
try {
|
|
198
|
+
return { path: installBinaryBytes(readFileSync(source)) };
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
201
|
+
return { error: `failed to install failproofaid from ${source}: ${message}` };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
85
205
|
async function fetchBytes(url: string): Promise<Buffer> {
|
|
86
206
|
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
87
207
|
if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
|
|
@@ -118,10 +238,6 @@ export async function downloadFailproofaidBinary(key: PlatformKey): Promise<Daem
|
|
|
118
238
|
}
|
|
119
239
|
|
|
120
240
|
const assetName = `failproofaid-${key}.gz`;
|
|
121
|
-
// Same directory as the target so the install is a rename within one
|
|
122
|
-
// filesystem — atomic, and never a half-written binary a service manager
|
|
123
|
-
// could pick up. The pid keeps two concurrent wizards off each other's file.
|
|
124
|
-
const tempPath = `${target}.${process.pid}.tmp`;
|
|
125
241
|
|
|
126
242
|
try {
|
|
127
243
|
const [asset, manifest] = await Promise.all([
|
|
@@ -138,14 +254,8 @@ export async function downloadFailproofaidBinary(key: PlatformKey): Promise<Daem
|
|
|
138
254
|
return { error: `checksum mismatch for ${assetName} (expected ${expected}, got ${actual})` };
|
|
139
255
|
}
|
|
140
256
|
|
|
141
|
-
|
|
142
|
-
mkdirSync(daemonBinaryDir(), { recursive: true });
|
|
143
|
-
writeFileSync(tempPath, binary);
|
|
144
|
-
chmodSync(tempPath, 0o755);
|
|
145
|
-
renameSync(tempPath, target);
|
|
146
|
-
return { path: target };
|
|
257
|
+
return { path: installBinaryBytes(gunzipSync(asset)) };
|
|
147
258
|
} catch (err) {
|
|
148
|
-
rmSync(tempPath, { force: true });
|
|
149
259
|
const message = err instanceof Error ? err.message : String(err);
|
|
150
260
|
return { error: `failed to download failproofaid v${version} for ${key}: ${message}` };
|
|
151
261
|
}
|
|
@@ -22,7 +22,7 @@ import { resolve, dirname } from "node:path";
|
|
|
22
22
|
import { execFileSync } from "node:child_process";
|
|
23
23
|
import { hookLogWarn } from "./hook-logger";
|
|
24
24
|
import { getConfigPathForScope } from "./hooks-config";
|
|
25
|
-
import { downloadFailproofaidBinary, installedBinaryPath } from "./daemon-download";
|
|
25
|
+
import { downloadFailproofaidBinary, installFromNpmPackage, installedBinaryPath } from "./daemon-download";
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Every `systemctl --user` / `launchctl` call is bounded. Both talk to a
|
|
@@ -145,13 +145,19 @@ export function resolveFailproofaidBinaryPath(): string | null {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
/**
|
|
148
|
-
* Resolves the binary,
|
|
149
|
-
* not already on disk.
|
|
148
|
+
* Resolves the binary, installing one if it is not already on disk.
|
|
150
149
|
*
|
|
151
|
-
* Only the install path calls this
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
150
|
+
* Only the install path calls this — `failproofai config` choosing the global
|
|
151
|
+
* scope is the moment a machine that opted into a daemon actually acquires
|
|
152
|
+
* one. Two channels, in this order:
|
|
153
|
+
*
|
|
154
|
+
* 1. The `@failproofai/failproofaid-<os>-<arch>` platform package, which a
|
|
155
|
+
* plain `npm install failproofai` already brought down as an optional
|
|
156
|
+
* dependency. No network, so it is also the only channel that works
|
|
157
|
+
* air-gapped or behind a proxy that blocks github.com.
|
|
158
|
+
* 2. The GitHub Release asset for this exact version, checksum-verified.
|
|
159
|
+
* Covers installs that skipped optional dependencies, older tarballs, and
|
|
160
|
+
* anyone installing the daemon standalone.
|
|
155
161
|
*/
|
|
156
162
|
export async function ensureFailproofaidBinary(): Promise<{ path?: string; reason?: string }> {
|
|
157
163
|
const existing = resolveFailproofaidBinaryPath();
|
|
@@ -162,9 +168,18 @@ export async function ensureFailproofaidBinary(): Promise<{ path?: string; reaso
|
|
|
162
168
|
return { reason: `failproofaid has no prebuilt binary for ${process.platform}/${process.arch}` };
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
const fromNpm = installFromNpmPackage(key);
|
|
172
|
+
if (fromNpm.path) return { path: fromNpm.path };
|
|
173
|
+
|
|
165
174
|
const result = await downloadFailproofaidBinary(key);
|
|
166
175
|
if (result.path) return { path: result.path };
|
|
167
|
-
|
|
176
|
+
// Both channels are worth reporting: "not installed" alone reads as a broken
|
|
177
|
+
// package, and the download error alone hides that npm could have supplied it.
|
|
178
|
+
return {
|
|
179
|
+
reason: result.error
|
|
180
|
+
? `${result.error} (${fromNpm.error})`
|
|
181
|
+
: "failproofaid binary could not be installed",
|
|
182
|
+
};
|
|
168
183
|
}
|
|
169
184
|
|
|
170
185
|
/**
|
package/src/hooks/handler.ts
CHANGED
|
@@ -41,6 +41,8 @@ import { resolveTranscriptPath } from "./resolve-transcript-path";
|
|
|
41
41
|
import { getInstanceId } from "../../lib/telemetry-id";
|
|
42
42
|
import { hookLogInfo, hookLogWarn } from "./hook-logger";
|
|
43
43
|
import { readStdinPayload } from "./read-stdin";
|
|
44
|
+
import { readActiveCloudManagedPolicies, type CloudManagedPolicyArtifact } from "./cloud-managed-policies";
|
|
45
|
+
import { readActivePause, type ActivePause } from "./session-pause";
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
48
|
* Canonicalize an event name to PascalCase. Codex sends snake_case event names
|
|
@@ -134,6 +136,37 @@ export interface EvaluateHookEventOptions {
|
|
|
134
136
|
fallbackCwd?: string;
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Runs an observe-mode policy under the same timeout and error handling as an
|
|
141
|
+
* enforcing one, so what gets measured is what would actually have happened —
|
|
142
|
+
* including a policy that times out, which in enforce mode is an allow and must
|
|
143
|
+
* be recorded as one rather than as a would-deny.
|
|
144
|
+
*/
|
|
145
|
+
async function runObserved(
|
|
146
|
+
hook: CustomHook,
|
|
147
|
+
ctx: Parameters<CustomHook["fn"]>[0],
|
|
148
|
+
hookName: string,
|
|
149
|
+
eventType: string,
|
|
150
|
+
cli: IntegrationType,
|
|
151
|
+
): Promise<PolicyResult> {
|
|
152
|
+
try {
|
|
153
|
+
return await Promise.race([
|
|
154
|
+
hook.fn(ctx),
|
|
155
|
+
new Promise<PolicyResult>((_, reject) => setTimeout(() => reject(new Error("timeout")), 10_000)),
|
|
156
|
+
]);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
159
|
+
void trackHookEvent(getInstanceId(), "custom_hook_error", {
|
|
160
|
+
hook_name: hookName,
|
|
161
|
+
error_type: msg === "timeout" ? "timeout" : "exception",
|
|
162
|
+
event_type: eventType,
|
|
163
|
+
cli,
|
|
164
|
+
is_observe_mode: true,
|
|
165
|
+
});
|
|
166
|
+
return { decision: "allow" };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
137
170
|
/**
|
|
138
171
|
* The core hook-evaluation logic, decoupled from process stdin/stdout so it
|
|
139
172
|
* can be called repeatedly inside a long-lived process (the daemon's warm
|
|
@@ -209,6 +242,20 @@ export async function evaluateHookEvent(
|
|
|
209
242
|
let config: HooksConfig;
|
|
210
243
|
let customHooksList: CustomHook[] = [];
|
|
211
244
|
let conventionHookNames = new Set<string>();
|
|
245
|
+
let activePause: ActivePause | null = null;
|
|
246
|
+
/** Registered policy name → where it came from. See the set() below. */
|
|
247
|
+
const policyAttribution = new Map<
|
|
248
|
+
string,
|
|
249
|
+
{ source: "custom" | "convention" | "cloud"; cloudPolicyId?: string; cloudRevision?: number }
|
|
250
|
+
>();
|
|
251
|
+
let cloudGeneration: number | undefined;
|
|
252
|
+
/** What observe-mode policies WOULD have done, had they been enforcing. */
|
|
253
|
+
const observedResults: Array<{
|
|
254
|
+
policyId: string;
|
|
255
|
+
revision: number;
|
|
256
|
+
decision: "deny" | "instruct";
|
|
257
|
+
reason: string | null;
|
|
258
|
+
}> = [];
|
|
212
259
|
|
|
213
260
|
if (opts?.forceDecision) {
|
|
214
261
|
// Fail-closed: no config/custom-hook loading at all — a daemon that
|
|
@@ -228,25 +275,80 @@ export async function evaluateHookEvent(
|
|
|
228
275
|
// Load enabled policies (merge across project/local/global scopes)
|
|
229
276
|
config = readMergedHooksConfig(session.cwd);
|
|
230
277
|
clearPolicies();
|
|
231
|
-
|
|
278
|
+
|
|
279
|
+
// A session pause suspends LOCAL policy only, for a bounded time. Cloud
|
|
280
|
+
// assignments are exempt below for the same reason `disabledCustomPolicies`
|
|
281
|
+
// already exempts them: a locally-issued command that could switch off a
|
|
282
|
+
// centrally assigned policy would make cloud enforcement decorative.
|
|
283
|
+
activePause = readActivePause(session.sessionId);
|
|
284
|
+
registerBuiltinPolicies(activePause ? [] : config.enabledPolicies);
|
|
285
|
+
|
|
286
|
+
// Cloud-managed policies are daemon-reconciled artifacts, but they use
|
|
287
|
+
// the same public JS policy API as local custom policies. Verify and add
|
|
288
|
+
// only the paths referenced by the atomically active generation.
|
|
289
|
+
const cloudManagedPolicies = readActiveCloudManagedPolicies();
|
|
290
|
+
// Recorded on every row once a machine is managed, whether or not a cloud
|
|
291
|
+
// policy decided this event: "what was deployed here at the time" is a
|
|
292
|
+
// separate question from "what decided", and only the former can tell a
|
|
293
|
+
// rollout that changed nothing from one that never reached the machine.
|
|
294
|
+
cloudGeneration = cloudManagedPolicies[0]?.generation;
|
|
295
|
+
const configuredCustomPaths = config.customPoliciesPaths ?? config.customPoliciesPath;
|
|
296
|
+
const allExplicitPaths =
|
|
297
|
+
cloudManagedPolicies.length === 0
|
|
298
|
+
? configuredCustomPaths
|
|
299
|
+
: [
|
|
300
|
+
...(typeof configuredCustomPaths === "string" ? [configuredCustomPaths] : configuredCustomPaths ?? []),
|
|
301
|
+
...cloudManagedPolicies.map((policy) => policy.path),
|
|
302
|
+
];
|
|
232
303
|
|
|
233
304
|
// Load and register custom hooks (layer 2, after builtins)
|
|
234
|
-
const loadResult = await loadAllCustomHooks(
|
|
305
|
+
const loadResult = await loadAllCustomHooks(allExplicitPaths, {
|
|
235
306
|
sessionCwd: session.cwd,
|
|
236
307
|
customPoliciesEnabled: config.customPoliciesEnabled,
|
|
308
|
+
...(cloudManagedPolicies.length > 0 ? { cloudManagedPolicies } : {}),
|
|
237
309
|
});
|
|
238
310
|
customHooksList = loadResult.hooks;
|
|
239
311
|
const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
|
|
240
312
|
conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
|
|
241
313
|
|
|
242
314
|
for (const hook of customHooksList) {
|
|
243
|
-
const
|
|
244
|
-
|
|
315
|
+
const taggedHook = hook as CustomHook & {
|
|
316
|
+
__policyId?: string;
|
|
317
|
+
__cloudManaged?: CloudManagedPolicyArtifact;
|
|
318
|
+
};
|
|
319
|
+
const policyId = taggedHook.__policyId;
|
|
320
|
+
const cloudManaged = taggedHook.__cloudManaged;
|
|
321
|
+
// Local config cannot disable a centrally assigned policy merely by
|
|
322
|
+
// copying its generated ID into disabledCustomPolicies.
|
|
323
|
+
if (!cloudManaged && policyId && disabledCustomPolicies.has(policyId)) continue;
|
|
324
|
+
// Same rule for a session pause — it suspends local policy, never cloud.
|
|
325
|
+
if (!cloudManaged && activePause) continue;
|
|
245
326
|
const hookName = hook.name;
|
|
246
327
|
const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope;
|
|
247
328
|
const isConvention = !!conventionScope;
|
|
248
|
-
const prefix =
|
|
329
|
+
const prefix = cloudManaged
|
|
330
|
+
? `cloud/${cloudManaged.id}@${cloudManaged.revision}`
|
|
331
|
+
: isConvention
|
|
332
|
+
? `.failproofai-${conventionScope}`
|
|
333
|
+
: "custom";
|
|
334
|
+
// Observe mode: run it for real, record what it decided, then hand back
|
|
335
|
+
// an allow. Evaluating and discarding is the whole point — a rollout is
|
|
336
|
+
// measured against real traffic before it can break anyone's work, and
|
|
337
|
+
// a policy that did not actually run would measure nothing.
|
|
338
|
+
const observeOnly = cloudManaged?.effect === "observe";
|
|
249
339
|
const fn: PolicyFunction = async (ctx): Promise<PolicyResult> => {
|
|
340
|
+
if (observeOnly) {
|
|
341
|
+
const shadow = await runObserved(hook, ctx, hookName, eventType, cli);
|
|
342
|
+
if (shadow.decision !== "allow") {
|
|
343
|
+
observedResults.push({
|
|
344
|
+
policyId: cloudManaged!.id,
|
|
345
|
+
revision: cloudManaged!.revision,
|
|
346
|
+
decision: shadow.decision,
|
|
347
|
+
reason: shadow.reason ?? null,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return { decision: "allow" };
|
|
351
|
+
}
|
|
250
352
|
try {
|
|
251
353
|
const result = await Promise.race([
|
|
252
354
|
hook.fn(ctx),
|
|
@@ -268,8 +370,18 @@ export async function evaluateHookEvent(
|
|
|
268
370
|
return { decision: "allow" };
|
|
269
371
|
}
|
|
270
372
|
};
|
|
373
|
+
const registeredName = `${prefix}/${hookName}`;
|
|
374
|
+
// Record where this policy came from as structured data, keyed by the
|
|
375
|
+
// exact name the evaluator will report back. The display name already
|
|
376
|
+
// encodes it ("cloud/org-guard@7/…"), but only as a string — so the one
|
|
377
|
+
// question cloud attribution has to answer, "which rollout produced
|
|
378
|
+
// this decision", could only be answered by re-parsing our own label.
|
|
379
|
+
policyAttribution.set(registeredName, {
|
|
380
|
+
source: cloudManaged ? "cloud" : isConvention ? "convention" : "custom",
|
|
381
|
+
...(cloudManaged ? { cloudPolicyId: cloudManaged.id, cloudRevision: cloudManaged.revision } : {}),
|
|
382
|
+
});
|
|
271
383
|
registerPolicy(
|
|
272
|
-
|
|
384
|
+
registeredName,
|
|
273
385
|
hook.description ?? "",
|
|
274
386
|
fn,
|
|
275
387
|
hook.match ?? {},
|
|
@@ -335,6 +447,32 @@ export async function evaluateHookEvent(
|
|
|
335
447
|
cwd: session.cwd,
|
|
336
448
|
permissionMode: session.permissionMode,
|
|
337
449
|
hookEventName: session.hookEventName,
|
|
450
|
+
// Attribution. A builtin is anything registered that is not in the map,
|
|
451
|
+
// so its absence is meaningful rather than missing — but only when a
|
|
452
|
+
// policy actually decided; a plain allow names nobody.
|
|
453
|
+
...(result.policyName
|
|
454
|
+
? (() => {
|
|
455
|
+
const attribution = policyAttribution.get(result.policyName);
|
|
456
|
+
return {
|
|
457
|
+
policySource: attribution?.source ?? ("builtin" as const),
|
|
458
|
+
...(attribution?.cloudPolicyId ? { cloudPolicyId: attribution.cloudPolicyId } : {}),
|
|
459
|
+
...(attribution?.cloudRevision !== undefined
|
|
460
|
+
? { cloudRevision: attribution.cloudRevision }
|
|
461
|
+
: {}),
|
|
462
|
+
};
|
|
463
|
+
})()
|
|
464
|
+
: {}),
|
|
465
|
+
...(cloudGeneration !== undefined ? { cloudGeneration } : {}),
|
|
466
|
+
// The point of observe mode is this record. Without it the rollout is
|
|
467
|
+
// unmeasurable and the row is indistinguishable from one where the policy
|
|
468
|
+
// never matched at all.
|
|
469
|
+
...(observedResults.length > 0 ? { observed: observedResults } : {}),
|
|
470
|
+
// Without these, a row logged during a pause is indistinguishable from
|
|
471
|
+
// one where every policy ran and allowed — the log would assert a clean
|
|
472
|
+
// window over exactly the window that was not enforced.
|
|
473
|
+
...(activePause
|
|
474
|
+
? { pausedBy: activePause.setBy, pauseExpiresAt: activePause.expiresAt }
|
|
475
|
+
: {}),
|
|
338
476
|
};
|
|
339
477
|
try {
|
|
340
478
|
persistHookActivity(activityEntry);
|