failproofai 0.0.14-beta.0 → 0.0.14-beta.2
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/.codex/hooks.json +6 -6
- package/.next/standalone/.cursor/hooks.json +6 -6
- package/.next/standalone/.factory/hooks.json +9 -9
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +5 -5
- 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 +2 -2
- 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 +2 -2
- 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 +2 -2
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- 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 +2 -2
- 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 +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
- 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 +2 -2
- 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 +2 -2
- 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 +2 -2
- 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]__0--lkk6._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-219ec._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__04_h-a5._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__08w4wmd._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g6bbuw._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q-v9z2._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q0qzx1._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0q2nbsl._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0rv7m0k._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0xuaoik._.js +3 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__16le-kd._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__17g9wh7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ffkmds._.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]__1legmza._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mrihkj._.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]__1qb590j._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1shcqgr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1xuedea._.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/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +1 -1
- 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]__00cz9as._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__015_i4t._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__030k0c6._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03lv-pe._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__042d3cd._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__05r_17v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09q1168._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07qfsfa._.js → [root-of-the-server]__09v9cwb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e2l3c1._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h12me5._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hci7t3._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hdsupo._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p84xee._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qeuy9c._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s89hoe._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0twi4fj._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ynf7tx._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11rtg6s._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__17ai7sy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1dky4g0._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1nq7ivq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tkjqka._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__212nf49._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_05whahf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1kje4fm._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_087bt9w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_1fvisnp._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_209u41o._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +2 -2
- 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 +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_0xkhw_v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0g5r64e.js → node_modules_html-to-image_es_index_06xsmte.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0-uvagv.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_03c7gi5.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_09p-8om.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0v-kfiu.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0xrgzyz.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1806lsc.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1j6dd-e.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_1sa65r-.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.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 +5 -5
- 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 +10 -10
- package/.next/standalone/.next/static/chunks/{13f1kmjea-0md.js → 02jcotghcxrbu.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1374x9bv_s_vg.js → 0h-6ymo9v86b0.js} +2 -2
- package/.next/standalone/.next/static/chunks/0pndjhcxfu-0t.js +1 -0
- package/.next/standalone/.next/static/chunks/1-33f82_l32or.js +1 -0
- package/.next/standalone/.next/static/chunks/{003uz088wbmz7.js → 1bg_g3g359c_d.js} +1 -1
- package/.next/standalone/.next/static/chunks/{454dyizqik6t9.js → 1gg-8e9q_m__9.js} +2 -2
- package/.next/standalone/.next/static/chunks/{2ufuwhv03yvtb.js → 29zkom4s9de2d.js} +1 -1
- package/.next/standalone/.next/static/chunks/{17sdztgbcc0fi.js → 2llkdafnaejw_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0iinhn4417nk1.js → 3-ogr79xqbw0h.js} +1 -1
- package/.next/standalone/.next/static/chunks/36j05kb2x323i.css +2 -0
- package/.next/standalone/.next/static/chunks/{43weu8f59tlix.js → 388hgfqye67xa.js} +1 -1
- package/.next/standalone/.next/static/chunks/396764jgd7-9j.js +1 -0
- package/.next/standalone/.next/static/chunks/3w7g0n0iwwnbs.js +1 -0
- package/.next/standalone/.next/static/chunks/{turbopack-0vavqo2d-tlt3.js → turbopack-10hj8t5pf33tu.js} +1 -1
- package/.next/standalone/.opencode/plugins/failproofai.mjs +30 -11
- package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +11 -2
- package/.next/standalone/app/components/copy-button.tsx +22 -6
- package/.next/standalone/app/components/global-error-listeners.tsx +27 -0
- package/.next/standalone/components/reach-developers.tsx +1 -1
- package/.next/standalone/docker-hook-sync/Dockerfile +8 -4
- package/.next/standalone/integration-suite/Dockerfile +47 -0
- package/.next/standalone/integration-suite/README.md +87 -0
- package/.next/standalone/integration-suite/canary-policies.mjs +45 -0
- package/.next/standalone/integration-suite/capture-tokens.sh +48 -0
- package/.next/standalone/integration-suite/ci-entrypoint.sh +164 -0
- package/.next/standalone/integration-suite/inject-tokens.sh +44 -0
- package/.next/standalone/integration-suite/install-clis.sh +91 -0
- package/.next/standalone/integration-suite/probe-cli.sh +208 -0
- package/.next/standalone/integration-suite/report.js +74 -0
- package/.next/standalone/integration-suite/run.sh +107 -0
- package/.next/standalone/lib/error-origin.ts +67 -0
- package/.next/standalone/lib/format-duration.ts +23 -9
- package/.next/standalone/lib/install-check.ts +231 -0
- package/.next/standalone/package.json +1 -3
- package/.next/standalone/server.js +1 -1
- package/README.md +1 -17
- package/bin/failproofai.mjs +11 -1
- package/dist/cli.mjs +818 -320
- package/lib/error-origin.ts +67 -0
- package/lib/format-duration.ts +23 -9
- package/lib/install-check.ts +231 -0
- package/package.json +1 -3
- package/scripts/dev-hook.mjs +292 -0
- package/scripts/install-diagnosis.mjs +5 -8
- package/scripts/install-telemetry.mjs +12 -4
- package/scripts/translate-docs/cli.ts +53 -1
- package/scripts/translate-docs/mdx-translator.ts +158 -18
- package/scripts/translate-docs/readme-translator.ts +36 -18
- package/scripts/translate-docs/translator.ts +180 -9
- package/scripts/translate-docs/types.ts +6 -0
- package/scripts/translate-docs/validate-translation.ts +132 -0
- package/scripts/validate-mdx.ts +92 -15
- package/src/audit/cli.ts +53 -15
- package/src/auth/cli.ts +14 -5
- package/src/hooks/builtin-policies.ts +168 -50
- package/src/hooks/configure-wizard.ts +238 -22
- package/src/hooks/custom-hooks-loader.ts +70 -4
- package/src/hooks/handler.ts +4 -1
- package/src/hooks/policy-types.ts +8 -0
- package/src/hooks/tui.ts +96 -29
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ge8e7w._.js +0 -3
- package/.next/standalone/.next/static/chunks/0405htbxay9vd.js +0 -1
- package/.next/standalone/.next/static/chunks/2m_tbl39fcedn.js +0 -1
- package/.next/standalone/.next/static/chunks/2o3ql0n5owqsw.js +0 -1
- package/.next/standalone/.next/static/chunks/35-90m9py64d4.js +0 -1
- package/.next/standalone/.next/static/chunks/3r--yyjkp9_7b.css +0 -2
- package/scripts/postinstall.mjs +0 -242
- package/scripts/preuninstall.mjs +0 -131
- /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → YKxI22hC-F_nHp7MEKfb1}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → YKxI22hC-F_nHp7MEKfb1}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{MNJWFb2jLRrxRKGFNK6Hy → YKxI22hC-F_nHp7MEKfb1}/_ssgManifest.js +0 -0
|
@@ -172,7 +172,8 @@ const CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh|dash|ksh|csh|tcsh|
|
|
|
172
172
|
const PS_WEB_PIPE_RE = /(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm)\s+.*\|\s*(?:Invoke-Expression|iex)/i;
|
|
173
173
|
|
|
174
174
|
// blockForcePush
|
|
175
|
-
const
|
|
175
|
+
const SHORT_FLAG_BUNDLE_RE = /^-[a-zA-Z]*f[a-zA-Z]*$/;
|
|
176
|
+
const SAFE_FORCE_PREFIXES = ["--force-with-lease", "--force-if-includes"] as const;
|
|
176
177
|
|
|
177
178
|
// blockSecretsWrite
|
|
178
179
|
const SECRET_FILE_RE = /\.(?:pem|key)$/;
|
|
@@ -587,36 +588,154 @@ function blockPushMaster(ctx: PolicyContext): PolicyResult {
|
|
|
587
588
|
return allow();
|
|
588
589
|
}
|
|
589
590
|
|
|
591
|
+
// -- block-rm-rf: deletion-target resolution --
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Leading shell forms that resolve to a home directory: `~`, `~user`, `$HOME`,
|
|
595
|
+
* `${HOME}`. The lookahead keeps `$HOMEBREW_PREFIX` (a variable this policy
|
|
596
|
+
* cannot resolve) from being mistaken for `$HOME`.
|
|
597
|
+
*/
|
|
598
|
+
const HOME_PREFIX_RE = /^(?:~[A-Za-z0-9_.-]*|\$HOME|\$\{HOME\})(?=$|\/)/;
|
|
599
|
+
|
|
600
|
+
/** `rm`, `/bin/rm`, `/usr/bin/rm`, … — the command word of a delete. */
|
|
601
|
+
const RM_CMD_RE = /^(?:\/\S*\/)?rm$/;
|
|
602
|
+
|
|
603
|
+
/** `find` expression that deletes: `-delete`, `-exec rm`, `-execdir rm`, `-ok rm`. */
|
|
604
|
+
// Same shape as RM_CMD_RE: `find` is just as dangerous when invoked by
|
|
605
|
+
// absolute path (`/usr/bin/find / -delete`), so match both forms.
|
|
606
|
+
const FIND_CMD_RE = /^(?:\/\S*\/)?find$/;
|
|
607
|
+
const FIND_EXEC_RE = /^-(?:exec|execdir|ok|okdir)$/;
|
|
608
|
+
|
|
609
|
+
/** find's global options, which precede its path operands (`find -L / -delete`). */
|
|
610
|
+
const FIND_GLOBAL_OPT_RE = /^-(?:[HLP]|D|O\d*)$/;
|
|
611
|
+
|
|
612
|
+
/** First token of find's expression — everything before it is a path operand. */
|
|
613
|
+
const FIND_EXPR_START_RE = /^(?:-|\\?[(!])/;
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Roots that exist to hold throwaway data. A delete of the root itself is still
|
|
617
|
+
* catastrophic (`rm -rf /tmp` wipes every process's scratch space); a delete of
|
|
618
|
+
* something *inside* one is ordinary work.
|
|
619
|
+
*/
|
|
620
|
+
const SCRATCH_ROOTS = ["/tmp", "/var/tmp"];
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* How many path segments below a root (`/` or a home directory) a delete has to
|
|
624
|
+
* reach before it stops being catastrophic. `/etc` (1) and `/home/chetan` (2)
|
|
625
|
+
* are system- and user-level directories; `/home/chetan/project` (3) is a
|
|
626
|
+
* specific thing the caller meant to delete.
|
|
627
|
+
*/
|
|
628
|
+
const CATASTROPHIC_DEPTH = 2;
|
|
629
|
+
|
|
630
|
+
/** Expand the leading `~` / `$HOME` / `${HOME}` of a path to the real home directory. */
|
|
631
|
+
function expandHomePrefix(path: string): string {
|
|
632
|
+
const m = path.match(/^(?:~|\$HOME|\$\{HOME\})(?=$|\/)/);
|
|
633
|
+
return m ? homedir() + path.slice(m[0].length) : path;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Drop a trailing `/*` glob and any trailing slashes: `/tmp/foo/*` → `/tmp/foo`. */
|
|
637
|
+
function stripTrailingGlob(path: string): string {
|
|
638
|
+
return path.replace(/\/\*$/, "").replace(/\/+$/, "");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Would deleting this target be catastrophic?
|
|
643
|
+
*
|
|
644
|
+
* Rather than pattern-matching the raw command text, this resolves the token as
|
|
645
|
+
* far as the shell would: quotes are stripped, `~` / `$HOME` are recognised as a
|
|
646
|
+
* root in their own right, and the remaining path segments are counted. A target
|
|
647
|
+
* within {@link CATASTROPHIC_DEPTH} segments of either root (`/` or home) takes
|
|
648
|
+
* out the machine or the user's data.
|
|
649
|
+
*
|
|
650
|
+
* Fails safe: a token whose head is an expansion this policy cannot evaluate —
|
|
651
|
+
* command substitution (`$(…)`, backticks) or any variable other than `$HOME` —
|
|
652
|
+
* could expand to `/`, so it counts as catastrophic. Relative targets are not
|
|
653
|
+
* flagged: they resolve under the working directory, not under a root.
|
|
654
|
+
*/
|
|
655
|
+
function isCatastrophicTarget(token: string): boolean {
|
|
656
|
+
const raw = token.replace(/^['"]|['"]$/g, "");
|
|
657
|
+
if (raw === "") return false;
|
|
658
|
+
|
|
659
|
+
const homePrefix = raw.match(HOME_PREFIX_RE);
|
|
660
|
+
// Unresolvable head: `$(echo /)`, `` `pwd` ``, `$TARGET_DIR`, …
|
|
661
|
+
if (!homePrefix && /^[$`]/.test(raw)) return true;
|
|
662
|
+
|
|
663
|
+
const belowRoot = homePrefix ? raw.slice(homePrefix[0].length) : raw.startsWith("/") ? raw : null;
|
|
664
|
+
if (belowRoot === null) return false;
|
|
665
|
+
|
|
666
|
+
const segments = stripTrailingGlob(belowRoot).split("/").filter(Boolean);
|
|
667
|
+
if (!homePrefix && SCRATCH_ROOTS.some((r) => `/${segments.join("/")}`.startsWith(`${r}/`))) return false;
|
|
668
|
+
return segments.length <= CATASTROPHIC_DEPTH;
|
|
669
|
+
}
|
|
670
|
+
|
|
590
671
|
/**
|
|
591
|
-
*
|
|
672
|
+
* The paths a single command segment would recursively delete, or `null` when the
|
|
673
|
+
* segment is not a recursive delete at all.
|
|
674
|
+
*
|
|
675
|
+
* Understands the two shapes that reach every file under a target: `rm` with both
|
|
676
|
+
* `-r` and `-f` (in any spelling or order), and `find`, which recurses by design,
|
|
677
|
+
* paired with `-delete` or an `-exec rm`.
|
|
678
|
+
*/
|
|
679
|
+
function recursiveDeletionTargets(seg: string): string[] | null {
|
|
680
|
+
const tokens = parseArgvTokens(seg);
|
|
681
|
+
|
|
682
|
+
const findIdx = tokens.findIndex((t) => FIND_CMD_RE.test(t));
|
|
683
|
+
if (findIdx >= 0) {
|
|
684
|
+
const expr = tokens.slice(findIdx + 1);
|
|
685
|
+
const execIdx = expr.findIndex((t) => FIND_EXEC_RE.test(t));
|
|
686
|
+
const deletes = expr.includes("-delete") || (execIdx >= 0 && RM_CMD_RE.test(expr[execIdx + 1] ?? ""));
|
|
687
|
+
if (deletes) {
|
|
688
|
+
// find's path operands sit between the leading global options and the
|
|
689
|
+
// first expression token: `find -L /home/chetan -name '*.log' -delete`
|
|
690
|
+
let start = 0;
|
|
691
|
+
while (start < expr.length && FIND_GLOBAL_OPT_RE.test(expr[start])) {
|
|
692
|
+
start += expr[start] === "-D" ? 2 : 1;
|
|
693
|
+
}
|
|
694
|
+
const rest = expr.slice(start);
|
|
695
|
+
const end = rest.findIndex((t) => FIND_EXPR_START_RE.test(t));
|
|
696
|
+
return end < 0 ? rest : rest.slice(0, end);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const rmIdx = tokens.findIndex((t) => RM_CMD_RE.test(t));
|
|
701
|
+
if (rmIdx >= 0) {
|
|
702
|
+
const args = tokens.slice(rmIdx + 1);
|
|
703
|
+
const shortFlags = args.filter((t) => /^-[^-]/.test(t)).join("");
|
|
704
|
+
const longFlags = args.filter((t) => /^--/.test(t));
|
|
705
|
+
const recursive = /r/i.test(shortFlags) || longFlags.some((f) => /^--recursive$/i.test(f));
|
|
706
|
+
const force = /f/.test(shortFlags) || longFlags.some((f) => /^--force$/i.test(f));
|
|
707
|
+
if (recursive && force) return args.filter((t) => !t.startsWith("-"));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Split a command into the segments the shell would run as separate commands. */
|
|
714
|
+
function shellSegments(cmd: string): string[] {
|
|
715
|
+
return cmd.split(/&&|\|\||[|;\n]/).map((s) => s.trim()).filter((s) => s !== "");
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Check whether all recursive-delete targets in a command are under an allowlisted path.
|
|
592
720
|
* Splits on shell operators first so that `/tmp` appearing in an unrelated
|
|
593
721
|
* sub-command (e.g. `echo /tmp && rm -rf /`) does not trigger a false allow.
|
|
594
722
|
* Uses path-boundary comparison so `/tmp` does not cover `/tmp2`.
|
|
595
723
|
* Non-recursive rm segments (no -r/-R flag) are skipped — they pose no catastrophic risk.
|
|
596
724
|
* Quoted paths with spaces are handled via a segment-level regex fallback.
|
|
725
|
+
* Home-relative targets and allowPaths entries are expanded, so `~/scratch` is
|
|
726
|
+
* covered by an allowPaths entry of either `~/scratch` or the absolute home path.
|
|
597
727
|
*/
|
|
598
|
-
function
|
|
728
|
+
function deletionTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
|
|
599
729
|
if (allowPaths.length === 0) return false;
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
// Only validate recursive rm segments — non-recursive rm has no catastrophic-deletion risk
|
|
610
|
-
const flagTokens = tokens.slice(rmIdx + 1).filter((t) => /^-[^-]/.test(t));
|
|
611
|
-
const longFlagsInSeg = tokens.slice(rmIdx + 1).filter((t) => /^--/.test(t));
|
|
612
|
-
if (!/r/i.test(flagTokens.join("")) && !longFlagsInSeg.some(f => /^--recursive$/i.test(f))) continue;
|
|
613
|
-
const pathArgs = tokens.slice(rmIdx + 1).filter((t) => !t.startsWith("-"));
|
|
614
|
-
for (const target of pathArgs) {
|
|
615
|
-
const normalized = target.replace(/\/\*$/, "").replace(/\/+$/, "") || "/";
|
|
616
|
-
const covered = allowPaths.some((p) => {
|
|
617
|
-
const np = p.replace(/\/+$/, "") || "/";
|
|
618
|
-
return normalized === np || normalized.startsWith(np + "/");
|
|
619
|
-
});
|
|
730
|
+
const normalizedAllowPaths = allowPaths.map((p) => stripTrailingGlob(expandHomePrefix(p)) || "/");
|
|
731
|
+
let sawRecursiveDelete = false;
|
|
732
|
+
for (const seg of shellSegments(cmd)) {
|
|
733
|
+
const targets = recursiveDeletionTargets(seg);
|
|
734
|
+
if (targets === null) continue;
|
|
735
|
+
sawRecursiveDelete = true;
|
|
736
|
+
for (const target of targets) {
|
|
737
|
+
const normalized = stripTrailingGlob(expandHomePrefix(target)) || "/";
|
|
738
|
+
const covered = normalizedAllowPaths.some((np) => normalized === np || normalized.startsWith(np + "/"));
|
|
620
739
|
if (!covered) {
|
|
621
740
|
// Fallback: check the raw segment for quoted paths that contain spaces
|
|
622
741
|
// (parseArgvTokens splits on whitespace, so "/tmp/my dir" becomes two tokens)
|
|
@@ -628,40 +747,23 @@ function rmTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
|
|
|
628
747
|
}
|
|
629
748
|
}
|
|
630
749
|
}
|
|
631
|
-
return
|
|
750
|
+
return sawRecursiveDelete;
|
|
632
751
|
}
|
|
633
752
|
|
|
634
753
|
function blockRmRf(ctx: PolicyContext): PolicyResult {
|
|
635
754
|
if (ctx.toolName !== "Bash") return allow();
|
|
636
755
|
const cmd = getCommand(ctx);
|
|
637
|
-
const hasDestructivePath = parseArgvTokens(cmd).some((token) => {
|
|
638
|
-
const normalized = token.replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
|
|
639
|
-
return normalized === "/" || normalized === "~" || /^\/[A-Za-z_][\w.-]*$/.test(normalized);
|
|
640
|
-
});
|
|
641
756
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
)
|
|
757
|
+
const hasCatastrophicTarget = shellSegments(cmd).some((seg) => {
|
|
758
|
+
const targets = recursiveDeletionTargets(seg);
|
|
759
|
+
return targets !== null && targets.some(isCatastrophicTarget);
|
|
760
|
+
});
|
|
761
|
+
if (hasCatastrophicTarget) {
|
|
647
762
|
const allowPaths = ((ctx.params?.allowPaths ?? []) as string[]);
|
|
648
|
-
if (
|
|
763
|
+
if (deletionTargetIsAllowed(cmd, allowPaths)) return allow();
|
|
649
764
|
return deny("Catastrophic deletion blocked");
|
|
650
765
|
}
|
|
651
766
|
|
|
652
|
-
// Separated flags: rm -r -f /, rm -f -r /, rm -r -v -f /, etc.
|
|
653
|
-
if (hasDestructivePath && /\brm\b/.test(cmd)) {
|
|
654
|
-
const tokens = parseArgvTokens(cmd);
|
|
655
|
-
const shortFlags = tokens.filter((t) => /^-[^-]/.test(t)).join("");
|
|
656
|
-
const longFlags = tokens.filter((t) => /^--/.test(t));
|
|
657
|
-
const hasRecursive = /r/i.test(shortFlags) || longFlags.some(f => /^--recursive$/i.test(f));
|
|
658
|
-
const hasForce = /f/.test(shortFlags) || longFlags.some(f => /^--force$/i.test(f));
|
|
659
|
-
if (hasRecursive && hasForce) {
|
|
660
|
-
const allowPaths = ((ctx.params?.allowPaths ?? []) as string[]);
|
|
661
|
-
if (rmTargetIsAllowed(cmd, allowPaths)) return allow();
|
|
662
|
-
return deny("Catastrophic deletion blocked");
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
767
|
// PowerShell: Remove-Item -Recurse -Force on root/drive
|
|
666
768
|
if (/Remove-Item\s+.*-Recurse.*-Force.*(?:[A-Z]:\\(?:\s|$)|\\\*)/i.test(cmd)) {
|
|
667
769
|
return deny("Catastrophic deletion blocked");
|
|
@@ -675,13 +777,29 @@ function blockRmRf(ctx: PolicyContext): PolicyResult {
|
|
|
675
777
|
|
|
676
778
|
function blockForcePush(ctx: PolicyContext): PolicyResult {
|
|
677
779
|
if (ctx.toolName !== "Bash") return allow();
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
780
|
+
for (const segment of extractGitPushArgs(getCommand(ctx))) {
|
|
781
|
+
let sawEndOfOptions = false;
|
|
782
|
+
for (const token of segment.split(/\s+/)) {
|
|
783
|
+
if (token === "--") {
|
|
784
|
+
sawEndOfOptions = true;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (sawEndOfOptions) continue;
|
|
788
|
+
if (isForcePushFlag(token)) {
|
|
789
|
+
return deny("Force-pushing is blocked");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
681
792
|
}
|
|
682
793
|
return allow();
|
|
683
794
|
}
|
|
684
795
|
|
|
796
|
+
function isForcePushFlag(token: string): boolean {
|
|
797
|
+
if (token === "--force") return true;
|
|
798
|
+
if (SAFE_FORCE_PREFIXES.some((prefix) => token.startsWith(prefix))) return false;
|
|
799
|
+
if (token.startsWith("--force")) return true;
|
|
800
|
+
return SHORT_FLAG_BUNDLE_RE.test(token);
|
|
801
|
+
}
|
|
802
|
+
|
|
685
803
|
function blockSecretsWrite(ctx: PolicyContext): PolicyResult {
|
|
686
804
|
if (ctx.toolName !== "Write") return allow();
|
|
687
805
|
const filePath = getFilePath(ctx);
|
|
@@ -12,19 +12,29 @@
|
|
|
12
12
|
* whatever is already enabled, so unticking removes). Reuses the tested
|
|
13
13
|
* install/uninstall manager and the existing searchable policy picker.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { resolve, sep } from "node:path";
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
selectOne,
|
|
21
|
+
multiSelect,
|
|
22
|
+
intro,
|
|
23
|
+
outro,
|
|
24
|
+
summarize,
|
|
25
|
+
type MultiChoice,
|
|
26
|
+
type TTYIn,
|
|
27
|
+
type TTYOut,
|
|
28
|
+
} from "./tui";
|
|
20
29
|
import {
|
|
21
30
|
detectInstalledClis,
|
|
22
31
|
getIntegration,
|
|
23
32
|
} from "./integrations";
|
|
24
33
|
import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types";
|
|
25
34
|
import { installHooks } from "./manager";
|
|
26
|
-
import { getConfigPathForScope } from "./hooks-config";
|
|
35
|
+
import { getConfigPathForScope, readHooksConfig } from "./hooks-config";
|
|
27
36
|
import { POLICY_PRESETS, resolvePreset, resolveEverything } from "./policy-presets";
|
|
37
|
+
import { discoverPolicyFiles, findSkippedPolicyFiles } from "./custom-hooks-loader";
|
|
28
38
|
import { trackHookEvent } from "./hook-telemetry";
|
|
29
39
|
import { getInstanceId } from "../../lib/telemetry-id";
|
|
30
40
|
|
|
@@ -75,7 +85,13 @@ export function buildScopeChoices(cwd: string) {
|
|
|
75
85
|
];
|
|
76
86
|
}
|
|
77
87
|
|
|
78
|
-
|
|
88
|
+
/** The CLIs that can actually be configured at `scope`. Hermes and OpenClaw
|
|
89
|
+
* are gateways with no project-level config, so they are user-scope only. */
|
|
90
|
+
export function clisSupportingScope(scope: HookScope): IntegrationType[] {
|
|
91
|
+
return INTEGRATION_TYPES.filter((id) => getIntegration(id).scopes.includes(scope));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function buildAgentChoices(scope: HookScope, cwd: string): MultiChoice<IntegrationType>[] {
|
|
79
95
|
const detected = new Set(detectInstalledClis());
|
|
80
96
|
// Detected first, then the rest as "install ahead of time".
|
|
81
97
|
const ordered = [
|
|
@@ -85,6 +101,25 @@ export function buildAgentChoices(scope: HookScope, cwd: string) {
|
|
|
85
101
|
return ordered.map((id) => {
|
|
86
102
|
const integration = getIntegration(id);
|
|
87
103
|
const isDetected = detected.has(id);
|
|
104
|
+
|
|
105
|
+
// Not every CLI can be configured at every scope — Hermes and OpenClaw
|
|
106
|
+
// have no project config at all. Offering them anyway meant picking "Just
|
|
107
|
+
// this project" and applying died with `Scope "project" is not supported
|
|
108
|
+
// by Hermes`, after the user had answered every question. Show them as
|
|
109
|
+
// locked and unchecked with the reason, so the constraint is visible
|
|
110
|
+
// instead of being discovered as a crash.
|
|
111
|
+
const supported = integration.scopes.includes(scope);
|
|
112
|
+
if (!supported) {
|
|
113
|
+
return {
|
|
114
|
+
label: integration.displayName,
|
|
115
|
+
value: id,
|
|
116
|
+
checked: false,
|
|
117
|
+
locked: true,
|
|
118
|
+
section: "Global only · not configurable per-project",
|
|
119
|
+
hint: `supports ${integration.scopes.join(", ")} scope — rerun with "Everywhere I code"`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
88
123
|
let installedHere = false;
|
|
89
124
|
try {
|
|
90
125
|
installedHere = integration.hooksInstalledInSettings(scope, cwd);
|
|
@@ -103,18 +138,62 @@ export function buildAgentChoices(scope: HookScope, cwd: string) {
|
|
|
103
138
|
|
|
104
139
|
const EVERYTHING = "__everything__";
|
|
105
140
|
const ALL_CLIS = "__all_clis__";
|
|
141
|
+
/** Sentinel for the locked "Custom" row — informational, never resolves to
|
|
142
|
+
* builtin policy names (custom policies load by convention, not by config). */
|
|
143
|
+
const CUSTOM = "__custom__";
|
|
106
144
|
|
|
107
145
|
/** The themed preset bundles for the wizard's multi-select, plus an "Everything"
|
|
108
146
|
* option that enables the full builtin policy set. */
|
|
109
|
-
export function buildPresetChoices() {
|
|
110
|
-
const choices:
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
export function buildPresetChoices(cwd: string = process.cwd(), enabled = true) {
|
|
148
|
+
const choices: MultiChoice<string>[] = POLICY_PRESETS.map((p) => ({
|
|
149
|
+
label: p.label,
|
|
150
|
+
value: p.id,
|
|
151
|
+
hint: p.description,
|
|
152
|
+
}));
|
|
113
153
|
choices.push({
|
|
114
154
|
label: "Everything",
|
|
115
155
|
value: EVERYTHING,
|
|
116
156
|
hint: `all ${resolveEverything().length} policies`,
|
|
117
157
|
});
|
|
158
|
+
|
|
159
|
+
// The Custom row is ALWAYS present, because it is the only place the feature
|
|
160
|
+
// is discoverable: a user who has never written a policy cannot learn the
|
|
161
|
+
// capability exists, and one who wrote a badly-named file cannot learn why
|
|
162
|
+
// nothing happened.
|
|
163
|
+
//
|
|
164
|
+
// When there are loadable files it is a REAL checkbox — unticking writes
|
|
165
|
+
// `customPoliciesEnabled: false`, which switches convention discovery off
|
|
166
|
+
// without renaming or deleting anything. With nothing to toggle (no files,
|
|
167
|
+
// or only skipped ones) it falls back to a locked status row.
|
|
168
|
+
const custom = describeCustomPolicies(cwd);
|
|
169
|
+
const skipped = custom.warnings.length;
|
|
170
|
+
const plural = (n: number) => `${n} file${n === 1 ? "" : "s"}`;
|
|
171
|
+
const skippedNote = skipped > 0 ? ` · ${skipped} skipped, see next screen` : "";
|
|
172
|
+
|
|
173
|
+
if (custom.fileCount > 0) {
|
|
174
|
+
choices.push({
|
|
175
|
+
label: "Custom",
|
|
176
|
+
value: CUSTOM,
|
|
177
|
+
checked: enabled,
|
|
178
|
+
// Deliberately NOT summaryExclude'd: this one is a real choice, and the
|
|
179
|
+
// step summary is the only place the user sees what they picked. Hiding
|
|
180
|
+
// it meant unticking Custom and every bundle showed "none", giving no
|
|
181
|
+
// way to tell the toggle had registered.
|
|
182
|
+
hint: `${plural(custom.fileCount)} in ${custom.scopes.join(" + ")}${skippedNote}`,
|
|
183
|
+
});
|
|
184
|
+
} else {
|
|
185
|
+
choices.push({
|
|
186
|
+
label: "Custom",
|
|
187
|
+
value: CUSTOM,
|
|
188
|
+
locked: true,
|
|
189
|
+
checked: false,
|
|
190
|
+
summaryExclude: true,
|
|
191
|
+
hint:
|
|
192
|
+
skipped > 0
|
|
193
|
+
? `${plural(skipped)} found but NOT loaded — see next screen`
|
|
194
|
+
: "none yet · drop *-policies.mjs in .failproofai/policies/",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
118
197
|
return choices;
|
|
119
198
|
}
|
|
120
199
|
|
|
@@ -124,14 +203,100 @@ export function buildPresetChoices() {
|
|
|
124
203
|
* enables the full policy set and wins over any presets.
|
|
125
204
|
*/
|
|
126
205
|
export function resolvePresetSelection(values: string[]): string[] {
|
|
127
|
-
|
|
128
|
-
|
|
206
|
+
// The Custom row is informational — custom policies are discovered from disk
|
|
207
|
+
// by the loader, never named in the enabled-policies config — so it must not
|
|
208
|
+
// reach resolvePreset(), which only knows builtin bundle ids.
|
|
209
|
+
const selected = values.filter((v) => v !== CUSTOM);
|
|
210
|
+
if (selected.includes(EVERYTHING)) return resolveEverything();
|
|
211
|
+
return [...new Set(selected.flatMap((id) => resolvePreset(id)))];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const DIM_NOTE = "(auto-loaded)";
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Persist the Custom checkbox into the scope's config, after installHooks has
|
|
218
|
+
* written it (installHooks copies the previous config forward, so writing
|
|
219
|
+
* first would be overwritten).
|
|
220
|
+
*
|
|
221
|
+
* Writes the key only to turn discovery OFF, and removes it when turning back
|
|
222
|
+
* on, so the common case leaves no `customPoliciesEnabled: true` noise in the
|
|
223
|
+
* file and "absent means enabled" stays the single default. `undefined` means
|
|
224
|
+
* there was nothing to toggle — leave whatever is there alone.
|
|
225
|
+
*/
|
|
226
|
+
export function setCustomPoliciesEnabled(
|
|
227
|
+
scope: HookScope,
|
|
228
|
+
cwd: string,
|
|
229
|
+
enabled: boolean | undefined,
|
|
230
|
+
): void {
|
|
231
|
+
if (enabled === undefined) return;
|
|
232
|
+
const path = getConfigPathForScope(scope, cwd);
|
|
233
|
+
let config: Record<string, unknown> = {};
|
|
234
|
+
try {
|
|
235
|
+
if (existsSync(path)) config = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
236
|
+
} catch {
|
|
237
|
+
return; // a malformed config is the install path's problem, not ours
|
|
238
|
+
}
|
|
239
|
+
if (enabled) delete config.customPoliciesEnabled;
|
|
240
|
+
else config.customPoliciesEnabled = false;
|
|
241
|
+
try {
|
|
242
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
243
|
+
} catch {
|
|
244
|
+
/* best-effort: never fail a completed setup over this flag */
|
|
245
|
+
}
|
|
129
246
|
}
|
|
130
247
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Summarise the custom policy files on disk, for the review screen.
|
|
250
|
+
*
|
|
251
|
+
* Only lists files — it deliberately does NOT load them. Loading executes
|
|
252
|
+
* user code, which is fine on the hook path but wrong in an interactive
|
|
253
|
+
* wizard the user hasn't confirmed yet.
|
|
254
|
+
*
|
|
255
|
+
* `warnings` covers the silent-skip trap: a file in the right directory whose
|
|
256
|
+
* name doesn't end in `policies.{js,mjs,ts}` is ignored entirely, with nothing
|
|
257
|
+
* on screen to say so. Surfacing it here is the difference between "my rule
|
|
258
|
+
* isn't working and I don't know why" and a one-line rename.
|
|
259
|
+
*/
|
|
260
|
+
export function describeCustomPolicies(cwd: string): {
|
|
261
|
+
active: string[];
|
|
262
|
+
warnings: string[];
|
|
263
|
+
fileCount: number;
|
|
264
|
+
scopes: string[];
|
|
265
|
+
} {
|
|
266
|
+
const active: string[] = [];
|
|
267
|
+
const warnings: string[] = [];
|
|
268
|
+
const scopes: string[] = [];
|
|
269
|
+
let fileCount = 0;
|
|
270
|
+
const dirs: Array<{ dir: string; label: string }> = [
|
|
271
|
+
{ dir: resolve(cwd, ".failproofai", "policies"), label: "project" },
|
|
272
|
+
{ dir: resolve(homedir(), ".failproofai", "policies"), label: "global" },
|
|
273
|
+
];
|
|
274
|
+
for (const { dir, label } of dirs) {
|
|
275
|
+
const found = discoverPolicyFiles(dir);
|
|
276
|
+
if (found.length > 0) {
|
|
277
|
+
active.push(`${found.length} file${found.length === 1 ? "" : "s"} (${label})`);
|
|
278
|
+
scopes.push(label);
|
|
279
|
+
fileCount += found.length;
|
|
280
|
+
}
|
|
281
|
+
for (const name of findSkippedPolicyFiles(dir)) {
|
|
282
|
+
warnings.push(
|
|
283
|
+
`! ${homeify(resolve(dir, name))} is NOT loaded — rename to ` +
|
|
284
|
+
`${name.replace(/\.(js|mjs|ts)$/, "-policies.$1")}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return { active, warnings, fileCount, scopes };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function reviewLines(state: {
|
|
292
|
+
scope: HookScope;
|
|
293
|
+
clis: IntegrationType[];
|
|
294
|
+
policies: string[];
|
|
295
|
+
cwd: string;
|
|
296
|
+
/** The Custom checkbox. `undefined` = nothing to toggle, leave as-is. */
|
|
297
|
+
customEnabled?: boolean;
|
|
298
|
+
}): string[] {
|
|
299
|
+
const { scope, clis, policies, cwd, customEnabled } = state;
|
|
135
300
|
const where =
|
|
136
301
|
scope === "project" ? `This project (${homeify(cwd)})` : "Everywhere (global)";
|
|
137
302
|
const lines: string[] = [];
|
|
@@ -139,6 +304,20 @@ export function reviewLines(
|
|
|
139
304
|
lines.push(` Where : ${where}`);
|
|
140
305
|
lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
|
|
141
306
|
lines.push(` Policies : ${policies.length} enabled`);
|
|
307
|
+
|
|
308
|
+
// Reflect the Custom decision, not just what is on disk. Reporting
|
|
309
|
+
// "1 file (project) (auto-loaded)" after the user had just unticked the row
|
|
310
|
+
// stated the opposite of what was about to happen.
|
|
311
|
+
const custom = describeCustomPolicies(cwd);
|
|
312
|
+
if (custom.active.length > 0) {
|
|
313
|
+
lines.push(
|
|
314
|
+
` Custom : ${custom.active.join(" · ")} ${
|
|
315
|
+
customEnabled === false ? "— DISABLED, will not load" : DIM_NOTE
|
|
316
|
+
}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
for (const warning of custom.warnings) lines.push(` ${warning}`);
|
|
320
|
+
|
|
142
321
|
lines.push("");
|
|
143
322
|
lines.push(" This will update:");
|
|
144
323
|
for (const cli of clis) {
|
|
@@ -279,7 +458,12 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
279
458
|
{
|
|
280
459
|
label: "Everything available",
|
|
281
460
|
value: ALL_CLIS,
|
|
282
|
-
|
|
461
|
+
// Counts only what this scope can actually take — expanding to all 12
|
|
462
|
+
// under project scope is what crashed the apply on Hermes.
|
|
463
|
+
hint: `protect all ${clisSupportingScope(scope).length} CLIs configurable here`,
|
|
464
|
+
// A selector, not an assistant. Counting it gave "13 assistants" for
|
|
465
|
+
// the 12 supported CLIs, and listed "Everything available" among them.
|
|
466
|
+
summaryExclude: true,
|
|
283
467
|
},
|
|
284
468
|
...buildAgentChoices(scope, cwd),
|
|
285
469
|
],
|
|
@@ -290,15 +474,28 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
290
474
|
stdout,
|
|
291
475
|
});
|
|
292
476
|
if (clisSel === null) return cancel();
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
477
|
+
// Filter to what this scope supports in BOTH branches: "Everything
|
|
478
|
+
// available" must not expand to CLIs that cannot take this scope, and a
|
|
479
|
+
// locked row can't be ticked but belt-and-braces keeps the invariant local
|
|
480
|
+
// to the one place `clis` is built.
|
|
481
|
+
const supported = new Set(clisSupportingScope(scope));
|
|
482
|
+
const clis: IntegrationType[] = (
|
|
483
|
+
clisSel.includes(ALL_CLIS)
|
|
484
|
+
? [...INTEGRATION_TYPES]
|
|
485
|
+
: (clisSel.filter((v) => v !== ALL_CLIS) as IntegrationType[])
|
|
486
|
+
).filter((id) => supported.has(id));
|
|
296
487
|
|
|
297
488
|
// 3 — Which policies? Multi-select of themed presets — additive, so the
|
|
298
489
|
// enabled set is the union of every ticked bundle.
|
|
490
|
+
// Seed the Custom checkbox from whatever the config already says, so the
|
|
491
|
+
// wizard shows the current state rather than resetting it every run.
|
|
492
|
+
const customEnabledBefore = readHooksConfig().customPoliciesEnabled !== false;
|
|
493
|
+
const presetChoices = buildPresetChoices(cwd, customEnabledBefore);
|
|
494
|
+
const hasCustomFiles = describeCustomPolicies(cwd).fileCount > 0;
|
|
495
|
+
|
|
299
496
|
const presets = await multiSelect<string>({
|
|
300
497
|
message: "What should we guard against?",
|
|
301
|
-
choices:
|
|
498
|
+
choices: presetChoices,
|
|
302
499
|
minSelected: 1,
|
|
303
500
|
summaryNoun: "bundles",
|
|
304
501
|
hint: "space toggles · combine presets · ↵ confirm",
|
|
@@ -307,11 +504,14 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
307
504
|
});
|
|
308
505
|
if (presets === null) return cancel();
|
|
309
506
|
const policies = resolvePresetSelection(presets);
|
|
507
|
+
// Only meaningful when there are files to switch off; with none, the row is
|
|
508
|
+
// locked-unchecked and must not write a disabling flag.
|
|
509
|
+
const customEnabled = hasCustomFiles ? presets.includes(CUSTOM) : undefined;
|
|
310
510
|
|
|
311
511
|
// 4 — Review & apply
|
|
312
512
|
const decision = await selectOne<"apply" | "cancel">({
|
|
313
513
|
message: "Ready to apply?",
|
|
314
|
-
body: reviewLines({ scope, clis, policies, cwd }),
|
|
514
|
+
body: reviewLines({ scope, clis, policies, cwd, customEnabled }),
|
|
315
515
|
choices: [
|
|
316
516
|
{ label: "Yes, apply now", value: "apply", hint: "write the config" },
|
|
317
517
|
{ label: "Cancel", value: "cancel", hint: "quit, no changes" },
|
|
@@ -345,12 +545,28 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
|
|
|
345
545
|
clis,
|
|
346
546
|
{ replace: true, quiet: true },
|
|
347
547
|
);
|
|
548
|
+
setCustomPoliciesEnabled(scope, cwd, customEnabled);
|
|
348
549
|
await applied;
|
|
349
550
|
// Only now — a completed apply — is the launcher considered "seen", so the
|
|
350
551
|
// first-run bare invocation stops redirecting here and opens the dashboard.
|
|
351
552
|
markLauncherSeen();
|
|
352
553
|
|
|
353
|
-
|
|
354
|
-
|
|
554
|
+
// Keep this inside a standard 80-column terminal. `writeLines` truncates with
|
|
555
|
+
// a hard cut and no ellipsis, so an over-long line doesn't just lose its tail
|
|
556
|
+
// — it reads as broken output. Naming all ten CLIs took it to 182 characters;
|
|
557
|
+
// the count alone carries the same information, and the user picked them two
|
|
558
|
+
// screens ago.
|
|
559
|
+
const customNote =
|
|
560
|
+
customEnabled === true
|
|
561
|
+
? " + your custom policies"
|
|
562
|
+
: customEnabled === false
|
|
563
|
+
? " · custom policies DISABLED"
|
|
564
|
+
: "";
|
|
565
|
+
const assistants = `${clis.length} assistant${clis.length === 1 ? "" : "s"}`;
|
|
566
|
+
outro(
|
|
567
|
+
`Setup complete — ${policies.length} policies${customNote} · ${assistants}`,
|
|
568
|
+
{ ok: true },
|
|
569
|
+
stdout,
|
|
570
|
+
);
|
|
355
571
|
return { applied: true, scope, clis, policies };
|
|
356
572
|
}
|