failproofai 0.0.6 → 0.0.8

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 (87) 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]__0t3ka1q._.js → [root-of-the-server]__0_rr1ty._.js} +2 -2
  54. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
  55. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
  56. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ow37ro._.js → [root-of-the-server]__0h3orxc._.js} +2 -2
  57. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0okos0k._.js +2 -2
  58. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +7 -7
  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/{061hxr2b-j.6q.js → 096~b1zwv69ph.js} +1 -1
  70. package/.next/standalone/.next/static/chunks/{0k5t-n0s8p2nr.js → 0eowehbf5egcz.js} +1 -1
  71. package/.next/standalone/.next/static/chunks/{0ubv3x~0zdd_w.js → 0lua3p__elu_..js} +1 -1
  72. package/.next/standalone/.next/static/chunks/{13cot7j99xkb~.js → 0mbc8hyeqe2c4.js} +1 -1
  73. package/.next/standalone/.next/static/chunks/0s_18.dox44e9.js +1 -0
  74. package/.next/standalone/.next/static/chunks/{0-igg2k65fzo_.js → 0t3euwspxi_zg.js} +1 -1
  75. package/.next/standalone/.next/static/chunks/{0lq8ary5l4s8t.js → 151bdxm9n-pry.js} +1 -1
  76. package/.next/standalone/.next/static/chunks/{0v23ca5xty1n~.js → 175-vim0.ztb2.js} +2 -2
  77. package/.next/standalone/package.json +1 -1
  78. package/.next/standalone/server.js +1 -1
  79. package/dist/cli.mjs +204 -62
  80. package/package.json +1 -1
  81. package/src/hooks/builtin-policies.ts +206 -71
  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/chunks/04iuhj_-h-21-.js +0 -1
  85. /package/.next/standalone/.next/static/{my01WPjry7ohRUHyTaYp4 → RYld7TSCDXm2_WhJq20rD}/_buildManifest.js +0 -0
  86. /package/.next/standalone/.next/static/{my01WPjry7ohRUHyTaYp4 → RYld7TSCDXm2_WhJq20rD}/_clientMiddlewareManifest.js +0 -0
  87. /package/.next/standalone/.next/static/{my01WPjry7ohRUHyTaYp4 → RYld7TSCDXm2_WhJq20rD}/_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.
@@ -1147,36 +1204,19 @@ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult {
1147
1204
  return allow(`PR #${pr.number} exists: ${pr.url}`);
1148
1205
  }
1149
1206
 
1150
- // PR is merged/closed. The earlier origin/{baseBranch} checks may have
1151
- // used a stale ref. Fetch and re-verify before denying.
1207
+ // Trust GitHub's authoritative state. Local-ref reconciliation can never
1208
+ // converge after squash-merge or rebase-merge (the original branch commit
1209
+ // is orphaned, never an ancestor of base) or when base is auto-modified
1210
+ // post-merge (e.g. release-workflow version bumps). The PR being MERGED
1211
+ // is itself the proof that the work shipped.
1152
1212
  if (pr.state === "MERGED") {
1153
- try {
1154
- execFileSync("git", ["fetch", "origin", `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`], {
1155
- cwd,
1156
- encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
1157
- timeout: 10000,
1158
- });
1159
- const freshAhead = execFileSync(
1160
- "git",
1161
- ["log", `origin/${baseBranch}..HEAD`, "--oneline"],
1162
- { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 },
1163
- ).trim();
1164
- if (!freshAhead) {
1165
- return allow(`PR #${pr.number} was merged; branch is up to date with ${baseBranch}.`);
1166
- }
1167
- const freshDiff = execFileSync(
1168
- "git",
1169
- ["diff", "--stat", `origin/${baseBranch}`, "HEAD"],
1170
- { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 },
1171
- ).trim();
1172
- if (!freshDiff) {
1173
- return allow(`PR #${pr.number} was merged; no file changes vs ${baseBranch}.`);
1174
- }
1175
- } catch {
1176
- // Fetch or git command failed — fall through to deny
1177
- }
1213
+ return allow(
1214
+ `PR #${pr.number} was merged: ${pr.url}. ` +
1215
+ `Switch off this branch (e.g. 'git checkout ${baseBranch} && git pull') before stopping again.`,
1216
+ );
1178
1217
  }
1179
1218
 
1219
+ // Reaches here only for CLOSED-without-merge — PR was rejected.
1180
1220
  return deny(
1181
1221
  `Pull request for branch "${branch}" is ${pr.state.toLowerCase()}. Run now: gh pr create`,
1182
1222
  );
