failproofai 0.0.2-beta.5 → 0.0.2-beta.7
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 +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 +2 -2
- 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 +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 +1 -1
- 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]__02nt~6d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_05pz9._._.js +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__00_.atk._.js → [root-of-the-server]__05zi2mt._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0gw4qdj._.js → [root-of-the-server]__0kkt_9z._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0okos0k._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +8 -9
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0a_7sdg.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0ef3uwk.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0j79~gv.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0pbja1x.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0r6o0i2.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_11y81~_.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_12or2kf.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_0mebn66._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- package/.next/standalone/.next/server/pages/404.html +2 -2
- 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 +9 -9
- package/.next/standalone/.next/static/chunks/{0issdwvmb81z_.js → 02u4v.k5amfah.js} +1 -1
- package/.next/standalone/.next/static/chunks/{031pa5~qfzt~_.js → 09e7drilkf1sn.js} +1 -1
- package/.next/standalone/.next/static/chunks/{14ee68i9dy9b3.js → 0bkizbynk9via.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0gleuaabeolm~.js → 0e76l4~hq_sei.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0odv81fzkn6u~.js → 0ltx5i0xv85_s.js} +1 -1
- package/.next/standalone/.next/static/chunks/{040il49xqyq~j.js → 0q7atesxo-36k.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0ezymnwrt2x6i.js → 0suauczjqzn07.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0d-hv1uc827s6.js → 0w.rtg9.m8dk-.js} +2 -2
- package/.next/standalone/.next/static/chunks/{10uhv8kh~ad6m.js → 13jdpvk~s2da8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-0uc5y~g6h.n7-.js → turbopack-0r26pc8h0y_-e.js} +1 -1
- package/.next/standalone/CHANGELOG.md +74 -0
- package/.next/standalone/CLAUDE.md +14 -0
- package/.next/standalone/README.md +20 -3
- package/.next/standalone/bin/failproofai.mjs +5 -0
- package/.next/standalone/bun.lock +31 -63
- package/.next/standalone/dist/cli.mjs +261 -61
- package/.next/standalone/docs/built-in-policies.mdx +2 -2
- package/.next/standalone/docs/configuration.mdx +46 -0
- package/.next/standalone/docs/custom-policies.mdx +63 -5
- package/.next/standalone/docs/docs.json +3 -3
- package/.next/standalone/examples/convention-policies/security-policies.mjs +40 -0
- package/.next/standalone/examples/convention-policies/workflow-policies.mjs +41 -0
- package/.next/standalone/node_modules/@next/env/package.json +1 -1
- package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/jsonwebtoken/index.js +2 -2
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
- package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +7 -2
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/render.js +20 -19
- package/.next/standalone/node_modules/next/dist/shared/lib/errors/canary-only-config-error.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
- package/.next/standalone/node_modules/next/package.json +15 -15
- package/.next/standalone/node_modules/react/cjs/react.development.js +1 -1
- package/.next/standalone/node_modules/react/cjs/react.production.js +1 -1
- package/.next/standalone/node_modules/react/package.json +1 -1
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.browser.production.js +1 -1
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server-legacy.node.production.js +1 -1
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.browser.production.js +3 -3
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.edge.production.js +3 -3
- package/.next/standalone/node_modules/react-dom/cjs/react-dom-server.node.production.js +3 -3
- package/.next/standalone/node_modules/react-dom/cjs/react-dom.production.js +1 -1
- package/.next/standalone/node_modules/react-dom/package.json +2 -2
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/server.js +1 -1
- package/.next/standalone/src/hooks/builtin-policies.ts +110 -18
- package/.next/standalone/src/hooks/custom-hooks-loader.ts +158 -21
- package/.next/standalone/src/hooks/handler.ts +26 -6
- package/.next/standalone/src/hooks/hooks-config.ts +47 -2
- package/.next/standalone/src/hooks/llm-client.ts +2 -2
- package/.next/standalone/src/hooks/loader-utils.ts +4 -4
- package/.next/standalone/src/hooks/manager.ts +57 -14
- package/.next/standalone/src/hooks/policy-evaluator.ts +16 -2
- package/README.md +20 -3
- package/bin/failproofai.mjs +5 -0
- package/dist/cli.mjs +261 -61
- package/package.json +1 -1
- package/src/hooks/builtin-policies.ts +110 -18
- package/src/hooks/custom-hooks-loader.ts +158 -21
- package/src/hooks/handler.ts +26 -6
- package/src/hooks/hooks-config.ts +47 -2
- package/src/hooks/llm-client.ts +2 -2
- package/src/hooks/loader-utils.ts +4 -4
- package/src/hooks/manager.ts +57 -14
- package/src/hooks/policy-evaluator.ts +16 -2
- /package/.next/standalone/.next/static/{p7b7Yk0VOBDjbtr1aHDyV → Opbai6exOQP2W488FWmr6}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{p7b7Yk0VOBDjbtr1aHDyV → Opbai6exOQP2W488FWmr6}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{p7b7Yk0VOBDjbtr1aHDyV → Opbai6exOQP2W488FWmr6}/_ssgManifest.js +0 -0
|
@@ -168,6 +168,50 @@ function getCurrentBranch(cwd: string): string | null {
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
function getHeadSha(cwd: string): string | null {
|
|
172
|
+
try {
|
|
173
|
+
const sha = execSync("git rev-parse HEAD", {
|
|
174
|
+
cwd,
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
timeout: 3000,
|
|
177
|
+
}).trim();
|
|
178
|
+
return sha || null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface CiCheck {
|
|
185
|
+
name: string;
|
|
186
|
+
status: string;
|
|
187
|
+
conclusion: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Fetch third-party check runs (non-GitHub-Actions) for a commit via the Checks API. */
|
|
191
|
+
function getThirdPartyCheckRuns(cwd: string, sha: string): CiCheck[] {
|
|
192
|
+
try {
|
|
193
|
+
const json = execFileSync(
|
|
194
|
+
"gh",
|
|
195
|
+
[
|
|
196
|
+
"api",
|
|
197
|
+
`repos/{owner}/{repo}/commits/${sha}/check-runs`,
|
|
198
|
+
"--jq",
|
|
199
|
+
'.check_runs | map(select(.app.slug != "github-actions")) | map({name: .name, status: .status, conclusion: (.conclusion // "")})',
|
|
200
|
+
],
|
|
201
|
+
{
|
|
202
|
+
cwd,
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
timeout: 15000,
|
|
205
|
+
},
|
|
206
|
+
).trim();
|
|
207
|
+
|
|
208
|
+
if (!json || json === "[]") return [];
|
|
209
|
+
return JSON.parse(json) as CiCheck[];
|
|
210
|
+
} catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
171
215
|
/**
|
|
172
216
|
* Check if a command matches an allow pattern using token-by-token comparison.
|
|
173
217
|
* The "*" token is a wildcard. Extra command tokens beyond the pattern are allowed,
|
|
@@ -842,6 +886,41 @@ function requirePushBeforeStop(ctx: PolicyContext): PolicyResult {
|
|
|
842
886
|
const branch = getCurrentBranch(cwd);
|
|
843
887
|
if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping push check.");
|
|
844
888
|
|
|
889
|
+
const baseBranch = (ctx.params?.baseBranch as string) ?? "main";
|
|
890
|
+
|
|
891
|
+
// If on the base branch itself, no push of a feature branch is needed
|
|
892
|
+
if (branch === baseBranch) {
|
|
893
|
+
return allow(`On base branch "${baseBranch}", skipping push check.`);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Check if branch has diverged from base in any meaningful way
|
|
897
|
+
try {
|
|
898
|
+
const ahead = execFileSync(
|
|
899
|
+
"git",
|
|
900
|
+
["log", `${remote}/${baseBranch}..HEAD`, "--oneline"],
|
|
901
|
+
{ cwd, encoding: "utf8", timeout: 5000 },
|
|
902
|
+
).trim();
|
|
903
|
+
|
|
904
|
+
if (!ahead) {
|
|
905
|
+
// No commits ahead — branch is fully merged (regular merge / fast-forward)
|
|
906
|
+
return allow(`No commits ahead of ${remote}/${baseBranch}, skipping push check.`);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Commits exist but might be from a squash-merged PR.
|
|
910
|
+
// Check actual file diff — if trees are identical, work is already in base.
|
|
911
|
+
const diff = execFileSync(
|
|
912
|
+
"git",
|
|
913
|
+
["diff", "--stat", `${remote}/${baseBranch}`, "HEAD"],
|
|
914
|
+
{ cwd, encoding: "utf8", timeout: 5000 },
|
|
915
|
+
).trim();
|
|
916
|
+
|
|
917
|
+
if (!diff) {
|
|
918
|
+
return allow(`No file changes compared to ${remote}/${baseBranch}, skipping push check.`);
|
|
919
|
+
}
|
|
920
|
+
} catch {
|
|
921
|
+
// remote/{baseBranch} ref missing — fall through to existing push checks
|
|
922
|
+
}
|
|
923
|
+
|
|
845
924
|
// Check if remote tracking branch exists
|
|
846
925
|
let hasTracking = false;
|
|
847
926
|
try {
|
|
@@ -978,27 +1057,35 @@ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
|
|
|
978
1057
|
const branch = getCurrentBranch(cwd);
|
|
979
1058
|
if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping CI check.");
|
|
980
1059
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
timeout: 15000,
|
|
988
|
-
|
|
989
|
-
).trim();
|
|
1060
|
+
// 1. GitHub Actions workflow runs
|
|
1061
|
+
let workflowRuns: CiCheck[] = [];
|
|
1062
|
+
try {
|
|
1063
|
+
const runsJson = execFileSync(
|
|
1064
|
+
"gh",
|
|
1065
|
+
["run", "list", "--branch", branch, "--limit", "5", "--json", "status,conclusion,name"],
|
|
1066
|
+
{ cwd, encoding: "utf8", timeout: 15000 },
|
|
1067
|
+
).trim();
|
|
990
1068
|
|
|
991
|
-
|
|
1069
|
+
if (runsJson && runsJson !== "[]") {
|
|
1070
|
+
workflowRuns = JSON.parse(runsJson) as CiCheck[];
|
|
1071
|
+
}
|
|
1072
|
+
} catch {
|
|
1073
|
+
// fail-open for workflow runs; continue to check third-party checks
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// 2. Third-party check runs (CodeRabbit, SonarCloud, Codecov, etc.)
|
|
1077
|
+
let thirdPartyChecks: CiCheck[] = [];
|
|
1078
|
+
const sha = getHeadSha(cwd);
|
|
1079
|
+
if (sha) {
|
|
1080
|
+
thirdPartyChecks = getThirdPartyCheckRuns(cwd, sha);
|
|
1081
|
+
}
|
|
992
1082
|
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
conclusion: string;
|
|
996
|
-
name: string;
|
|
997
|
-
}>;
|
|
1083
|
+
// 3. Merge all checks
|
|
1084
|
+
const allChecks = [...workflowRuns, ...thirdPartyChecks];
|
|
998
1085
|
|
|
999
|
-
if (
|
|
1086
|
+
if (allChecks.length === 0) return allow(`No CI runs found for branch "${branch}".`);
|
|
1000
1087
|
|
|
1001
|
-
const failing =
|
|
1088
|
+
const failing = allChecks.filter(
|
|
1002
1089
|
(r) => r.status === "completed" && r.conclusion !== "success" && r.conclusion !== "skipped",
|
|
1003
1090
|
);
|
|
1004
1091
|
if (failing.length > 0) {
|
|
@@ -1008,7 +1095,7 @@ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
|
|
|
1008
1095
|
);
|
|
1009
1096
|
}
|
|
1010
1097
|
|
|
1011
|
-
const pending =
|
|
1098
|
+
const pending = allChecks.filter(
|
|
1012
1099
|
(r) => r.status === "in_progress" || r.status === "queued" || r.status === "waiting",
|
|
1013
1100
|
);
|
|
1014
1101
|
if (pending.length > 0) {
|
|
@@ -1314,6 +1401,11 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
|
|
|
1314
1401
|
description: "Remote name to push to (default: origin)",
|
|
1315
1402
|
default: "origin",
|
|
1316
1403
|
},
|
|
1404
|
+
baseBranch: {
|
|
1405
|
+
type: "string",
|
|
1406
|
+
description: "Base branch to compare against (default: main)",
|
|
1407
|
+
default: "main",
|
|
1408
|
+
},
|
|
1317
1409
|
} satisfies PolicyParamsSchema,
|
|
1318
1410
|
},
|
|
1319
1411
|
{
|
|
@@ -1,39 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Loads
|
|
2
|
+
* Loads user-authored policy files with ESM import rewriting.
|
|
3
3
|
* Supports transitive local imports and `import { ... } from 'failproofai'`.
|
|
4
4
|
*
|
|
5
|
+
* Two loading modes:
|
|
6
|
+
* 1. Explicit: a single file via `customPoliciesPath` in policies-config.json
|
|
7
|
+
* 2. Convention: auto-discovered *policies.{js,mjs,ts} files from
|
|
8
|
+
* .failproofai/policies/ at project and user level (git-hooks style)
|
|
9
|
+
*
|
|
5
10
|
* Fail-open: any error (file not found, syntax error, import failure) is logged
|
|
6
|
-
* and results in an empty hook list. Builtins continue
|
|
11
|
+
* and results in an empty hook list for that file. Builtins continue normally.
|
|
7
12
|
*/
|
|
8
|
-
import { resolve, isAbsolute } from "node:path";
|
|
9
|
-
import { existsSync } from "node:fs";
|
|
13
|
+
import { resolve, isAbsolute, basename } from "node:path";
|
|
14
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
10
15
|
import { pathToFileURL } from "node:url";
|
|
11
|
-
import {
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { hookLogWarn, hookLogError, hookLogInfo } from "./hook-logger";
|
|
12
18
|
import { getCustomHooks, clearCustomHooks } from "./custom-hooks-registry";
|
|
13
19
|
import { findDistIndex, rewriteFileTree, TMP_SUFFIX, cleanupTmpFiles } from "./loader-utils";
|
|
14
20
|
import type { CustomHook } from "./policy-types";
|
|
15
21
|
|
|
16
22
|
const LOADING_KEY = "__FAILPROOFAI_LOADING_HOOKS__";
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
opts?: { strict?: boolean },
|
|
21
|
-
): Promise<CustomHook[]> {
|
|
22
|
-
if (!customPoliciesPath) return [];
|
|
24
|
+
/** Regex matching convention policy filenames: *policies.{js,mjs,ts} */
|
|
25
|
+
const CONVENTION_FILE_RE = /policies\.(js|mjs|ts)$/;
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Scan a directory for convention policy files (*policies.{js,mjs,ts}).
|
|
29
|
+
* Returns sorted absolute paths. Returns [] if the directory doesn't exist.
|
|
30
|
+
*/
|
|
31
|
+
export function discoverPolicyFiles(dir: string): string[] {
|
|
32
|
+
if (!existsSync(dir)) return [];
|
|
33
|
+
try {
|
|
34
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
35
|
+
return entries
|
|
36
|
+
.filter((e) => e.isFile() && CONVENTION_FILE_RE.test(e.name))
|
|
37
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
38
|
+
.map((e) => resolve(dir, e.name));
|
|
39
|
+
} catch {
|
|
31
40
|
return [];
|
|
32
41
|
}
|
|
42
|
+
}
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Load a single policy file into the globalThis custom hooks registry.
|
|
46
|
+
* Does NOT clear the registry — caller is responsible for that.
|
|
47
|
+
*/
|
|
48
|
+
async function loadSingleFile(absPath: string, opts?: { strict?: boolean }): Promise<void> {
|
|
37
49
|
const g = globalThis as Record<string, unknown>;
|
|
38
50
|
g[LOADING_KEY] = true;
|
|
39
51
|
|
|
@@ -51,11 +63,136 @@ export async function loadCustomHooks(
|
|
|
51
63
|
const msg = err instanceof Error ? err.message : String(err);
|
|
52
64
|
if (opts?.strict) throw new Error(`Failed to load custom hooks from ${absPath}: ${msg}`);
|
|
53
65
|
hookLogError(`failed to load custom hooks from ${absPath}: ${msg}`);
|
|
54
|
-
return [];
|
|
55
66
|
} finally {
|
|
56
67
|
g[LOADING_KEY] = false;
|
|
57
68
|
await cleanupTmpFiles(tmpFiles);
|
|
58
69
|
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load a single explicit custom hooks file (legacy API).
|
|
74
|
+
* Clears the registry, loads the file, returns registered hooks.
|
|
75
|
+
*/
|
|
76
|
+
export async function loadCustomHooks(
|
|
77
|
+
customPoliciesPath: string | undefined,
|
|
78
|
+
opts?: { strict?: boolean; sessionCwd?: string },
|
|
79
|
+
): Promise<CustomHook[]> {
|
|
80
|
+
if (!customPoliciesPath) return [];
|
|
81
|
+
|
|
82
|
+
const absPath = isAbsolute(customPoliciesPath)
|
|
83
|
+
? customPoliciesPath
|
|
84
|
+
: resolve(opts?.sessionCwd ?? process.cwd(), customPoliciesPath);
|
|
59
85
|
|
|
86
|
+
if (!existsSync(absPath)) {
|
|
87
|
+
if (opts?.strict) throw new Error(`Custom hooks file not found: ${absPath}`);
|
|
88
|
+
hookLogWarn(`customPoliciesPath not found: ${absPath}`);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
clearCustomHooks();
|
|
93
|
+
await loadSingleFile(absPath, opts);
|
|
60
94
|
return getCustomHooks();
|
|
61
95
|
}
|
|
96
|
+
|
|
97
|
+
/** Source metadata for a loaded convention policy file. */
|
|
98
|
+
export interface ConventionSource {
|
|
99
|
+
scope: "project" | "user";
|
|
100
|
+
file: string;
|
|
101
|
+
hookNames: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Result of loadAllCustomHooks with source metadata. */
|
|
105
|
+
export interface LoadAllResult {
|
|
106
|
+
hooks: CustomHook[];
|
|
107
|
+
conventionSources: ConventionSource[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Load ALL custom hooks: explicit customPoliciesPath + convention-discovered files.
|
|
112
|
+
*
|
|
113
|
+
* Load order:
|
|
114
|
+
* 1. Explicit customPoliciesPath (if configured)
|
|
115
|
+
* 2. Project convention: {cwd}/.failproofai/policies/*policies.{js,mjs,ts} (alphabetical)
|
|
116
|
+
* 3. User convention: ~/.failproofai/policies/*policies.{js,mjs,ts} (alphabetical)
|
|
117
|
+
*
|
|
118
|
+
* Each file is loaded independently (fail-open per file).
|
|
119
|
+
* Convention hooks are tagged with __conventionSource so the handler can distinguish them.
|
|
120
|
+
*/
|
|
121
|
+
export async function loadAllCustomHooks(
|
|
122
|
+
customPoliciesPath: string | undefined,
|
|
123
|
+
opts?: { sessionCwd?: string },
|
|
124
|
+
): Promise<LoadAllResult> {
|
|
125
|
+
clearCustomHooks();
|
|
126
|
+
|
|
127
|
+
const conventionSources: ConventionSource[] = [];
|
|
128
|
+
|
|
129
|
+
// 1. Explicit customPoliciesPath (existing behavior)
|
|
130
|
+
if (customPoliciesPath) {
|
|
131
|
+
const absPath = isAbsolute(customPoliciesPath)
|
|
132
|
+
? customPoliciesPath
|
|
133
|
+
: resolve(opts?.sessionCwd ?? process.cwd(), customPoliciesPath);
|
|
134
|
+
if (existsSync(absPath)) {
|
|
135
|
+
await loadSingleFile(absPath);
|
|
136
|
+
} else {
|
|
137
|
+
hookLogWarn(`customPoliciesPath not found: ${absPath}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const hooksBeforeConvention = getCustomHooks().length;
|
|
142
|
+
|
|
143
|
+
// 2. Project convention: {cwd}/.failproofai/policies/*policies.{js,mjs,ts}
|
|
144
|
+
const projectDir = resolve(opts?.sessionCwd ?? process.cwd(), ".failproofai", "policies");
|
|
145
|
+
const projectFiles = discoverPolicyFiles(projectDir);
|
|
146
|
+
for (const file of projectFiles) {
|
|
147
|
+
const hooksBefore = getCustomHooks().length;
|
|
148
|
+
await loadSingleFile(file);
|
|
149
|
+
const newHooks = getCustomHooks().slice(hooksBefore);
|
|
150
|
+
if (newHooks.length > 0) {
|
|
151
|
+
conventionSources.push({
|
|
152
|
+
scope: "project",
|
|
153
|
+
file: basename(file),
|
|
154
|
+
hookNames: newHooks.map((h) => h.name),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. User convention: ~/.failproofai/policies/*policies.{js,mjs,ts}
|
|
160
|
+
const userDir = resolve(homedir(), ".failproofai", "policies");
|
|
161
|
+
const userFiles = discoverPolicyFiles(userDir);
|
|
162
|
+
for (const file of userFiles) {
|
|
163
|
+
const hooksBefore = getCustomHooks().length;
|
|
164
|
+
await loadSingleFile(file);
|
|
165
|
+
const newHooks = getCustomHooks().slice(hooksBefore);
|
|
166
|
+
if (newHooks.length > 0) {
|
|
167
|
+
conventionSources.push({
|
|
168
|
+
scope: "user",
|
|
169
|
+
file: basename(file),
|
|
170
|
+
hookNames: newHooks.map((h) => h.name),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const allHooks = getCustomHooks();
|
|
176
|
+
const conventionCount = allHooks.length - hooksBeforeConvention;
|
|
177
|
+
|
|
178
|
+
if (projectFiles.length > 0 || userFiles.length > 0) {
|
|
179
|
+
hookLogInfo(
|
|
180
|
+
`convention policies: ${projectFiles.length} project file(s), ${userFiles.length} user file(s), ${conventionCount} hook(s)`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Tag convention hooks so the handler can register them with a "convention/" prefix.
|
|
185
|
+
// Track by object reference (not name) to avoid mis-tagging an explicit custom hook
|
|
186
|
+
// that happens to share the same name as a convention hook.
|
|
187
|
+
const conventionHookRefs = new Set<CustomHook>();
|
|
188
|
+
for (const hook of allHooks.slice(hooksBeforeConvention)) {
|
|
189
|
+
conventionHookRefs.add(hook);
|
|
190
|
+
}
|
|
191
|
+
for (const hook of allHooks) {
|
|
192
|
+
if (conventionHookRefs.has(hook)) {
|
|
193
|
+
(hook as CustomHook & { __conventionSource?: boolean }).__conventionSource = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { hooks: allHooks, conventionSources };
|
|
198
|
+
}
|
package/src/hooks/handler.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { readMergedHooksConfig } from "./hooks-config";
|
|
|
11
11
|
import { registerBuiltinPolicies } from "./builtin-policies";
|
|
12
12
|
import { evaluatePolicies } from "./policy-evaluator";
|
|
13
13
|
import { clearPolicies, registerPolicy } from "./policy-registry";
|
|
14
|
-
import {
|
|
14
|
+
import { loadAllCustomHooks } from "./custom-hooks-loader";
|
|
15
|
+
import type { CustomHook } from "./policy-types";
|
|
15
16
|
import { persistHookActivity } from "./hook-activity-store";
|
|
16
17
|
import { trackHookEvent } from "./hook-telemetry";
|
|
17
18
|
import { getInstanceId } from "../../lib/telemetry-id";
|
|
@@ -71,9 +72,14 @@ export async function handleHookEvent(eventType: string): Promise<number> {
|
|
|
71
72
|
registerBuiltinPolicies(config.enabledPolicies);
|
|
72
73
|
|
|
73
74
|
// Load and register custom hooks (layer 2, after builtins)
|
|
74
|
-
const
|
|
75
|
+
const loadResult = await loadAllCustomHooks(config.customPoliciesPath, { sessionCwd: session.cwd });
|
|
76
|
+
const customHooksList = loadResult.hooks;
|
|
77
|
+
const conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
|
|
78
|
+
|
|
75
79
|
for (const hook of customHooksList) {
|
|
76
80
|
const hookName = hook.name;
|
|
81
|
+
const isConvention = (hook as CustomHook & { __conventionSource?: boolean }).__conventionSource === true;
|
|
82
|
+
const prefix = isConvention ? "convention" : "custom";
|
|
77
83
|
const fn: PolicyFunction = async (ctx): Promise<PolicyResult> => {
|
|
78
84
|
try {
|
|
79
85
|
const result = await Promise.race([
|
|
@@ -86,17 +92,18 @@ export async function handleHookEvent(eventType: string): Promise<number> {
|
|
|
86
92
|
} catch (err) {
|
|
87
93
|
const msg = err instanceof Error ? err.message : String(err);
|
|
88
94
|
const isTimeout = msg === "timeout";
|
|
89
|
-
hookLogWarn(
|
|
95
|
+
hookLogWarn(`${prefix} hook "${hookName}" failed: ${msg}`);
|
|
90
96
|
void trackHookEvent(getInstanceId(), "custom_hook_error", {
|
|
91
97
|
hook_name: hookName,
|
|
92
98
|
error_type: isTimeout ? "timeout" : "exception",
|
|
93
99
|
event_type: eventType,
|
|
100
|
+
is_convention_policy: isConvention,
|
|
94
101
|
});
|
|
95
102
|
return { decision: "allow" };
|
|
96
103
|
}
|
|
97
104
|
};
|
|
98
105
|
registerPolicy(
|
|
99
|
-
|
|
106
|
+
`${prefix}/${hookName}`,
|
|
100
107
|
hook.description ?? "",
|
|
101
108
|
fn,
|
|
102
109
|
hook.match ?? {},
|
|
@@ -113,7 +120,18 @@ export async function handleHookEvent(eventType: string): Promise<number> {
|
|
|
113
120
|
});
|
|
114
121
|
}
|
|
115
122
|
|
|
116
|
-
|
|
123
|
+
// Fire telemetry for convention-based policy discovery
|
|
124
|
+
if (loadResult.conventionSources.length > 0) {
|
|
125
|
+
void trackHookEvent(getInstanceId(), "convention_policies_loaded", {
|
|
126
|
+
event_type: eventType,
|
|
127
|
+
project_file_count: loadResult.conventionSources.filter((s) => s.scope === "project").length,
|
|
128
|
+
user_file_count: loadResult.conventionSources.filter((s) => s.scope === "user").length,
|
|
129
|
+
convention_hook_count: conventionHookNames.size,
|
|
130
|
+
convention_hook_names: [...conventionHookNames],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
hookLogInfo(`event=${eventType} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`);
|
|
117
135
|
|
|
118
136
|
// Evaluate policies
|
|
119
137
|
const result = await evaluatePolicies(eventType as HookEventType, parsed, session, config);
|
|
@@ -152,8 +170,9 @@ export async function handleHookEvent(eventType: string): Promise<number> {
|
|
|
152
170
|
if (result.decision === "deny" || result.decision === "instruct") {
|
|
153
171
|
try {
|
|
154
172
|
const isCustomHook = result.policyName?.startsWith("custom/") ?? false;
|
|
173
|
+
const isConventionPolicy = result.policyName?.startsWith("convention/") ?? false;
|
|
155
174
|
const hasCustomParams =
|
|
156
|
-
!isCustomHook && !!(result.policyName && config.policyParams?.[result.policyName]);
|
|
175
|
+
!isCustomHook && !isConventionPolicy && !!(result.policyName && config.policyParams?.[result.policyName]);
|
|
157
176
|
const paramKeysOverridden = hasCustomParams
|
|
158
177
|
? Object.keys(config.policyParams![result.policyName!])
|
|
159
178
|
: [];
|
|
@@ -164,6 +183,7 @@ export async function handleHookEvent(eventType: string): Promise<number> {
|
|
|
164
183
|
policy_name: result.policyName,
|
|
165
184
|
decision: result.decision,
|
|
166
185
|
is_custom_hook: isCustomHook,
|
|
186
|
+
is_convention_policy: isConventionPolicy,
|
|
167
187
|
has_custom_params: hasCustomParams,
|
|
168
188
|
param_keys_overridden: paramKeysOverridden,
|
|
169
189
|
});
|
|
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
|
5
5
|
import { resolve, dirname } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import type { HooksConfig } from "./policy-types";
|
|
8
|
+
import type { HookScope } from "./types";
|
|
8
9
|
import { hookLogInfo, hookLogWarn } from "./hook-logger";
|
|
9
10
|
|
|
10
11
|
function readConfigAt(path: string): Partial<HooksConfig> {
|
|
@@ -100,14 +101,58 @@ export function writeHooksConfig(config: HooksConfig): void {
|
|
|
100
101
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the policies-config path for a specific scope.
|
|
106
|
+
*/
|
|
107
|
+
export function getConfigPathForScope(scope: HookScope, cwd?: string): string {
|
|
108
|
+
const base = cwd ? resolve(cwd) : process.cwd();
|
|
109
|
+
switch (scope) {
|
|
110
|
+
case "user":
|
|
111
|
+
return resolve(homedir(), ".failproofai", "policies-config.json");
|
|
112
|
+
case "project":
|
|
113
|
+
return resolve(base, ".failproofai", "policies-config.json");
|
|
114
|
+
case "local":
|
|
115
|
+
return resolve(base, ".failproofai", "policies-config.local.json");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read hooks config from a single specific scope (not merged).
|
|
121
|
+
*/
|
|
122
|
+
export function readScopedHooksConfig(scope: HookScope, cwd?: string): HooksConfig {
|
|
123
|
+
const configPath = getConfigPathForScope(scope, cwd);
|
|
124
|
+
if (!existsSync(configPath)) {
|
|
125
|
+
return { enabledPolicies: [] };
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const raw = readFileSync(configPath, "utf8");
|
|
129
|
+
return JSON.parse(raw) as HooksConfig;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
hookLogWarn(`failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
132
|
+
return { enabledPolicies: [] };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Write hooks config to the scope-appropriate path.
|
|
138
|
+
*/
|
|
139
|
+
export function writeScopedHooksConfig(config: HooksConfig, scope: HookScope, cwd?: string): void {
|
|
140
|
+
const configPath = getConfigPathForScope(scope, cwd);
|
|
141
|
+
const dir = dirname(configPath);
|
|
142
|
+
if (!existsSync(dir)) {
|
|
143
|
+
mkdirSync(dir, { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
146
|
+
}
|
|
147
|
+
|
|
103
148
|
export interface ResolvedLlmConfig {
|
|
104
149
|
baseUrl: string;
|
|
105
150
|
apiKey: string;
|
|
106
151
|
model: string;
|
|
107
152
|
}
|
|
108
153
|
|
|
109
|
-
export function readLlmConfig(): ResolvedLlmConfig | null {
|
|
110
|
-
const config =
|
|
154
|
+
export function readLlmConfig(cwd?: string): ResolvedLlmConfig | null {
|
|
155
|
+
const config = readMergedHooksConfig(cwd);
|
|
111
156
|
const baseUrl =
|
|
112
157
|
process.env.FAILPROOFAI_LLM_BASE_URL ?? config.llm?.baseUrl ?? "https://api.openai.com/v1";
|
|
113
158
|
const apiKey = process.env.FAILPROOFAI_LLM_API_KEY ?? config.llm?.apiKey;
|
package/src/hooks/llm-client.ts
CHANGED
|
@@ -30,9 +30,9 @@ export interface ChatCompletionResponse {
|
|
|
30
30
|
|
|
31
31
|
export async function chatCompletion(
|
|
32
32
|
messages: ChatMessage[],
|
|
33
|
-
options?: ChatCompletionOptions,
|
|
33
|
+
options?: ChatCompletionOptions & { cwd?: string },
|
|
34
34
|
): Promise<ChatCompletionResponse> {
|
|
35
|
-
const config = readLlmConfig();
|
|
35
|
+
const config = readLlmConfig(options?.cwd);
|
|
36
36
|
if (!config) {
|
|
37
37
|
throw new Error(
|
|
38
38
|
"No LLM API key configured. Set FAILPROOFAI_LLM_API_KEY or configure llm.apiKey in policies-config.json",
|
|
@@ -71,7 +71,8 @@ export async function resolveLocalImport(
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* Create an ESM shim that re-exports from the CJS dist module.
|
|
74
|
-
*
|
|
74
|
+
* Exports the full public API of failproofai: customPolicies, allow, deny, instruct,
|
|
75
|
+
* getCustomHooks, clearCustomHooks.
|
|
75
76
|
*/
|
|
76
77
|
export async function createEsmShim(
|
|
77
78
|
distIndex: string,
|
|
@@ -80,10 +81,9 @@ export async function createEsmShim(
|
|
|
80
81
|
const shimPath = distIndex + ".__failproofai_esm_shim__.mjs";
|
|
81
82
|
const shimCode = [
|
|
82
83
|
`import _cjs from '${distUrl}';`,
|
|
83
|
-
`export const createApp = _cjs.createApp;`,
|
|
84
|
-
`export const getQueueCondition = _cjs.getQueueCondition;`,
|
|
85
|
-
`export const clearQueueCondition = _cjs.clearQueueCondition;`,
|
|
86
84
|
`export const customPolicies = _cjs.customPolicies;`,
|
|
85
|
+
`export const getCustomHooks = _cjs.getCustomHooks;`,
|
|
86
|
+
`export const clearCustomHooks = _cjs.clearCustomHooks;`,
|
|
87
87
|
`export const allow = _cjs.allow;`,
|
|
88
88
|
`export const deny = _cjs.deny;`,
|
|
89
89
|
`export const instruct = _cjs.instruct;`,
|