failproofai 0.0.6 → 0.0.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.
Files changed (86) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/.next/server/app/index.html +1 -1
  27. package/.next/standalone/.next/server/app/index.rsc +15 -15
  28. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  29. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  30. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  31. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  32. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  33. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  34. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  37. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  39. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  43. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  44. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +1 -1
  50. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  51. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
  52. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.js +2 -2
  53. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
  54. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
  55. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0okos0k._.js +2 -2
  56. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0t3ka1q._.js → [root-of-the-server]__0tjjyb9._.js} +2 -2
  57. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +7 -7
  58. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ow37ro._.js → [root-of-the-server]__0zn7uo6._.js} +2 -2
  59. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
  60. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
  61. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +1 -1
  64. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  65. package/.next/standalone/.next/server/pages/404.html +2 -2
  66. package/.next/standalone/.next/server/pages/500.html +1 -1
  67. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  68. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  69. package/.next/standalone/.next/static/chunks/{0lq8ary5l4s8t.js → 01l2mh88iy.ga.js} +1 -1
  70. package/.next/standalone/.next/static/chunks/{04iuhj_-h-21-.js → 0388wpenm9-a4.js} +1 -1
  71. package/.next/standalone/.next/static/chunks/{0ubv3x~0zdd_w.js → 0a0lh_a4f_xs-.js} +1 -1
  72. package/.next/standalone/.next/static/chunks/{13cot7j99xkb~.js → 0f_9854du76y2.js} +1 -1
  73. package/.next/standalone/.next/static/chunks/{0-igg2k65fzo_.js → 0j2o20pqkib~d.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/{0k5t-n0s8p2nr.js → 0kkzzoo.s-t3p.js} +1 -1
  75. package/.next/standalone/.next/static/chunks/{0v23ca5xty1n~.js → 0x0o8~u4jsatb.js} +2 -2
  76. package/.next/standalone/.next/static/chunks/{061hxr2b-j.6q.js → 12wu.28cbx4dl.js} +1 -1
  77. package/.next/standalone/package.json +1 -1
  78. package/.next/standalone/server.js +1 -1
  79. package/dist/cli.mjs +203 -46
  80. package/package.json +1 -1
  81. package/src/hooks/builtin-policies.ts +196 -44
  82. package/src/hooks/custom-hooks-loader.ts +6 -3
  83. package/src/hooks/hooks-config.ts +31 -5
  84. /package/.next/standalone/.next/static/{my01WPjry7ohRUHyTaYp4 → 9FNjQiktocMN-qDiGqDL5}/_buildManifest.js +0 -0
  85. /package/.next/standalone/.next/static/{my01WPjry7ohRUHyTaYp4 → 9FNjQiktocMN-qDiGqDL5}/_clientMiddlewareManifest.js +0 -0
  86. /package/.next/standalone/.next/static/{my01WPjry7ohRUHyTaYp4 → 9FNjQiktocMN-qDiGqDL5}/_ssgManifest.js +0 -0
@@ -159,6 +159,21 @@ const TMUX_DETACH_RE = /\btmux\s+(?:new-session|new)\b[^|&;]*-d\b/;
159
159
  const DISOWN_RE = /\bdisown\b/;
160
160
  const BACKGROUND_AMPERSAND_RE = /(?<![&|])\s?&\s*(?:$|#|;)/;
161
161
 
162
+ // Infra Commands — leading-token detection across shell separators.
163
+ // Each regex matches the CLI name only when it appears as the first token of a
164
+ // command segment (start-of-string or after ; && || |). Trailing \s prevents
165
+ // false matches on names like "kubectlx" or "awsctl".
166
+ const KUBECTL_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*kubectl(?:\s|$)/;
167
+ const TERRAFORM_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*(?:terraform|tofu)(?:\s|$)/;
168
+ const AWS_CLI_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*aws(?:\s|$)/;
169
+ const GCLOUD_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*gcloud(?:\s|$)/;
170
+ const AZ_CLI_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*az(?:\s|$)/;
171
+ const HELM_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*helm(?:\s|$)/;
172
+ // gh: only mutating / pipeline-trigger subcommands. Read-only forms
173
+ // (gh pr view, gh run list, gh api ...) are intentionally allowed because
174
+ // failproofai's own workflow policies depend on them.
175
+ const GH_PIPELINE_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*gh\s+(?:workflow\s+(?:run|enable|disable)|run\s+(?:rerun|cancel)|pr\s+merge|release\s+(?:create|delete)|cache\s+delete|secret\s+(?:set|delete))\b/;
176
+
162
177
  // Caches the current branch per cwd to avoid repeated execSync calls.
163
178
  // Trade-off: if the user switches branches externally mid-session, the cache serves
164
179
  // the stale value until the process restarts. This is acceptable since branch switches
@@ -770,6 +785,48 @@ function blockFailproofaiCommands(ctx: PolicyContext): PolicyResult {
770
785
  return allow();
771
786
  }
772
787
 
788
+ // Shared CLI-blocker: deny any command whose argv begins with the matched CLI,
789
+ // unless an entry in `allowPatterns` matches via `matchesAllowedPattern` (which
790
+ // already defends against shell-operator injection).
791
+ function blockInfraCli(ctx: PolicyContext, re: RegExp, denyMsg: string): PolicyResult {
792
+ if (ctx.toolName !== "Bash") return allow();
793
+ const cmd = getCommand(ctx);
794
+ if (!re.test(cmd)) return allow();
795
+ const allowPatterns = ((ctx.params?.allowPatterns ?? []) as string[]);
796
+ if (allowPatterns.some((p) => matchesAllowedPattern(cmd, p))) return allow();
797
+ return deny(denyMsg);
798
+ }
799
+
800
+ function blockKubectl(ctx: PolicyContext): PolicyResult {
801
+ return blockInfraCli(ctx, KUBECTL_RE, "kubectl commands are blocked");
802
+ }
803
+
804
+ function blockTerraform(ctx: PolicyContext): PolicyResult {
805
+ return blockInfraCli(ctx, TERRAFORM_RE, "terraform/tofu commands are blocked");
806
+ }
807
+
808
+ function blockAwsCli(ctx: PolicyContext): PolicyResult {
809
+ return blockInfraCli(ctx, AWS_CLI_RE, "aws CLI commands are blocked");
810
+ }
811
+
812
+ function blockGcloud(ctx: PolicyContext): PolicyResult {
813
+ return blockInfraCli(ctx, GCLOUD_RE, "gcloud commands are blocked");
814
+ }
815
+
816
+ function blockAzCli(ctx: PolicyContext): PolicyResult {
817
+ return blockInfraCli(ctx, AZ_CLI_RE, "az (Azure) CLI commands are blocked");
818
+ }
819
+
820
+ function blockHelm(ctx: PolicyContext): PolicyResult {
821
+ return blockInfraCli(ctx, HELM_RE, "helm commands are blocked");
822
+ }
823
+
824
+ // gh-pipeline only fires on mutating subcommands; allowPatterns are still
825
+ // supported in case a user wants to permit a specific scripted invocation.
826
+ function blockGhPipeline(ctx: PolicyContext): PolicyResult {
827
+ return blockInfraCli(ctx, GH_PIPELINE_RE, "gh pipeline-trigger commands are blocked");
828
+ }
829
+
773
830
  // Maximum size of the per-session tool-call sidecar before we stop updating it.
774
831
  // If exceeded, repeated-call detection degrades gracefully (allows through) rather
775
832
  // than growing the file unboundedly.
@@ -1197,8 +1254,37 @@ function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult {
1197
1254
  return allow(`On base branch "${baseBranch}", skipping conflict check.`);
1198
1255
  }
1199
1256
 
1257
+ // -- Precheck: only enforce when an OPEN PR exists on GitHub. Without a
1258
+ // confirmable merge target there is nothing to enforce, so we skip both
1259
+ // the local merge-tree probe and the GitHub mergeability probe.
1260
+ try {
1261
+ execSync("gh --version", { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
1262
+ } catch {
1263
+ return allow("gh CLI not installed, skipping conflict check.");
1264
+ }
1265
+
1266
+ let prJson: string;
1267
+ try {
1268
+ prJson = execSync("gh pr view --json mergeable,number,url,state", {
1269
+ cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 15000,
1270
+ }).trim();
1271
+ } catch {
1272
+ return allow("No pull request found for branch, skipping conflict check.");
1273
+ }
1274
+
1275
+ let pr: { mergeable: string; number: number; url: string; state: string };
1276
+ try {
1277
+ pr = JSON.parse(prJson);
1278
+ } catch {
1279
+ return allow("Could not parse gh pr view output, skipping conflict check.");
1280
+ }
1281
+
1282
+ // GitHub stops computing mergeability for non-OPEN PRs (returns UNKNOWN forever).
1283
+ if (pr.state !== "OPEN") {
1284
+ return allow(`PR #${pr.number} is ${pr.state.toLowerCase()}; skipping conflict check.`);
1285
+ }
1286
+
1200
1287
  // -- Layer 1: local git merge-tree --
1201
- let localSkipped = false;
1202
1288
  try {
1203
1289
  execFileSync("git", ["rev-parse", "--verify", `origin/${baseBranch}`], {
1204
1290
  cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000,
@@ -1209,17 +1295,14 @@ function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult {
1209
1295
  { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 },
1210
1296
  ).trim();
1211
1297
 
1212
- if (!ahead) {
1213
- // Nothing ahead of base — Layer 1 doesn't apply, fall through to Layer 2.
1214
- localSkipped = true;
1215
- } else {
1298
+ if (ahead) {
1216
1299
  execFileSync(
1217
1300
  "git",
1218
1301
  ["merge-tree", "--write-tree", "--name-only", `origin/${baseBranch}`, "HEAD"],
1219
1302
  { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 10000 },
1220
1303
  );
1221
- // exit 0 → clean merge, fall through to Layer 2
1222
1304
  }
1305
+ // !ahead or merge-tree exit 0 → fall through to Layer 2
1223
1306
  } catch (err) {
1224
1307
  const e = err as { status?: number; stdout?: string | Buffer };
1225
1308
  if (e.status === 1) {
@@ -1238,46 +1321,10 @@ function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult {
1238
1321
  `Rebase or merge origin/${baseBranch} now and resolve the conflicts.`,
1239
1322
  );
1240
1323
  }
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.`);
1324
+ // any other failure (e.g. missing origin/<base>, log failure) → fall through
1279
1325
  }
1280
1326
 
1327
+ // -- Layer 2: GitHub PR mergeability (reuses pr from precheck) --
1281
1328
  if (pr.mergeable === "CONFLICTING") {
1282
1329
  return deny(
1283
1330
  `PR #${pr.number} has merge conflicts per GitHub (${pr.url}). ` +
@@ -1495,6 +1542,111 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1495
1542
  defaultEnabled: true,
1496
1543
  category: "Dangerous Commands",
1497
1544
  },
1545
+ {
1546
+ name: "block-kubectl",
1547
+ description: "Block kubectl commands (Kubernetes cluster mutations)",
1548
+ fn: blockKubectl,
1549
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1550
+ defaultEnabled: false,
1551
+ category: "Infra Commands",
1552
+ params: {
1553
+ allowPatterns: {
1554
+ type: "string[]",
1555
+ description: "kubectl command patterns to allow, matched token-by-token (e.g. 'kubectl get *', 'kubectl describe *')",
1556
+ default: [],
1557
+ },
1558
+ } satisfies PolicyParamsSchema,
1559
+ },
1560
+ {
1561
+ name: "block-terraform",
1562
+ description: "Block terraform and tofu (OpenTofu) commands",
1563
+ fn: blockTerraform,
1564
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1565
+ defaultEnabled: false,
1566
+ category: "Infra Commands",
1567
+ params: {
1568
+ allowPatterns: {
1569
+ type: "string[]",
1570
+ description: "terraform/tofu command patterns to allow (e.g. 'terraform plan', 'terraform validate')",
1571
+ default: [],
1572
+ },
1573
+ } satisfies PolicyParamsSchema,
1574
+ },
1575
+ {
1576
+ name: "block-aws-cli",
1577
+ description: "Block aws CLI commands",
1578
+ fn: blockAwsCli,
1579
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1580
+ defaultEnabled: false,
1581
+ category: "Infra Commands",
1582
+ params: {
1583
+ allowPatterns: {
1584
+ type: "string[]",
1585
+ description: "aws CLI command patterns to allow (e.g. 'aws s3 ls *', 'aws sts get-caller-identity')",
1586
+ default: [],
1587
+ },
1588
+ } satisfies PolicyParamsSchema,
1589
+ },
1590
+ {
1591
+ name: "block-gcloud",
1592
+ description: "Block gcloud (Google Cloud) CLI commands",
1593
+ fn: blockGcloud,
1594
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1595
+ defaultEnabled: false,
1596
+ category: "Infra Commands",
1597
+ params: {
1598
+ allowPatterns: {
1599
+ type: "string[]",
1600
+ description: "gcloud command patterns to allow (e.g. 'gcloud auth list', 'gcloud config list')",
1601
+ default: [],
1602
+ },
1603
+ } satisfies PolicyParamsSchema,
1604
+ },
1605
+ {
1606
+ name: "block-az-cli",
1607
+ description: "Block az (Azure) CLI commands",
1608
+ fn: blockAzCli,
1609
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1610
+ defaultEnabled: false,
1611
+ category: "Infra Commands",
1612
+ params: {
1613
+ allowPatterns: {
1614
+ type: "string[]",
1615
+ description: "az CLI command patterns to allow (e.g. 'az account show', 'az group list')",
1616
+ default: [],
1617
+ },
1618
+ } satisfies PolicyParamsSchema,
1619
+ },
1620
+ {
1621
+ name: "block-helm",
1622
+ description: "Block helm commands",
1623
+ fn: blockHelm,
1624
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1625
+ defaultEnabled: false,
1626
+ category: "Infra Commands",
1627
+ params: {
1628
+ allowPatterns: {
1629
+ type: "string[]",
1630
+ description: "helm command patterns to allow (e.g. 'helm list', 'helm status *')",
1631
+ default: [],
1632
+ },
1633
+ } satisfies PolicyParamsSchema,
1634
+ },
1635
+ {
1636
+ name: "block-gh-pipeline",
1637
+ description: "Block gh CLI pipeline-trigger subcommands (workflow run, run rerun/cancel, pr merge, release create/delete, cache delete, secret set/delete)",
1638
+ fn: blockGhPipeline,
1639
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1640
+ defaultEnabled: false,
1641
+ category: "Infra Commands",
1642
+ params: {
1643
+ allowPatterns: {
1644
+ type: "string[]",
1645
+ description: "gh pipeline command patterns to allow (e.g. specific scripted invocations); read-only gh subcommands like 'gh pr view' and 'gh run list' are not matched by this policy",
1646
+ default: [],
1647
+ },
1648
+ } satisfies PolicyParamsSchema,
1649
+ },
1498
1650
  {
1499
1651
  name: "block-secrets-write",
1500
1652
  description: "Block writing secret key files",
@@ -17,6 +17,7 @@ import { homedir } from "node:os";
17
17
  import { hookLogWarn, hookLogError, hookLogInfo } from "./hook-logger";
18
18
  import { getCustomHooks, clearCustomHooks } from "./custom-hooks-registry";
19
19
  import { findDistIndex, rewriteFileTree, TMP_SUFFIX, cleanupTmpFiles } from "./loader-utils";
20
+ import { findProjectConfigDir } from "./hooks-config";
20
21
  import type { CustomHook } from "./policy-types";
21
22
 
22
23
  const LOADING_KEY = "__FAILPROOFAI_LOADING_HOOKS__";
@@ -126,11 +127,13 @@ export async function loadAllCustomHooks(
126
127
 
127
128
  const conventionSources: ConventionSource[] = [];
128
129
 
130
+ const projectRoot = findProjectConfigDir(opts?.sessionCwd ?? process.cwd());
131
+
129
132
  // 1. Explicit customPoliciesPath (existing behavior)
130
133
  if (customPoliciesPath) {
131
134
  const absPath = isAbsolute(customPoliciesPath)
132
135
  ? customPoliciesPath
133
- : resolve(opts?.sessionCwd ?? process.cwd(), customPoliciesPath);
136
+ : resolve(projectRoot, customPoliciesPath);
134
137
  if (existsSync(absPath)) {
135
138
  await loadSingleFile(absPath);
136
139
  } else {
@@ -140,8 +143,8 @@ export async function loadAllCustomHooks(
140
143
 
141
144
  const hooksBeforeConvention = getCustomHooks().length;
142
145
 
143
- // 2. Project convention: {cwd}/.failproofai/policies/*policies.{js,mjs,ts}
144
- const projectDir = resolve(opts?.sessionCwd ?? process.cwd(), ".failproofai", "policies");
146
+ // 2. Project convention: {projectRoot}/.failproofai/policies/*policies.{js,mjs,ts}
147
+ const projectDir = resolve(projectRoot, ".failproofai", "policies");
145
148
  const projectFiles = discoverPolicyFiles(projectDir);
146
149
  for (const file of projectFiles) {
147
150
  const hooksBefore = getCustomHooks().length;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Read/write the hooks configuration file at ~/.failproofai/policies-config.json.
3
3
  */
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } 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";
@@ -19,6 +19,33 @@ function readConfigAt(path: string): Partial<HooksConfig> {
19
19
  }
20
20
  }
21
21
 
22
+ /**
23
+ * Walk up from `start` until a `.failproofai/` directory is found, and return that
24
+ * dir as the project root. Stops at homedir (the global `~/.failproofai/` is not a
25
+ * project root) or filesystem root. If no marker is found, returns the original
26
+ * `start` so callers fall through to the global-only config merge.
27
+ *
28
+ * Fixes #200: when Claude Code's Bash tool drifts CWD into a subdirectory, the
29
+ * project policy config was silently missed because we resolved it at the exact
30
+ * cwd instead of walking up.
31
+ */
32
+ export function findProjectConfigDir(start: string): string {
33
+ const home = homedir();
34
+ let dir = resolve(start);
35
+ while (dir !== home) {
36
+ const marker = resolve(dir, ".failproofai");
37
+ try {
38
+ if (statSync(marker).isDirectory()) return dir;
39
+ } catch {
40
+ // not present or unreadable — keep walking
41
+ }
42
+ const parent = dirname(dir);
43
+ if (parent === dir) break;
44
+ dir = parent;
45
+ }
46
+ return resolve(start);
47
+ }
48
+
22
49
  /**
23
50
  * Read and merge hooks config from three scopes in priority order:
24
51
  * 1. {cwd}/.failproofai/policies-config.json (project)
@@ -32,7 +59,7 @@ function readConfigAt(path: string): Partial<HooksConfig> {
32
59
  * llm: first scope that defines it wins
33
60
  */
34
61
  export function readMergedHooksConfig(cwd?: string): HooksConfig {
35
- const base = cwd ? resolve(cwd) : process.cwd();
62
+ const base = findProjectConfigDir(cwd ?? process.cwd());
36
63
  const projectPath = resolve(base, ".failproofai", "policies-config.json");
37
64
  const localPath = resolve(base, ".failproofai", "policies-config.local.json");
38
65
  const globalPath = resolve(homedir(), ".failproofai", "policies-config.json");
@@ -105,14 +132,13 @@ export function writeHooksConfig(config: HooksConfig): void {
105
132
  * Resolve the policies-config path for a specific scope.
106
133
  */
107
134
  export function getConfigPathForScope(scope: HookScope, cwd?: string): string {
108
- const base = cwd ? resolve(cwd) : process.cwd();
109
135
  switch (scope) {
110
136
  case "user":
111
137
  return resolve(homedir(), ".failproofai", "policies-config.json");
112
138
  case "project":
113
- return resolve(base, ".failproofai", "policies-config.json");
139
+ return resolve(findProjectConfigDir(cwd ?? process.cwd()), ".failproofai", "policies-config.json");
114
140
  case "local":
115
- return resolve(base, ".failproofai", "policies-config.local.json");
141
+ return resolve(findProjectConfigDir(cwd ?? process.cwd()), ".failproofai", "policies-config.local.json");
116
142
  }
117
143
  }
118
144