@@ -1197,8 +1237,37 @@ function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult {
1197
1237
  return allow(`On base branch "${baseBranch}", skipping conflict check.`);
1198
1238
  }
1199
1239
 
1240
+ // -- Precheck: only enforce when an OPEN PR exists on GitHub. Without a
1241
+ // confirmable merge target there is nothing to enforce, so we skip both
1242
+ // the local merge-tree probe and the GitHub mergeability probe.
1243
+ try {
1244
+ execSync("gh --version", { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 });
1245
+ } catch {
1246
+ return allow("gh CLI not installed, skipping conflict check.");
1247
+ }
1248
+
1249
+ let prJson: string;
1250
+ try {
1251
+ prJson = execSync("gh pr view --json mergeable,number,url,state", {
1252
+ cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 15000,
1253
+ }).trim();
1254
+ } catch {
1255
+ return allow("No pull request found for branch, skipping conflict check.");
1256
+ }
1257
+
1258
+ let pr: { mergeable: string; number: number; url: string; state: string };
1259
+ try {
1260
+ pr = JSON.parse(prJson);
1261
+ } catch {
1262
+ return allow("Could not parse gh pr view output, skipping conflict check.");
1263
+ }
1264
+
1265
+ // GitHub stops computing mergeability for non-OPEN PRs (returns UNKNOWN forever).
1266
+ if (pr.state !== "OPEN") {
1267
+ return allow(`PR #${pr.number} is ${pr.state.toLowerCase()}; skipping conflict check.`);
1268
+ }
1269
+
1200
1270
  // -- Layer 1: local git merge-tree --
