failproofai 0.0.6-beta.4 → 0.0.6
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 +3 -3
- 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/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/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/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/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/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/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/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]__0g72weg._.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]__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]__0okos0k._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__111.vxi._.js → [root-of-the-server]__0ow37ro._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0om-5pe._.js → [root-of-the-server]__0t3ka1q._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +2 -2
- 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_posthog-node_dist_entrypoints_index_node_mjs_0mebn66._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- 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/{0rcwkbh24w38b.js → 0-igg2k65fzo_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0o547jv-k_k35.js → 04iuhj_-h-21-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{070orfsl6.xal.js → 061hxr2b-j.6q.js} +1 -1
- package/.next/standalone/.next/static/chunks/{17ne4p.1sw1jy.js → 0k5t-n0s8p2nr.js} +1 -1
- package/.next/standalone/.next/static/chunks/{169_e4dq~1~b6.js → 0lq8ary5l4s8t.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0pk2h2.mjxy.m.js → 0ubv3x~0zdd_w.js} +1 -1
- package/.next/standalone/.next/static/chunks/{02dqjyv6_9mhq.js → 0v23ca5xty1n~.js} +2 -2
- package/.next/standalone/.next/static/chunks/{140xx_tfr~lm_.js → 13cot7j99xkb~.js} +1 -1
- package/.next/standalone/failproofai-hq.gif +0 -0
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/server.js +1 -1
- package/README.md +6 -2
- package/dist/cli.mjs +117 -11
- package/package.json +1 -1
- package/src/hooks/builtin-policies.ts +128 -3
- package/src/hooks/policy-evaluator.ts +31 -7
- package/src/hooks/policy-registry.ts +19 -2
- /package/.next/standalone/.next/static/{wOkJXoch1UmRAmyIuKZWc → my01WPjry7ohRUHyTaYp4}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{wOkJXoch1UmRAmyIuKZWc → my01WPjry7ohRUHyTaYp4}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{wOkJXoch1UmRAmyIuKZWc → my01WPjry7ohRUHyTaYp4}/_ssgManifest.js +0 -0
package/dist/cli.mjs
CHANGED
|
@@ -198,6 +198,9 @@ function instruct(reason) {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
// src/hooks/policy-registry.ts
|
|
201
|
+
function normalizePolicyName(name) {
|
|
202
|
+
return name.includes("/") ? name : `${DEFAULT_POLICY_NAMESPACE}/${name}`;
|
|
203
|
+
}
|
|
201
204
|
function getIndexCache() {
|
|
202
205
|
return globalThis[INDEX_CACHE_KEY];
|
|
203
206
|
}
|
|
@@ -212,9 +215,10 @@ function getRegistry() {
|
|
|
212
215
|
return g[REGISTRY_KEY];
|
|
213
216
|
}
|
|
214
217
|
function registerPolicy(name, description, fn, match, priority = 0) {
|
|
218
|
+
const canonical = normalizePolicyName(name);
|
|
215
219
|
const registry = getRegistry();
|
|
216
|
-
const idx = registry.findIndex((p) => p.name ===
|
|
217
|
-
const entry = { name, description, fn, match, priority };
|
|
220
|
+
const idx = registry.findIndex((p) => p.name === canonical);
|
|
221
|
+
const entry = { name: canonical, description, fn, match, priority };
|
|
218
222
|
if (idx >= 0) {
|
|
219
223
|
registry[idx] = entry;
|
|
220
224
|
} else {
|
|
@@ -251,7 +255,7 @@ function clearPolicies() {
|
|
|
251
255
|
g[REGISTRY_KEY] = [];
|
|
252
256
|
setIndexCache(null);
|
|
253
257
|
}
|
|
254
|
-
var REGISTRY_KEY = "__FAILPROOFAI_POLICY_REGISTRY__", INDEX_CACHE_KEY = "__FAILPROOFAI_POLICY_INDEX_CACHE__";
|
|
258
|
+
var REGISTRY_KEY = "__FAILPROOFAI_POLICY_REGISTRY__", INDEX_CACHE_KEY = "__FAILPROOFAI_POLICY_INDEX_CACHE__", DEFAULT_POLICY_NAMESPACE = "exospherehost";
|
|
255
259
|
|
|
256
260
|
// src/hooks/builtin-policies.ts
|
|
257
261
|
import { resolve as resolve2, join as join2 } from "node:path";
|
|
@@ -1045,6 +1049,82 @@ function requirePrBeforeStop(ctx) {
|
|
|
1045
1049
|
return allow("Could not check PR status, skipping.");
|
|
1046
1050
|
}
|
|
1047
1051
|
}
|
|
1052
|
+
function requireNoConflictsBeforeStop(ctx) {
|
|
1053
|
+
const cwd = ctx.session?.cwd;
|
|
1054
|
+
if (!cwd)
|
|
1055
|
+
return allow("No working directory available, skipping conflict check.");
|
|
1056
|
+
const branch = getCurrentBranch(cwd);
|
|
1057
|
+
if (!branch || branch === "HEAD")
|
|
1058
|
+
return allow("Detached HEAD, skipping conflict check.");
|
|
1059
|
+
const baseBranch = ctx.params?.baseBranch ?? "main";
|
|
1060
|
+
if (branch === baseBranch) {
|
|
1061
|
+
return allow(`On base branch "${baseBranch}", skipping conflict check.`);
|
|
1062
|
+
}
|
|
1063
|
+
let localSkipped = false;
|
|
1064
|
+
try {
|
|
1065
|
+
execFileSync("git", ["rev-parse", "--verify", `origin/${baseBranch}`], {
|
|
1066
|
+
cwd,
|
|
1067
|
+
encoding: "utf8",
|
|
1068
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1069
|
+
timeout: 3000
|
|
1070
|
+
});
|
|
1071
|
+
const ahead = execFileSync("git", ["log", `origin/${baseBranch}..HEAD`, "--oneline"], { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
|
|
1072
|
+
if (!ahead) {
|
|
1073
|
+
localSkipped = true;
|
|
1074
|
+
} else {
|
|
1075
|
+
execFileSync("git", ["merge-tree", "--write-tree", "--name-only", `origin/${baseBranch}`, "HEAD"], { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 1e4 });
|
|
1076
|
+
}
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
const e = err;
|
|
1079
|
+
if (e.status === 1) {
|
|
1080
|
+
const out = (typeof e.stdout === "string" ? e.stdout : e.stdout?.toString("utf8") ?? "").trim();
|
|
1081
|
+
const lines = out.split(`
|
|
1082
|
+
`);
|
|
1083
|
+
const files = [];
|
|
1084
|
+
for (let i = 1;i < lines.length; i++) {
|
|
1085
|
+
const line = lines[i];
|
|
1086
|
+
if (line === "")
|
|
1087
|
+
break;
|
|
1088
|
+
files.push(line);
|
|
1089
|
+
}
|
|
1090
|
+
const fileList = files.length ? files.join(", ") : "one or more files";
|
|
1091
|
+
return deny(`Branch "${branch}" has merge conflicts with ${baseBranch} in: ${fileList}. ` + `Rebase or merge origin/${baseBranch} now and resolve the conflicts.`);
|
|
1092
|
+
}
|
|
1093
|
+
localSkipped = true;
|
|
1094
|
+
}
|
|
1095
|
+
try {
|
|
1096
|
+
execSync("gh --version", { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
1097
|
+
} catch {
|
|
1098
|
+
return allow(localSkipped ? "Local conflict check skipped and gh CLI not installed, skipping conflict check." : `Branch "${branch}" merges cleanly with ${baseBranch} locally (gh CLI not installed, PR mergeability not verified).`);
|
|
1099
|
+
}
|
|
1100
|
+
let prJson;
|
|
1101
|
+
try {
|
|
1102
|
+
prJson = execSync("gh pr view --json mergeable,number,url,state", {
|
|
1103
|
+
cwd,
|
|
1104
|
+
encoding: "utf8",
|
|
1105
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1106
|
+
timeout: 15000
|
|
1107
|
+
}).trim();
|
|
1108
|
+
} catch {
|
|
1109
|
+
return allow(localSkipped ? "No pull request found for branch, skipping conflict check." : `Branch "${branch}" merges cleanly with ${baseBranch} locally (no PR to verify against).`);
|
|
1110
|
+
}
|
|
1111
|
+
let pr;
|
|
1112
|
+
try {
|
|
1113
|
+
pr = JSON.parse(prJson);
|
|
1114
|
+
} catch {
|
|
1115
|
+
return allow("Could not parse gh pr view output, skipping PR mergeability check.");
|
|
1116
|
+
}
|
|
1117
|
+
if (pr.state !== "OPEN") {
|
|
1118
|
+
return allow(`PR #${pr.number} is ${pr.state.toLowerCase()}; skipping conflict check.`);
|
|
1119
|
+
}
|
|
1120
|
+
if (pr.mergeable === "CONFLICTING") {
|
|
1121
|
+
return deny(`PR #${pr.number} has merge conflicts per GitHub (${pr.url}). ` + `Rebase or merge origin/${baseBranch} now and resolve the conflicts.`);
|
|
1122
|
+
}
|
|
1123
|
+
if (pr.mergeable === "UNKNOWN") {
|
|
1124
|
+
return deny(`GitHub is still computing mergeability for PR #${pr.number} (${pr.url}). ` + `Wait ~10 seconds, then re-check with \`gh pr view --json mergeable\` before attempting to stop again.`);
|
|
1125
|
+
}
|
|
1126
|
+
return allow(`PR #${pr.number} merges cleanly per GitHub.`);
|
|
1127
|
+
}
|
|
1048
1128
|
function requireCiGreenBeforeStop(ctx) {
|
|
1049
1129
|
const cwd = ctx.session?.cwd;
|
|
1050
1130
|
if (!cwd)
|
|
@@ -1091,9 +1171,9 @@ function requireCiGreenBeforeStop(ctx) {
|
|
|
1091
1171
|
}
|
|
1092
1172
|
}
|
|
1093
1173
|
function registerBuiltinPolicies(enabledNames) {
|
|
1094
|
-
const enabledSet = new Set(enabledNames);
|
|
1174
|
+
const enabledSet = new Set(enabledNames.map(normalizePolicyName));
|
|
1095
1175
|
for (const policy of BUILTIN_POLICIES) {
|
|
1096
|
-
if (enabledSet.has(policy.name)) {
|
|
1176
|
+
if (enabledSet.has(normalizePolicyName(policy.name))) {
|
|
1097
1177
|
registerPolicy(policy.name, policy.description, policy.fn, policy.match);
|
|
1098
1178
|
}
|
|
1099
1179
|
}
|
|
@@ -1502,6 +1582,21 @@ var init_builtin_policies = __esm(() => {
|
|
|
1502
1582
|
}
|
|
1503
1583
|
}
|
|
1504
1584
|
},
|
|
1585
|
+
{
|
|
1586
|
+
name: "require-no-conflicts-before-stop",
|
|
1587
|
+
description: "Require the current branch to merge cleanly with the base branch before Claude stops",
|
|
1588
|
+
fn: requireNoConflictsBeforeStop,
|
|
1589
|
+
match: { events: ["Stop"] },
|
|
1590
|
+
defaultEnabled: false,
|
|
1591
|
+
category: "Workflow",
|
|
1592
|
+
params: {
|
|
1593
|
+
baseBranch: {
|
|
1594
|
+
type: "string",
|
|
1595
|
+
description: "Base branch to check for conflicts against (default: main)",
|
|
1596
|
+
default: "main"
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1505
1600
|
{
|
|
1506
1601
|
name: "require-ci-green-before-stop",
|
|
1507
1602
|
description: "Require CI checks to pass on the current branch before Claude stops",
|
|
@@ -1523,6 +1618,17 @@ function appendHint(baseReason, hint) {
|
|
|
1523
1618
|
return normalizedHint;
|
|
1524
1619
|
return `${base}. ${normalizedHint}`;
|
|
1525
1620
|
}
|
|
1621
|
+
function getConfigParamsFor(config, canonicalName) {
|
|
1622
|
+
if (!config?.policyParams)
|
|
1623
|
+
return;
|
|
1624
|
+
const canonicalParams = config.policyParams[canonicalName];
|
|
1625
|
+
if (canonicalParams)
|
|
1626
|
+
return canonicalParams;
|
|
1627
|
+
const defaultPrefix = `${DEFAULT_POLICY_NAMESPACE}/`;
|
|
1628
|
+
if (!canonicalName.startsWith(defaultPrefix))
|
|
1629
|
+
return;
|
|
1630
|
+
return config.policyParams[canonicalName.slice(defaultPrefix.length)];
|
|
1631
|
+
}
|
|
1526
1632
|
async function evaluatePolicies(eventType, payload, session, config) {
|
|
1527
1633
|
const toolName = payload.tool_name;
|
|
1528
1634
|
const toolInput = payload.tool_input;
|
|
@@ -1544,7 +1650,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
|
|
|
1544
1650
|
const schema = POLICY_PARAMS_MAP.get(policy.name);
|
|
1545
1651
|
let ctx;
|
|
1546
1652
|
if (schema) {
|
|
1547
|
-
const userParams = config
|
|
1653
|
+
const userParams = getConfigParamsFor(config, policy.name) ?? {};
|
|
1548
1654
|
const resolvedParams = {};
|
|
1549
1655
|
for (const [key, spec] of Object.entries(schema)) {
|
|
1550
1656
|
resolvedParams[key] = key in userParams ? userParams[key] : spec.default;
|
|
@@ -1561,7 +1667,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
|
|
|
1561
1667
|
continue;
|
|
1562
1668
|
}
|
|
1563
1669
|
if (result.decision === "deny") {
|
|
1564
|
-
const reason = appendHint(result.reason ?? `Blocked by policy: ${policy.name}`, config
|
|
1670
|
+
const reason = appendHint(result.reason ?? `Blocked by policy: ${policy.name}`, getConfigParamsFor(config, policy.name)?.hint);
|
|
1565
1671
|
hookLogInfo(`deny by "${policy.name}": ${reason}`);
|
|
1566
1672
|
const displayTool = ctx.toolName ?? "unknown tool";
|
|
1567
1673
|
if (eventType === "PreToolUse") {
|
|
@@ -1619,7 +1725,7 @@ You MUST complete the above action NOW. Do NOT ask the user for confirmation —
|
|
|
1619
1725
|
};
|
|
1620
1726
|
}
|
|
1621
1727
|
if (result.decision === "instruct") {
|
|
1622
|
-
const reason = appendHint(result.reason ?? `Instruction from policy: ${policy.name}`, config
|
|
1728
|
+
const reason = appendHint(result.reason ?? `Instruction from policy: ${policy.name}`, getConfigParamsFor(config, policy.name)?.hint);
|
|
1623
1729
|
instructEntries.push({ policyName: policy.name, reason });
|
|
1624
1730
|
hookLogInfo(`instruct by "${policy.name}": ${reason}`);
|
|
1625
1731
|
}
|
|
@@ -1678,7 +1784,7 @@ var POLICY_PARAMS_MAP;
|
|
|
1678
1784
|
var init_policy_evaluator = __esm(() => {
|
|
1679
1785
|
init_builtin_policies();
|
|
1680
1786
|
init_hook_logger();
|
|
1681
|
-
POLICY_PARAMS_MAP = new Map(BUILTIN_POLICIES.filter((p) => p.params).map((p) => [p.name, p.params]));
|
|
1787
|
+
POLICY_PARAMS_MAP = new Map(BUILTIN_POLICIES.filter((p) => p.params).map((p) => [normalizePolicyName(p.name), p.params]));
|
|
1682
1788
|
});
|
|
1683
1789
|
|
|
1684
1790
|
// src/hooks/custom-hooks-registry.ts
|
|
@@ -2065,7 +2171,7 @@ var init_hook_activity_store = __esm(() => {
|
|
|
2065
2171
|
});
|
|
2066
2172
|
|
|
2067
2173
|
// package.json
|
|
2068
|
-
var version2 = "0.0.6
|
|
2174
|
+
var version2 = "0.0.6";
|
|
2069
2175
|
var init_package = () => {};
|
|
2070
2176
|
|
|
2071
2177
|
// src/posthog-key.ts
|
|
@@ -4378,7 +4484,7 @@ import { realpathSync as realpathSync2 } from "fs";
|
|
|
4378
4484
|
import { dirname as dirname7, resolve as resolve8 } from "path";
|
|
4379
4485
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4380
4486
|
// package.json
|
|
4381
|
-
var version = "0.0.6
|
|
4487
|
+
var version = "0.0.6";
|
|
4382
4488
|
|
|
4383
4489
|
// bin/failproofai.mjs
|
|
4384
4490
|
if (!process.env.FAILPROOFAI_PACKAGE_ROOT) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "failproofai",
|
|
3
|
-
"version": "0.0.6
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
|
|
5
5
|
"bin": {
|
|
6
6
|
"failproofai": "./dist/cli.mjs"
|
|
@@ -7,7 +7,7 @@ import { execSync, execFileSync } from "node:child_process";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import type { BuiltinPolicyDefinition, PolicyContext, PolicyResult, PolicyParamsSchema } from "./policy-types";
|
|
9
9
|
import { allow, deny, instruct } from "./policy-helpers";
|
|
10
|
-
import { registerPolicy } from "./policy-registry";
|
|
10
|
+
import { normalizePolicyName, registerPolicy } from "./policy-registry";
|
|
11
11
|
import { hookLogWarn } from "./hook-logger";
|
|
12
12
|
|
|
13
13
|
function isClaudeInternalPath(resolved: string): boolean {
|
|
@@ -1185,6 +1185,114 @@ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult {
|
|
|
1185
1185
|
}
|
|
1186
1186
|
}
|
|
1187
1187
|
|
|
1188
|
+
function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult {
|
|
1189
|
+
const cwd = ctx.session?.cwd;
|
|
1190
|
+
if (!cwd) return allow("No working directory available, skipping conflict check.");
|
|
1191
|
+
|
|
1192
|
+
const branch = getCurrentBranch(cwd);
|
|
1193
|
+
if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping conflict check.");
|
|
1194
|
+
|
|
1195
|
+
const baseBranch = (ctx.params?.baseBranch as string) ?? "main";
|
|
1196
|
+
if (branch === baseBranch) {
|
|
1197
|
+
return allow(`On base branch "${baseBranch}", skipping conflict check.`);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// -- Layer 1: local git merge-tree --
|
|
1201
|
+
let localSkipped = false;
|
|
1202
|
+
try {
|
|
1203
|
+
execFileSync("git", ["rev-parse", "--verify", `origin/${baseBranch}`], {
|
|
1204
|
+
cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000,
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
const ahead = execFileSync(
|
|
1208
|
+
"git", ["log", `origin/${baseBranch}..HEAD`, "--oneline"],
|
|
1209
|
+
{ cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 },
|
|
1210
|
+
).trim();
|
|
1211
|
+
|
|
1212
|
+
if (!ahead) {
|
|
1213
|
+
// Nothing ahead of base — Layer 1 doesn't apply, fall through to Layer 2.
|
|
1214
|
+
localSkipped = true;
|
|
1215
|
+
} else {
|
|
1216
|
+
execFileSync(
|
|
1217
|
+
"git",
|
|
1218
|
+
["merge-tree", "--write-tree", "--name-only", `origin/${baseBranch}`, "HEAD"],
|
|
1219
|
+
{ cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 10000 },
|
|
1220
|
+
);
|
|
1221
|
+
// exit 0 → clean merge, fall through to Layer 2
|
|
1222
|
+
}
|
|
1223
|
+
} catch (err) {
|
|
1224
|
+
const e = err as { status?: number; stdout?: string | Buffer };
|
|
1225
|
+
if (e.status === 1) {
|
|
1226
|
+
// git merge-tree exit 1 = conflicts. stdout: <tree>\n<file>\n<file>\n\n<messages>
|
|
1227
|
+
const out = (typeof e.stdout === "string" ? e.stdout : e.stdout?.toString("utf8") ?? "").trim();
|
|
1228
|
+
const lines = out.split("\n");
|
|
1229
|
+
const files: string[] = [];
|
|
1230
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1231
|
+
const line = lines[i];
|
|
1232
|
+
if (line === "") break;
|
|
1233
|
+
files.push(line);
|
|
1234
|
+
}
|
|
1235
|
+
const fileList = files.length ? files.join(", ") : "one or more files";
|
|
1236
|
+
return deny(
|
|
1237
|
+
`Branch "${branch}" has merge conflicts with ${baseBranch} in: ${fileList}. ` +
|
|
1238
|
+
`Rebase or merge origin/${baseBranch} now and resolve the conflicts.`,
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
localSkipped = true;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// -- Layer 2: GitHub PR mergeability --
|
|
1245
|
+
try {
|
|
1246
|
+
execSync("gh --version", { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
|
|
1247
|
+
} catch {
|
|
1248
|
+
return allow(
|
|
1249
|
+
localSkipped
|
|
1250
|
+
? "Local conflict check skipped and gh CLI not installed, skipping conflict check."
|
|
1251
|
+
: `Branch "${branch}" merges cleanly with ${baseBranch} locally (gh CLI not installed, PR mergeability not verified).`,
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
let prJson: string;
|
|
1256
|
+
try {
|
|
1257
|
+
prJson = execSync("gh pr view --json mergeable,number,url,state", {
|
|
1258
|
+
cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 15000,
|
|
1259
|
+
}).trim();
|
|
1260
|
+
} catch {
|
|
1261
|
+
return allow(
|
|
1262
|
+
localSkipped
|
|
1263
|
+
? "No pull request found for branch, skipping conflict check."
|
|
1264
|
+
: `Branch "${branch}" merges cleanly with ${baseBranch} locally (no PR to verify against).`,
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
let pr: { mergeable: string; number: number; url: string; state: string };
|
|
1269
|
+
try {
|
|
1270
|
+
pr = JSON.parse(prJson);
|
|
1271
|
+
} catch {
|
|
1272
|
+
return allow("Could not parse gh pr view output, skipping PR mergeability check.");
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// GitHub stops computing mergeability for non-OPEN PRs (returns UNKNOWN forever).
|
|
1276
|
+
// Skip the check entirely so a merged or closed PR doesn't trap Stop in a wait loop.
|
|
1277
|
+
if (pr.state !== "OPEN") {
|
|
1278
|
+
return allow(`PR #${pr.number} is ${pr.state.toLowerCase()}; skipping conflict check.`);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (pr.mergeable === "CONFLICTING") {
|
|
1282
|
+
return deny(
|
|
1283
|
+
`PR #${pr.number} has merge conflicts per GitHub (${pr.url}). ` +
|
|
1284
|
+
`Rebase or merge origin/${baseBranch} now and resolve the conflicts.`,
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
if (pr.mergeable === "UNKNOWN") {
|
|
1288
|
+
return deny(
|
|
1289
|
+
`GitHub is still computing mergeability for PR #${pr.number} (${pr.url}). ` +
|
|
1290
|
+
`Wait ~10 seconds, then re-check with \`gh pr view --json mergeable\` before attempting to stop again.`,
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
return allow(`PR #${pr.number} merges cleanly per GitHub.`);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1188
1296
|
function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
|
|
1189
1297
|
const cwd = ctx.session?.cwd;
|
|
1190
1298
|
if (!cwd) return allow("No working directory available, skipping CI check.");
|
|
@@ -1590,6 +1698,21 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
|
|
|
1590
1698
|
},
|
|
1591
1699
|
} satisfies PolicyParamsSchema,
|
|
1592
1700
|
},
|
|
1701
|
+
{
|
|
1702
|
+
name: "require-no-conflicts-before-stop",
|
|
1703
|
+
description: "Require the current branch to merge cleanly with the base branch before Claude stops",
|
|
1704
|
+
fn: requireNoConflictsBeforeStop,
|
|
1705
|
+
match: { events: ["Stop"] },
|
|
1706
|
+
defaultEnabled: false,
|
|
1707
|
+
category: "Workflow",
|
|
1708
|
+
params: {
|
|
1709
|
+
baseBranch: {
|
|
1710
|
+
type: "string",
|
|
1711
|
+
description: "Base branch to check for conflicts against (default: main)",
|
|
1712
|
+
default: "main",
|
|
1713
|
+
},
|
|
1714
|
+
} satisfies PolicyParamsSchema,
|
|
1715
|
+
},
|
|
1593
1716
|
{
|
|
1594
1717
|
name: "require-ci-green-before-stop",
|
|
1595
1718
|
description: "Require CI checks to pass on the current branch before Claude stops",
|
|
@@ -1601,9 +1724,11 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
|
|
|
1601
1724
|
];
|
|
1602
1725
|
|
|
1603
1726
|
export function registerBuiltinPolicies(enabledNames: string[]): void {
|
|
1604
|
-
|
|
1727
|
+
// Tolerate both flat ("sanitize-jwt") and qualified ("exospherehost/sanitize-jwt")
|
|
1728
|
+
// forms in the user's enabledPolicies config — canonicalize both sides.
|
|
1729
|
+
const enabledSet = new Set(enabledNames.map(normalizePolicyName));
|
|
1605
1730
|
for (const policy of BUILTIN_POLICIES) {
|
|
1606
|
-
if (enabledSet.has(policy.name)) {
|
|
1731
|
+
if (enabledSet.has(normalizePolicyName(policy.name))) {
|
|
1607
1732
|
registerPolicy(policy.name, policy.description, policy.fn, policy.match);
|
|
1608
1733
|
}
|
|
1609
1734
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { HookEventType, SessionMetadata } from "./types";
|
|
6
6
|
import type { PolicyContext, HooksConfig } from "./policy-types";
|
|
7
7
|
import { BUILTIN_POLICIES } from "./builtin-policies";
|
|
8
|
-
import { getPoliciesForEvent } from "./policy-registry";
|
|
8
|
+
import { DEFAULT_POLICY_NAMESPACE, getPoliciesForEvent, normalizePolicyName } from "./policy-registry";
|
|
9
9
|
import { hookLogInfo, hookLogWarn } from "./hook-logger";
|
|
10
10
|
|
|
11
11
|
function appendHint(baseReason: string, hint: unknown): string {
|
|
@@ -26,11 +26,33 @@ export interface EvaluationResult {
|
|
|
26
26
|
decision: "allow" | "deny" | "instruct";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Build a map from policy name to its params schema (for injecting defaults)
|
|
29
|
+
// Build a map from canonical policy name to its params schema (for injecting defaults).
|
|
30
|
+
// Keyed by canonical name because registered policies always carry the canonical form.
|
|
30
31
|
const POLICY_PARAMS_MAP = new Map(
|
|
31
|
-
BUILTIN_POLICIES.filter((p) => p.params).map((p) => [p.name, p.params!]),
|
|
32
|
+
BUILTIN_POLICIES.filter((p) => p.params).map((p) => [normalizePolicyName(p.name), p.params!]),
|
|
32
33
|
);
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Look up policy params for a canonical policy name in the user config,
|
|
37
|
+
* tolerating either flat ("block-force-push") or qualified
|
|
38
|
+
* ("exospherehost/block-force-push") config keys for built-in policies.
|
|
39
|
+
*
|
|
40
|
+
* The flat-key fallback is intentionally limited to the default namespace
|
|
41
|
+
* so namespace isolation is preserved: `policyParams.foo` only matches
|
|
42
|
+
* `exospherehost/foo`, never `myorg/foo` or `custom/foo`.
|
|
43
|
+
*/
|
|
44
|
+
function getConfigParamsFor(
|
|
45
|
+
config: HooksConfig | undefined,
|
|
46
|
+
canonicalName: string,
|
|
47
|
+
): Record<string, unknown> | undefined {
|
|
48
|
+
if (!config?.policyParams) return undefined;
|
|
49
|
+
const canonicalParams = config.policyParams[canonicalName];
|
|
50
|
+
if (canonicalParams) return canonicalParams;
|
|
51
|
+
const defaultPrefix = `${DEFAULT_POLICY_NAMESPACE}/`;
|
|
52
|
+
if (!canonicalName.startsWith(defaultPrefix)) return undefined;
|
|
53
|
+
return config.policyParams[canonicalName.slice(defaultPrefix.length)];
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
export async function evaluatePolicies(
|
|
35
57
|
eventType: HookEventType,
|
|
36
58
|
payload: Record<string, unknown>,
|
|
@@ -63,11 +85,13 @@ export async function evaluatePolicies(
|
|
|
63
85
|
const allowEntries: Array<{ policyName: string; reason: string }> = [];
|
|
64
86
|
|
|
65
87
|
for (const policy of policies) {
|
|
66
|
-
// Inject params: merge policyParams[policy.name] over schema defaults
|
|
88
|
+
// Inject params: merge policyParams[policy.name] over schema defaults.
|
|
89
|
+
// policy.name is canonical (e.g. "exospherehost/block-force-push"); user
|
|
90
|
+
// config keys may be flat or canonical — getConfigParamsFor accepts both.
|
|
67
91
|
const schema = POLICY_PARAMS_MAP.get(policy.name);
|
|
68
92
|
let ctx: PolicyContext;
|
|
69
93
|
if (schema) {
|
|
70
|
-
const userParams = config
|
|
94
|
+
const userParams = getConfigParamsFor(config, policy.name) ?? {};
|
|
71
95
|
const resolvedParams: Record<string, unknown> = {};
|
|
72
96
|
for (const [key, spec] of Object.entries(schema)) {
|
|
73
97
|
resolvedParams[key] = key in userParams ? userParams[key] : spec.default;
|
|
@@ -89,7 +113,7 @@ export async function evaluatePolicies(
|
|
|
89
113
|
if (result.decision === "deny") {
|
|
90
114
|
const reason = appendHint(
|
|
91
115
|
result.reason ?? `Blocked by policy: ${policy.name}`,
|
|
92
|
-
config
|
|
116
|
+
getConfigParamsFor(config, policy.name)?.hint,
|
|
93
117
|
);
|
|
94
118
|
hookLogInfo(`deny by "${policy.name}": ${reason}`);
|
|
95
119
|
|
|
@@ -156,7 +180,7 @@ export async function evaluatePolicies(
|
|
|
156
180
|
if (result.decision === "instruct") {
|
|
157
181
|
const reason = appendHint(
|
|
158
182
|
result.reason ?? `Instruction from policy: ${policy.name}`,
|
|
159
|
-
config
|
|
183
|
+
getConfigParamsFor(config, policy.name)?.hint,
|
|
160
184
|
);
|
|
161
185
|
instructEntries.push({ policyName: policy.name, reason });
|
|
162
186
|
hookLogInfo(`instruct by "${policy.name}": ${reason}`);
|
|
@@ -11,6 +11,22 @@ import type { PolicyFunction, PolicyMatcher, RegisteredPolicy } from "./policy-t
|
|
|
11
11
|
const REGISTRY_KEY = "__FAILPROOFAI_POLICY_REGISTRY__";
|
|
12
12
|
const INDEX_CACHE_KEY = "__FAILPROOFAI_POLICY_INDEX_CACHE__";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The default namespace applied to any policy name registered without a
|
|
16
|
+
* `<namespace>/` prefix. Builtins live under this namespace; custom hooks
|
|
17
|
+
* loaded by the handler get their own prefixes (e.g. `custom/foo`).
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_POLICY_NAMESPACE = "exospherehost";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonicalize a policy name. If the name already contains a `/`, it is
|
|
23
|
+
* treated as already-namespaced and returned unchanged. Otherwise the
|
|
24
|
+
* default namespace is prepended.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizePolicyName(name: string): string {
|
|
27
|
+
return name.includes("/") ? name : `${DEFAULT_POLICY_NAMESPACE}/${name}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
interface GlobalWithRegistry {
|
|
15
31
|
[REGISTRY_KEY]?: RegisteredPolicy[];
|
|
16
32
|
}
|
|
@@ -42,9 +58,10 @@ export function registerPolicy(
|
|
|
42
58
|
match: PolicyMatcher,
|
|
43
59
|
priority: number = 0,
|
|
44
60
|
): void {
|
|
61
|
+
const canonical = normalizePolicyName(name);
|
|
45
62
|
const registry = getRegistry();
|
|
46
|
-
const idx = registry.findIndex((p) => p.name ===
|
|
47
|
-
const entry: RegisteredPolicy = { name, description, fn, match, priority };
|
|
63
|
+
const idx = registry.findIndex((p) => p.name === canonical);
|
|
64
|
+
const entry: RegisteredPolicy = { name: canonical, description, fn, match, priority };
|
|
48
65
|
if (idx >= 0) {
|
|
49
66
|
registry[idx] = entry;
|
|
50
67
|
} else {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|