failproofai 1.0.0-beta.2 → 1.0.0-beta.3
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.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.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.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.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.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.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/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- 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]__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]__0qucxyj._.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]__1psjybo._.js → [root-of-the-server]__10s_z4l._.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]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- 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_0jfts82.js} +1 -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/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- 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 +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- 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/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/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
* install/uninstall manager and the existing searchable policy picker.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
16
|
-
import { homedir } from "node:os";
|
|
16
|
+
import { homedir, hostname } from "node:os";
|
|
17
17
|
import { resolve, sep } from "node:path";
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
20
|
selectOne,
|
|
21
21
|
multiSelect,
|
|
22
|
+
promptText,
|
|
22
23
|
intro,
|
|
23
24
|
outro,
|
|
24
25
|
summarize,
|
|
@@ -26,6 +27,12 @@ import {
|
|
|
26
27
|
type TTYIn,
|
|
27
28
|
type TTYOut,
|
|
28
29
|
} from "./tui";
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_INGEST_URL,
|
|
32
|
+
validateIngestKey,
|
|
33
|
+
writeIngestCredential,
|
|
34
|
+
writeCollectorSettings,
|
|
35
|
+
} from "./collector-config";
|
|
29
36
|
import {
|
|
30
37
|
detectInstalledClis,
|
|
31
38
|
getIntegration,
|
|
@@ -47,6 +54,12 @@ import {
|
|
|
47
54
|
setDaemonConfigured,
|
|
48
55
|
} from "./daemon-service";
|
|
49
56
|
import { hookLogWarn } from "./hook-logger";
|
|
57
|
+
import {
|
|
58
|
+
readCloudCredentials,
|
|
59
|
+
verifyCloudCredentials,
|
|
60
|
+
writeCloudCredentials,
|
|
61
|
+
} from "./cloud-enrollment";
|
|
62
|
+
import { cloudBaseFor, ingestUrlFor } from "./cloud-connection";
|
|
50
63
|
|
|
51
64
|
export interface WizardIO {
|
|
52
65
|
stdin?: TTYIn;
|
|
@@ -365,7 +378,14 @@ export function reviewLines(state: {
|
|
|
365
378
|
const assistantNames = clis.map((c) => getIntegration(c).displayName);
|
|
366
379
|
lines.push(` Where : ${where}`);
|
|
367
380
|
lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
|
|
368
|
-
|
|
381
|
+
// Zero is a deliberate answer, not a failed step — say so, and say where to
|
|
382
|
+
// change it, so the review screen doesn't read like the wizard lost the
|
|
383
|
+
// selection. Hooks still install; only the builtin set is empty.
|
|
384
|
+
lines.push(
|
|
385
|
+
policies.length === 0
|
|
386
|
+
? " Policies : none enabled (add later: failproofai policies --install)"
|
|
387
|
+
: ` Policies : ${policies.length} enabled`,
|
|
388
|
+
);
|
|
369
389
|
if (installDaemon && isDaemonSupportedPlatform()) {
|
|
370
390
|
lines.push(
|
|
371
391
|
` Daemon : failproofaid, installed as a system service running as you`,
|
|
@@ -635,12 +655,23 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
635
655
|
const presetChoices = buildPresetChoices(cwd, customEnabledBefore);
|
|
636
656
|
const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
|
|
637
657
|
|
|
658
|
+
// No minimum. Ticking nothing is a real answer — someone who only wants their
|
|
659
|
+
// own custom policies, or who intends to pick bundles later from the
|
|
660
|
+
// dashboard, was previously stuck on this step with no way forward and no
|
|
661
|
+
// explanation beyond "Select at least 1". An empty set is already supported
|
|
662
|
+
// end to end: `installHooksImpl` documents its explicit-array path as "may be
|
|
663
|
+
// empty", `replace: true` makes it the full enabled set, and `summarize([])`
|
|
664
|
+
// renders "none". Hooks still install, so enforcement can be switched on
|
|
665
|
+
// later without re-running setup.
|
|
666
|
+
//
|
|
667
|
+
// The assistants step above keeps its minimum deliberately: an empty CLI list
|
|
668
|
+
// does NOT mean "no assistants" there — `installHooksImpl` falls back to
|
|
669
|
+
// ["claude"], so letting it through would silently install for Claude.
|
|
638
670
|
const presets = await multiSelect<string>({
|
|
639
671
|
message: "What should we guard against?",
|
|
640
672
|
choices: presetChoices,
|
|
641
|
-
minSelected: 1,
|
|
642
673
|
summaryNoun: "bundles",
|
|
643
|
-
hint: "space toggles · combine presets · ↵ confirm",
|
|
674
|
+
hint: "space toggles · combine presets · ↵ confirm · none is fine",
|
|
644
675
|
stdin,
|
|
645
676
|
stdout,
|
|
646
677
|
});
|
|
@@ -650,7 +681,166 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
650
681
|
// locked-unchecked and must not write a disabling flag.
|
|
651
682
|
const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined;
|
|
652
683
|
|
|
653
|
-
// 4 —
|
|
684
|
+
// 4 — Send data to AgentEye? Last, and only when a daemon was asked for,
|
|
685
|
+
// since the daemon is what runs the collector.
|
|
686
|
+
//
|
|
687
|
+
// Deliberately AFTER the enforcement questions rather than beside the daemon
|
|
688
|
+
// one: by this point the user has decided what to protect, so "and would you
|
|
689
|
+
// like to see it in a dashboard?" follows naturally, where asking up front
|
|
690
|
+
// interrupts setup with a question about a product they may not have.
|
|
691
|
+
//
|
|
692
|
+
// Defaults to NO, and the transcript choice is a SEPARATE question from the
|
|
693
|
+
// key, because a transcript carries prompts, file contents and whatever was
|
|
694
|
+
// pasted into a terminal. Configuring ingest must never silently send those.
|
|
695
|
+
let collector: { cred: { url: string; key: string }; sessions: boolean } | null = null;
|
|
696
|
+
if (daemonWanted) {
|
|
697
|
+
const send = await selectOne<"yes" | "no">({
|
|
698
|
+
message: "Send session and hook data to AgentEye?",
|
|
699
|
+
choices: [
|
|
700
|
+
{
|
|
701
|
+
label: "Not now",
|
|
702
|
+
value: "no",
|
|
703
|
+
hint: "nothing leaves this machine",
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
label: "Yes — connect to a dashboard",
|
|
707
|
+
value: "yes",
|
|
708
|
+
hint: "needs an events:add API key",
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
stdin,
|
|
712
|
+
stdout,
|
|
713
|
+
});
|
|
714
|
+
if (send === null) return cancel();
|
|
715
|
+
|
|
716
|
+
if (send === "yes") {
|
|
717
|
+
// If this machine is ALREADY enrolled with Failproof Cloud, it has a URL
|
|
718
|
+
// and a token that usually work for both capabilities. Asking for them
|
|
719
|
+
// again is the seam that made connecting feel like two products: someone
|
|
720
|
+
// runs `--connect`, sees an empty dashboard, and has no reason to think a
|
|
721
|
+
// second credential exists. Offer what we already know, and let it be
|
|
722
|
+
// overridden.
|
|
723
|
+
const existing = readCloudCredentials();
|
|
724
|
+
let url: string | null = null;
|
|
725
|
+
let key: string | null = null;
|
|
726
|
+
|
|
727
|
+
if (existing) {
|
|
728
|
+
const reuse = await selectOne<"reuse" | "other">({
|
|
729
|
+
message: `Use this machine's existing connection to ${existing.url}?`,
|
|
730
|
+
choices: [
|
|
731
|
+
{
|
|
732
|
+
label: `Yes — reuse it`,
|
|
733
|
+
value: "reuse",
|
|
734
|
+
hint: `as ${existing.machineId}, same token`,
|
|
735
|
+
},
|
|
736
|
+
{ label: "No — use a different endpoint and key", value: "other", hint: "" },
|
|
737
|
+
],
|
|
738
|
+
stdin,
|
|
739
|
+
stdout,
|
|
740
|
+
});
|
|
741
|
+
if (reuse === null) return cancel();
|
|
742
|
+
if (reuse === "reuse") {
|
|
743
|
+
url = ingestUrlFor(existing.url);
|
|
744
|
+
key = existing.token;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (url === null) {
|
|
749
|
+
const entered = await promptText({
|
|
750
|
+
message: "Failproof Cloud URL",
|
|
751
|
+
defaultValue: cloudBaseFor(DEFAULT_INGEST_URL),
|
|
752
|
+
hint: "Enter for the hosted endpoint",
|
|
753
|
+
validate: (v) => (/^https?:\/\//.test(v) ? null : "must be an http(s) URL"),
|
|
754
|
+
stdin,
|
|
755
|
+
stdout,
|
|
756
|
+
});
|
|
757
|
+
if (entered === null) return cancel();
|
|
758
|
+
// Asked for as a base and derived, so this and `--connect` take the
|
|
759
|
+
// same thing. Someone pasting the older `/events` endpoint still works.
|
|
760
|
+
url = ingestUrlFor(cloudBaseFor(entered));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (key === null) {
|
|
764
|
+
key = await promptText({
|
|
765
|
+
message: "API key",
|
|
766
|
+
hint: "needs events:add",
|
|
767
|
+
// Masked: setup is routinely run while screen-sharing, and a pasted key
|
|
768
|
+
// would otherwise sit in the scrollback of every recording.
|
|
769
|
+
mask: true,
|
|
770
|
+
validate: (v) => (v.length >= 8 ? null : "that looks too short to be a key"),
|
|
771
|
+
stdin,
|
|
772
|
+
stdout,
|
|
773
|
+
});
|
|
774
|
+
if (key === null) return cancel();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Check BEFORE writing anything. A typo'd key is otherwise only
|
|
778
|
+
// discovered later as a silent pile of 401s parked in failed/, which
|
|
779
|
+
// reads like a server problem rather than a typo.
|
|
780
|
+
stdout.write("\nChecking the key… ");
|
|
781
|
+
const check = await validateIngestKey({ url, key });
|
|
782
|
+
if (!check.ok) {
|
|
783
|
+
stdout.write(`\nThat did not work: ${check.reason}\n`);
|
|
784
|
+
const retry = await selectOne<"skip" | "anyway">({
|
|
785
|
+
message: "Carry on without sending data?",
|
|
786
|
+
choices: [
|
|
787
|
+
{ label: "Yes, skip it", value: "skip", hint: "re-run failproofai config later" },
|
|
788
|
+
{ label: "Save it anyway", value: "anyway", hint: "if you know the server is just down" },
|
|
789
|
+
],
|
|
790
|
+
stdin,
|
|
791
|
+
stdout,
|
|
792
|
+
});
|
|
793
|
+
if (retry === null) return cancel();
|
|
794
|
+
if (retry === "skip") {
|
|
795
|
+
stdout.write("Skipping data collection. Everything else still applies.\n\n");
|
|
796
|
+
} else {
|
|
797
|
+
collector = { cred: { url, key }, sessions: false };
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
stdout.write("looks good.\n");
|
|
801
|
+
const sessions = await selectOne<"hooks" | "both">({
|
|
802
|
+
message: "What should it send?",
|
|
803
|
+
choices: [
|
|
804
|
+
{
|
|
805
|
+
label: "Policy decisions only",
|
|
806
|
+
value: "hooks",
|
|
807
|
+
hint: "which hooks fired and what they decided \u00b7 no file contents",
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
label: "Decisions and full session transcripts",
|
|
811
|
+
value: "both",
|
|
812
|
+
hint: "includes prompts, file contents and command output",
|
|
813
|
+
},
|
|
814
|
+
],
|
|
815
|
+
stdin,
|
|
816
|
+
stdout,
|
|
817
|
+
});
|
|
818
|
+
if (sessions === null) return cancel();
|
|
819
|
+
collector = { cred: { url, key }, sessions: sessions === "both" };
|
|
820
|
+
|
|
821
|
+
// The other half of the same connection. If this machine is not
|
|
822
|
+
// enrolled for policy and the key turns out to carry `policies:pull`,
|
|
823
|
+
// enrol it here rather than making the user discover a second command.
|
|
824
|
+
// Silent when the key lacks the permission — an events-only key is a
|
|
825
|
+
// perfectly reasonable thing to have, and a warning about a capability
|
|
826
|
+
// nobody asked for is noise.
|
|
827
|
+
if (!readCloudCredentials()) {
|
|
828
|
+
const base = cloudBaseFor(url);
|
|
829
|
+
const machineId = hostname();
|
|
830
|
+
const enrol = await verifyCloudCredentials({ url: base, machineId, token: key });
|
|
831
|
+
if (enrol.ok) {
|
|
832
|
+
writeCloudCredentials({ url: base, machineId, token: key });
|
|
833
|
+
stdout.write(
|
|
834
|
+
`Also connected for cloud-managed policy (${enrol.policyCount} assigned, generation ${enrol.generation}).\n`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
// 5 — Review & apply
|
|
654
844
|
const decision = await selectOne<"apply" | "cancel">({
|
|
655
845
|
message: "Ready to apply?",
|
|
656
846
|
body: reviewLines({ scope, clis, policies, cwd, customEnabled, installDaemon: daemonWanted }),
|
|
@@ -717,6 +907,32 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
717
907
|
});
|
|
718
908
|
}
|
|
719
909
|
|
|
910
|
+
// Collector config, written only when the daemon actually came up: the
|
|
911
|
+
// daemon is what runs the collector, so writing a credential for a service
|
|
912
|
+
// that failed to install would leave a key on disk doing nothing.
|
|
913
|
+
if (collector && daemonInstalled) {
|
|
914
|
+
try {
|
|
915
|
+
writeIngestCredential(collector.cred);
|
|
916
|
+
writeCollectorSettings({
|
|
917
|
+
sessions: collector.sessions,
|
|
918
|
+
hooks: true,
|
|
919
|
+
hooksVerbosity: "decisions",
|
|
920
|
+
redact: "minimal",
|
|
921
|
+
});
|
|
922
|
+
// Never the key, the URL, or the count — only that it happened and what
|
|
923
|
+
// shape of data the user agreed to send.
|
|
924
|
+
void emit("configure_collector", { sessions: collector.sessions });
|
|
925
|
+
} catch (err) {
|
|
926
|
+
// Non-fatal, like the daemon: the rest of setup is worth keeping, and a
|
|
927
|
+
// machine with no collector behaves exactly as every release before this.
|
|
928
|
+
hookLogWarn(
|
|
929
|
+
`collector configuration was not written: ${err instanceof Error ? err.message : String(err)}`,
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
} else if (collector && !daemonInstalled) {
|
|
933
|
+
hookLogWarn("skipped collector configuration because the daemon was not installed");
|
|
934
|
+
}
|
|
935
|
+
|
|
720
936
|
await applied;
|
|
721
937
|
// Only now — a completed apply — is the launcher considered "seen", so the
|
|
722
938
|
// first-run bare invocation stops redirecting here and opens the dashboard.
|
|
@@ -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");
|
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);
|