1201
- let localSkipped = false;
1202
1271
  try {
1203
1272
  execFileSync("git", ["rev-parse", "--verify", `origin/${baseBranch}`], {
1204
1273
  cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000,
@@ -1209,17 +1278,14 @@ function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult {
1209
1278
  { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 },
1210
1279
  ).trim();
1211
1280
 
1212
- if (!ahead) {
1213
- // Nothing ahead of base — Layer 1 doesn't apply, fall through to Layer 2.
1214
- localSkipped = true;
1215
- } else {
1281
+ if (ahead) {
1216
1282
  execFileSync(
1217
1283
  "git",
1218
1284
  ["merge-tree", "--write-tree", "--name-only", `origin/${baseBranch}`, "HEAD"],
1219
1285
  { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 10000 },
1220
1286
  );
1221
- // exit 0 → clean merge, fall through to Layer 2
1222
1287
  }
1288
+ // !ahead or merge-tree exit 0 → fall through to Layer 2
1223
1289
  } catch (err) {
1224
1290
  const e = err as { status?: number; stdout?: string | Buffer };
1225
1291
  if (e.status === 1) {
@@ -1238,46 +1304,10 @@ function requireNoConflictsBeforeStop(ctx: PolicyContext): PolicyResult {
1238
1304
  `Rebase or merge origin/${baseBranch} now and resolve the conflicts.`,
1239
1305
  );
1240
1306
  }
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.`);
1307
+ // any other failure (e.g. missing origin/<base>, log failure) → fall through
1279
1308
  }
1280
1309
 
1310
+ // -- Layer 2: GitHub PR mergeability (reuses pr from precheck) --
1281
1311
  if (pr.mergeable === "CONFLICTING") {
1282
1312
  return deny(
1283
1313
  `PR #${pr.number} has merge conflicts per GitHub (${pr.url}). ` +
@@ -1495,6 +1525,111 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1495
1525
  defaultEnabled: true,
1496
1526
  category: "Dangerous Commands",
1497
1527
  },
1528
+ {
1529
+ name: "block-kubectl",
1530
+ description: "Block kubectl commands (Kubernetes cluster mutations)",
1531
+ fn: blockKubectl,
1532
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1533
+ defaultEnabled: false,
1534
+ category: "Infra Commands",
1535
+ params: {
1536
+ allowPatterns: {
1537
+ type: "string[]",
1538
+ description: "kubectl command patterns to allow, matched token-by-token (e.g. 'kubectl get *', 'kubectl describe *')",
1539
+ default: [],
1540
+ },
1541
+ } satisfies PolicyParamsSchema,
1542
+ },
1543
+ {
1544
+ name: "block-terraform",
1545
+ description: "Block terraform and tofu (OpenTofu) commands",
1546
+ fn: blockTerraform,
1547
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1548
+ defaultEnabled: false,
1549
+ category: "Infra Commands",
1550
+ params: {
1551
+ allowPatterns: {
1552
+ type: "string[]",
1553
+ description: "terraform/tofu command patterns to allow (e.g. 'terraform plan', 'terraform validate')",
1554
+ default: [],
1555
+ },
1556
+ } satisfies PolicyParamsSchema,
1557
+ },
1558
+ {
1559
+ name: "block-aws-cli",
1560
+ description: "Block aws CLI commands",
1561
+ fn: blockAwsCli,
1562
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1563
+ defaultEnabled: false,
1564
+ category: "Infra Commands",
1565
+ params: {
1566
+ allowPatterns: {
1567
+ type: "string[]",
1568
+ description: "aws CLI command patterns to allow (e.g. 'aws s3 ls *', 'aws sts get-caller-identity')",
1569
+ default: [],
1570
+ },
1571
+ } satisfies PolicyParamsSchema,
1572
+ },
1573
+ {
1574
+ name: "block-gcloud",
1575
+ description: "Block gcloud (Google Cloud) CLI commands",
1576
+ fn: blockGcloud,
1577
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1578
+ defaultEnabled: false,
1579
+ category: "Infra Commands",
1580
+ params: {
1581
+ allowPatterns: {
1582
+ type: "string[]",
1583
+ description: "gcloud command patterns to allow (e.g. 'gcloud auth list', 'gcloud config list')",
1584
+ default: [],
1585
+ },
1586
+ } satisfies PolicyParamsSchema,
1587
+ },
1588
+ {
1589
+ name: "block-az-cli",
1590
+ description: "Block az (Azure) CLI commands",
1591
+ fn: blockAzCli,
1592
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1593
+ defaultEnabled: false,
1594
+ category: "Infra Commands",
1595
+ params: {
1596
+ allowPatterns: {
1597
+ type: "string[]",
1598
+ description: "az CLI command patterns to allow (e.g. 'az account show', 'az group list')",
1599
+ default: [],
1600
+ },
1601
+ } satisfies PolicyParamsSchema,
1602
+ },
1603
+ {
1604
+ name: "block-helm",
1605
+ description: "Block helm commands",
1606
+ fn: blockHelm,
1607
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1608
+ defaultEnabled: false,
1609
+ category: "Infra Commands",
1610
+ params: {
1611
+ allowPatterns: {
1612
+ type: "string[]",
1613
+ description: "helm command patterns to allow (e.g. 'helm list', 'helm status *')",
1614
+ default: [],
1615
+ },
1616
+ } satisfies PolicyParamsSchema,
1617
+ },
1618
+ {
1619
+ name: "block-gh-pipeline",
1620
+ description: "Block gh CLI pipeline-trigger subcommands (workflow run, run rerun/cancel, pr merge, release create/delete, cache delete, secret set/delete)",
1621
+ fn: blockGhPipeline,
1622
+ match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1623
+ defaultEnabled: false,
1624
+ category: "Infra Commands",
1625
+ params: {
1626
+ allowPatterns: {
1627
+ type: "string[]",
1628
+ 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",
1629
+ default: [],
1630
+ },
1631
+ } satisfies PolicyParamsSchema,
1632
+ },
1498
1633
  {
1499
1634
  name: "block-secrets-write",
1500
1635
  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
 
@@ -1 +0,0 @@
1
- (globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,53348,e=>{"use strict";var r=e.i(43476),t=e.i(71645),n=e.i(65771),i=e.i(9969);e.s(["default",0,function({error:e,reset:o}){return(0,t.useEffect)(()=>{(0,n.getTelemetryConfig)().then(r=>{(0,i.setClientTelemetryConfig)(r),(0,i.captureClientEvent)("client_error",{error_message:e.message,error_name:e.name,error_digest:e.digest,boundary:"global"})}).catch(()=>{})},[e]),(0,r.jsx)("html",{children:(0,r.jsx)("body",{children:(0,r.jsx)("main",{style:{minHeight:"100vh",display:"flex",alignItems:"center",justifyContent:"center",background:"#031035",color:"#f8fafc",fontFamily:"system-ui, sans-serif"},children:(0,r.jsxs)("div",{style:{textAlign:"center",padding:"2rem",border:"1px solid rgba(239,68,68,0.4)",borderRadius:"0.5rem",maxWidth:"500px"},children:[(0,r.jsx)("h2",{style:{color:"#ef4444",marginBottom:"0.5rem",fontSize:"1.25rem"},children:"Something went wrong"}),(0,r.jsx)("p",{style:{color:"#94a3b8",marginBottom:"1.5rem"},children:e.message||"An unexpected error occurred."}),(0,r.jsx)("button",{onClick:o,style:{padding:"0.5rem 1.25rem",background:"#3b82f6",color:"white",border:"none",borderRadius:"0.375rem",cursor:"pointer",fontSize:"0.875rem"},children:"Try again"})]})})})})}])}]);