failproofai 0.0.2-beta.3 → 0.0.2-beta.5

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 (98) 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 +3 -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 +17 -17
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  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 +11 -11
  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 +2 -2
  26. package/.next/standalone/.next/server/app/index.html +1 -1
  27. package/.next/standalone/.next/server/app/index.rsc +16 -16
  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 +16 -16
  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 +11 -11
  32. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  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]__02nt~6d._.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]__07k6eu-._.js → [root-of-the-server]__00_.atk._.js} +2 -2
  52. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
  53. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.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]__0kfv9fw._.js → [root-of-the-server]__0gw4qdj._.js} +2 -2
  56. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.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 +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/{0p_fpyfmmohnx.js → 040il49xqyq~j.js} +1 -1
  70. package/.next/standalone/.next/static/chunks/{0t94r_mk0s7e4.js → 0d-hv1uc827s6.js} +2 -2
  71. package/.next/standalone/.next/static/chunks/{139~00zc9.u7s.js → 0ezymnwrt2x6i.js} +1 -1
  72. package/.next/standalone/.next/static/chunks/{01haq0a3zrx0v.js → 0gleuaabeolm~.js} +1 -1
  73. package/.next/standalone/.next/static/chunks/{0.jo.465b6_k..js → 0issdwvmb81z_.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/0jf9lx3rkmqx_.css +1 -0
  75. package/.next/standalone/.next/static/chunks/{0mq7ze1vkeo1p.js → 0odv81fzkn6u~.js} +1 -1
  76. package/.next/standalone/.next/static/chunks/{0qwyj3m400l_g.js → 10uhv8kh~ad6m.js} +2 -2
  77. package/.next/standalone/.next/static/chunks/{0a6xi1a8f_qlp.js → 14ee68i9dy9b3.js} +1 -1
  78. package/.next/standalone/app/components/session-hooks-panel.tsx +14 -1
  79. package/.next/standalone/app/policies/hooks-client.tsx +14 -1
  80. package/.next/standalone/components/navbar.tsx +5 -0
  81. package/.next/standalone/dist/cli.mjs +54 -21
  82. package/.next/standalone/next.config.ts +5 -3
  83. package/.next/standalone/package.json +1 -1
  84. package/.next/standalone/server.js +1 -1
  85. package/.next/standalone/src/hooks/builtin-policies.ts +54 -5
  86. package/.next/standalone/src/hooks/handler.ts +1 -0
  87. package/.next/standalone/src/hooks/hook-activity-store.ts +6 -1
  88. package/.next/standalone/src/hooks/policy-evaluator.ts +16 -13
  89. package/dist/cli.mjs +54 -21
  90. package/package.json +1 -1
  91. package/src/hooks/builtin-policies.ts +54 -5
  92. package/src/hooks/handler.ts +1 -0
  93. package/src/hooks/hook-activity-store.ts +6 -1
  94. package/src/hooks/policy-evaluator.ts +16 -13
  95. package/.next/standalone/.next/static/chunks/08f78tecvx61l.css +0 -1
  96. /package/.next/standalone/.next/static/{7fR022u1Sj-s5MfKO1q9Y → p7b7Yk0VOBDjbtr1aHDyV}/_buildManifest.js +0 -0
  97. /package/.next/standalone/.next/static/{7fR022u1Sj-s5MfKO1q9Y → p7b7Yk0VOBDjbtr1aHDyV}/_clientMiddlewareManifest.js +0 -0
  98. /package/.next/standalone/.next/static/{7fR022u1Sj-s5MfKO1q9Y → p7b7Yk0VOBDjbtr1aHDyV}/_ssgManifest.js +0 -0
@@ -496,7 +496,8 @@ function rmTargetIsAllowed(cmd, allowPaths) {
496
496
  if (rmIdx < 0)
497
497
  continue;
498
498
  const flagTokens = tokens.slice(rmIdx + 1).filter((t) => /^-[^-]/.test(t));
499
- if (!/r/i.test(flagTokens.join("")))
499
+ const longFlagsInSeg = tokens.slice(rmIdx + 1).filter((t) => /^--/.test(t));
500
+ if (!/r/i.test(flagTokens.join("")) && !longFlagsInSeg.some((f) => /^--recursive$/i.test(f)))
500
501
  continue;
501
502
  const pathArgs = tokens.slice(rmIdx + 1).filter((t) => !t.startsWith("-"));
502
503
  for (const target of pathArgs) {
@@ -521,7 +522,10 @@ function blockRmRf(ctx) {
521
522
  if (ctx.toolName !== "Bash")
522
523
  return allow();
523
524
  const cmd = getCommand(ctx);
524
- const hasDestructivePath = /(?:\/\s*$|\/\*|~)/.test(cmd);
525
+ const hasDestructivePath = parseArgvTokens(cmd).some((token) => {
526
+ const normalized = token.replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
527
+ return normalized === "/" || normalized === "~" || /^\/[A-Za-z_][\w.-]*$/.test(normalized);
528
+ });
525
529
  if (hasDestructivePath && (/rm\s+-[^\s]*r[^\s]*f[^\s]*/.test(cmd) || /rm\s+-[^\s]*f[^\s]*r[^\s]*/.test(cmd))) {
526
530
  const allowPaths = ctx.params?.allowPaths ?? [];
527
531
  if (rmTargetIsAllowed(cmd, allowPaths))
@@ -531,7 +535,10 @@ function blockRmRf(ctx) {
531
535
  if (hasDestructivePath && /\brm\b/.test(cmd)) {
532
536
  const tokens = parseArgvTokens(cmd);
533
537
  const shortFlags = tokens.filter((t) => /^-[^-]/.test(t)).join("");
534
- if (/r/i.test(shortFlags) && /f/.test(shortFlags)) {
538
+ const longFlags = tokens.filter((t) => /^--/.test(t));
539
+ const hasRecursive = /r/i.test(shortFlags) || longFlags.some((f) => /^--recursive$/i.test(f));
540
+ const hasForce = /f/.test(shortFlags) || longFlags.some((f) => /^--force$/i.test(f));
541
+ if (hasRecursive && hasForce) {
535
542
  const allowPaths = ctx.params?.allowPaths ?? [];
536
543
  if (rmTargetIsAllowed(cmd, allowPaths))
537
544
  return allow();
@@ -847,6 +854,20 @@ function requirePrBeforeStop(ctx) {
847
854
  const branch = getCurrentBranch(cwd);
848
855
  if (!branch || branch === "HEAD")
849
856
  return allow("Detached HEAD, skipping PR check.");
857
+ const baseBranch = ctx.params?.baseBranch ?? "main";
858
+ if (branch === baseBranch) {
859
+ return allow(`On base branch "${baseBranch}", skipping PR check.`);
860
+ }
861
+ try {
862
+ const ahead = execFileSync("git", ["log", `origin/${baseBranch}..HEAD`, "--oneline"], { cwd, encoding: "utf8", timeout: 5000 }).trim();
863
+ if (!ahead) {
864
+ return allow(`No commits ahead of origin/${baseBranch}, skipping PR check.`);
865
+ }
866
+ const diff = execFileSync("git", ["diff", "--stat", `origin/${baseBranch}`, "HEAD"], { cwd, encoding: "utf8", timeout: 5000 }).trim();
867
+ if (!diff) {
868
+ return allow(`No file changes compared to origin/${baseBranch}, skipping PR check.`);
869
+ }
870
+ } catch {}
850
871
  let prJson;
851
872
  try {
852
873
  prJson = execSync("gh pr view --json number,url,state", {
@@ -939,7 +960,7 @@ var init_builtin_policies = __esm(() => {
939
960
  SCHEMA_ALTER_RE = /\bALTER\s+TABLE\b[\s\S]*\b(?:DROP\s+COLUMN|ADD\s+COLUMN|RENAME\s+(?:COLUMN|TO)|MODIFY\s+COLUMN)\b/i;
940
961
  PUBLISH_CMD_RE = /(?:npm\s+publish|bun\s+publish|pnpm\s+publish|yarn\s+npm\s+publish|twine\s+upload|poetry\s+publish|cargo\s+publish|gem\s+push)\b/;
941
962
  ENV_PRINTENV_RE = /(?:^|\s|;|&&|\|\|)(?:env|printenv)(?:\s|$|;|&&|\|)/;
942
- ECHO_ENV_RE = /echo\s+.*\$[A-Za-z_]/;
963
+ ECHO_ENV_RE = /echo\s+.*\$\{?[A-Za-z_]/;
943
964
  EXPORT_RE = /(?:^|\s|;|&&|\|\|)export\s+\w+/;
944
965
  PS_ENV_VAR_RE = /\$env:[A-Za-z_]/i;
945
966
  PS_CHILDITEM_ENV_RE = /(?:Get-ChildItem|dir|gci|ls)\s+Env:/i;
@@ -950,7 +971,7 @@ var init_builtin_policies = __esm(() => {
950
971
  SUDO_RE = /(?:^|;|&&|\|\|)\s*sudo\s/;
951
972
  PS_ELEVATION_RE = /Start-Process\s+.*-Verb\s+RunAs/i;
952
973
  RUNAS_RE = /(?:^|;|&&|\|\|)\s*runas\s/i;
953
- CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh)/;
974
+ CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh|dash|ksh|csh|tcsh|fish|ash)\b/;
954
975
  PS_WEB_PIPE_RE = /(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm)\s+.*\|\s*(?:Invoke-Expression|iex)/i;
955
976
  FORCE_PUSH_RE = /(?:--force|-f\b)/;
956
977
  SECRET_FILE_RE = /\.(?:pem|key)$/;
@@ -1272,7 +1293,14 @@ var init_builtin_policies = __esm(() => {
1272
1293
  match: { events: ["Stop"] },
1273
1294
  defaultEnabled: false,
1274
1295
  category: "Workflow",
1275
- beta: true
1296
+ beta: true,
1297
+ params: {
1298
+ baseBranch: {
1299
+ type: "string",
1300
+ description: "Base branch to compare against (default: main)",
1301
+ default: "main"
1302
+ }
1303
+ }
1276
1304
  },
1277
1305
  {
1278
1306
  name: "require-ci-green-before-stop",
@@ -1304,7 +1332,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
1304
1332
  };
1305
1333
  let instructPolicyName = null;
1306
1334
  let instructReason = null;
1307
- const allowMessages = [];
1335
+ const allowEntries = [];
1308
1336
  for (const policy of policies) {
1309
1337
  const schema = POLICY_PARAMS_MAP.get(policy.name);
1310
1338
  let ctx;
@@ -1365,7 +1393,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
1365
1393
  return {
1366
1394
  exitCode: 2,
1367
1395
  stdout: "",
1368
- stderr: "",
1396
+ stderr: reason,
1369
1397
  policyName: policy.name,
1370
1398
  reason,
1371
1399
  decision: "deny"
@@ -1377,7 +1405,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
1377
1405
  hookLogInfo(`instruct by "${policy.name}": ${instructReason}`);
1378
1406
  }
1379
1407
  if (result.decision === "allow" && result.reason) {
1380
- allowMessages.push(result.reason);
1408
+ allowEntries.push({ policyName: policy.name, reason: result.reason });
1381
1409
  }
1382
1410
  }
1383
1411
  if (instructPolicyName && instructReason) {
@@ -1406,16 +1434,16 @@ async function evaluatePolicies(eventType, payload, session, config) {
1406
1434
  decision: "instruct"
1407
1435
  };
1408
1436
  }
1409
- if (allowMessages.length > 0) {
1410
- const combined = allowMessages.join(`
1437
+ if (allowEntries.length > 0) {
1438
+ const combined = allowEntries.map((e) => e.reason).join(`
1411
1439
  `);
1412
- const response = {
1413
- hookSpecificOutput: {
1414
- hookEventName: eventType,
1415
- additionalContext: combined
1416
- }
1417
- };
1418
- return { exitCode: 0, stdout: JSON.stringify(response), stderr: "", policyName: null, reason: combined, decision: "allow" };
1440
+ const policyNames = allowEntries.map((e) => e.policyName);
1441
+ const supportsHookSpecificOutput = eventType === "PreToolUse" || eventType === "PostToolUse" || eventType === "UserPromptSubmit";
1442
+ const response = supportsHookSpecificOutput ? { hookSpecificOutput: { hookEventName: eventType, additionalContext: `Note from failproofai: ${combined}` } } : { reason: combined };
1443
+ const stderrMsg = allowEntries.map((e) => `[failproofai] ${e.policyName}: ${e.reason}`).join(`
1444
+ `);
1445
+ return { exitCode: 0, stdout: JSON.stringify(response), stderr: stderrMsg + `
1446
+ `, policyName: policyNames[0], policyNames, reason: combined, decision: "allow" };
1419
1447
  }
1420
1448
  return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
1421
1449
  }
@@ -1711,7 +1739,11 @@ function updateStats(entry) {
1711
1739
  s.totalEvents += 1;
1712
1740
  if (entry.decision === "deny")
1713
1741
  s.denyCount += 1;
1714
- if (entry.policyName) {
1742
+ if (entry.policyNames && entry.policyNames.length > 0) {
1743
+ for (const name of entry.policyNames) {
1744
+ s.policyMap[name] = (s.policyMap[name] ?? 0) + 1;
1745
+ }
1746
+ } else if (entry.policyName) {
1715
1747
  s.policyMap[entry.policyName] = (s.policyMap[entry.policyName] ?? 0) + 1;
1716
1748
  }
1717
1749
  const tmpPath = join3(storeDir, `stats.json.${process.pid}.tmp`);
@@ -1731,7 +1763,7 @@ var init_hook_activity_store = __esm(() => {
1731
1763
  });
1732
1764
 
1733
1765
  // package.json
1734
- var version2 = "0.0.2-beta.3";
1766
+ var version2 = "0.0.2-beta.5";
1735
1767
  var init_package = () => {};
1736
1768
 
1737
1769
  // src/posthog-key.ts
@@ -1940,6 +1972,7 @@ async function handleHookEvent(eventType) {
1940
1972
  eventType,
1941
1973
  toolName: parsed.tool_name ?? null,
1942
1974
  policyName: result.policyName,
1975
+ policyNames: result.policyNames,
1943
1976
  decision: result.decision,
1944
1977
  reason: result.reason,
1945
1978
  durationMs,
@@ -2957,7 +2990,7 @@ import { realpathSync as realpathSync2 } from "fs";
2957
2990
  import { dirname as dirname5, resolve as resolve8 } from "path";
2958
2991
  import { fileURLToPath as fileURLToPath2 } from "url";
2959
2992
  // package.json
2960
- var version = "0.0.2-beta.3";
2993
+ var version = "0.0.2-beta.5";
2961
2994
 
2962
2995
  // bin/failproofai.mjs
2963
2996
  if (!process.env.FAILPROOFAI_PACKAGE_ROOT) {
@@ -1,4 +1,8 @@
1
1
  import type { NextConfig } from "next";
2
+ import { readFileSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
2
6
 
3
7
  const allowedDevOrigins = process.env.FAILPROOFAI_ALLOWED_DEV_ORIGINS
4
8
  ? process.env.FAILPROOFAI_ALLOWED_DEV_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean)
@@ -19,9 +23,7 @@ const nextConfig: NextConfig = {
19
23
  return config;
20
24
  },
21
25
  env: {
22
- // Expose CLAUDE_PROJECTS_PATH to the client-side if needed
23
- // Note: Only use this if you need it on the client side
24
- // For server-side only, you can access it via process.env.CLAUDE_PROJECTS_PATH
26
+ NEXT_PUBLIC_APP_VERSION: pkg.version,
25
27
  },
26
28
  };
27
29
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.2-beta.3",
3
+ "version": "0.0.2-beta.5",
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"
@@ -9,7 +9,7 @@ const currentPort = parseInt(process.env.PORT, 10) || 3000
9
9
  const hostname = process.env.HOSTNAME || '0.0.0.0'
10
10
 
11
11
  let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10)
12
- const nextConfig = {"env":{},"typescript":{"ignoreBuildErrors":false},"typedRoutes":false,"distDir":"./.next","cleanDistDir":true,"assetPrefix":"","cacheMaxMemorySize":52428800,"configOrigin":"next.config.ts","useFileSystemPublicRoutes":true,"generateEtags":true,"pageExtensions":["tsx","ts","jsx","js"],"poweredByHeader":true,"compress":true,"images":{"deviceSizes":[640,750,828,1080,1200,1920,2048,3840],"imageSizes":[32,48,64,96,128,256,384],"path":"/_next/image","loader":"default","loaderFile":"","domains":[],"disableStaticImages":false,"minimumCacheTTL":14400,"formats":["image/webp"],"maximumRedirects":3,"maximumResponseBody":50000000,"dangerouslyAllowLocalIP":false,"dangerouslyAllowSVG":false,"contentSecurityPolicy":"script-src 'none'; frame-src 'none'; sandbox;","contentDispositionType":"attachment","localPatterns":[{"pathname":"**","search":""}],"remotePatterns":[],"qualities":[75],"unoptimized":true,"customCacheHandler":false},"devIndicators":{"position":"bottom-left"},"onDemandEntries":{"maxInactiveAge":60000,"pagesBufferLength":5},"basePath":"","sassOptions":{},"trailingSlash":false,"i18n":null,"productionBrowserSourceMaps":false,"excludeDefaultMomentLocales":true,"reactProductionProfiling":false,"reactStrictMode":null,"reactMaxHeadersLength":6000,"httpAgentOptions":{"keepAlive":true},"logging":{"serverFunctions":true,"browserToTerminal":"warn"},"compiler":{},"expireTime":31536000,"staticPageGenerationTimeout":60,"output":"standalone","modularizeImports":{"@mui/icons-material":{"transform":"@mui/icons-material/{{member}}"},"lodash":{"transform":"lodash/{{member}}"}},"outputFileTracingRoot":"/home/runner/work/failproofai/failproofai","cacheComponents":false,"cacheLife":{"default":{"stale":300,"revalidate":900,"expire":4294967294},"seconds":{"stale":30,"revalidate":1,"expire":60},"minutes":{"stale":300,"revalidate":60,"expire":3600},"hours":{"stale":300,"revalidate":3600,"expire":86400},"days":{"stale":300,"revalidate":86400,"expire":604800},"weeks":{"stale":300,"revalidate":604800,"expire":2592000},"max":{"stale":300,"revalidate":2592000,"expire":31536000}},"cacheHandlers":{},"experimental":{"appNewScrollHandler":false,"useSkewCookie":false,"cssChunking":true,"multiZoneDraftMode":false,"appNavFailHandling":false,"prerenderEarlyExit":true,"serverMinification":true,"linkNoTouchStart":false,"caseSensitiveRoutes":false,"cachedNavigations":false,"partialFallbacks":false,"dynamicOnHover":false,"varyParams":false,"prefetchInlining":false,"preloadEntriesOnStart":true,"clientRouterFilter":true,"clientRouterFilterRedirects":false,"fetchCacheKeyPrefix":"","proxyPrefetch":"flexible","optimisticClientCache":true,"manualClientBasePath":false,"cpus":3,"memoryBasedWorkersCount":false,"imgOptConcurrency":null,"imgOptTimeoutInSeconds":7,"imgOptMaxInputPixels":268402689,"imgOptSequentialRead":null,"imgOptSkipMetadata":null,"isrFlushToDisk":true,"workerThreads":false,"optimizeCss":false,"nextScriptWorkers":false,"scrollRestoration":false,"externalDir":false,"disableOptimizedLoading":false,"gzipSize":true,"craCompat":false,"esmExternals":true,"fullySpecified":false,"swcTraceProfiling":false,"forceSwcTransforms":false,"largePageDataBytes":128000,"typedEnv":false,"parallelServerCompiles":false,"parallelServerBuildTraces":false,"ppr":false,"authInterrupts":false,"webpackMemoryOptimizations":false,"optimizeServerReact":true,"strictRouteTypes":false,"viewTransition":false,"removeUncaughtErrorAndRejectionListeners":false,"validateRSCRequestHeaders":false,"staleTimes":{"dynamic":0,"static":300},"reactDebugChannel":true,"serverComponentsHmrCache":true,"staticGenerationMaxConcurrency":8,"staticGenerationMinPagesPerWorker":25,"transitionIndicator":false,"gestureTransition":false,"inlineCss":false,"useCache":false,"globalNotFound":false,"browserDebugInfoInTerminal":"warn","lockDistDir":true,"proxyClientMaxBodySize":10485760,"hideLogsAfterAbort":false,"mcpServer":true,"turbopackFileSystemCacheForDev":true,"turbopackFileSystemCacheForBuild":false,"turbopackInferModuleSideEffects":true,"turbopackPluginRuntimeStrategy":"childProcesses","optimizePackageImports":["lucide-react","date-fns","lodash-es","ramda","antd","react-bootstrap","ahooks","@ant-design/icons","@headlessui/react","@headlessui-float/react","@heroicons/react/20/solid","@heroicons/react/24/solid","@heroicons/react/24/outline","@visx/visx","@tremor/react","rxjs","@mui/material","@mui/icons-material","recharts","react-use","effect","@effect/schema","@effect/platform","@effect/platform-node","@effect/platform-browser","@effect/platform-bun","@effect/sql","@effect/sql-mssql","@effect/sql-mysql2","@effect/sql-pg","@effect/sql-sqlite-node","@effect/sql-sqlite-bun","@effect/sql-sqlite-wasm","@effect/sql-sqlite-react-native","@effect/rpc","@effect/rpc-http","@effect/typeclass","@effect/experimental","@effect/opentelemetry","@material-ui/core","@material-ui/icons","@tabler/icons-react","mui-core","react-icons/ai","react-icons/bi","react-icons/bs","react-icons/cg","react-icons/ci","react-icons/di","react-icons/fa","react-icons/fa6","react-icons/fc","react-icons/fi","react-icons/gi","react-icons/go","react-icons/gr","react-icons/hi","react-icons/hi2","react-icons/im","react-icons/io","react-icons/io5","react-icons/lia","react-icons/lib","react-icons/lu","react-icons/md","react-icons/pi","react-icons/ri","react-icons/rx","react-icons/si","react-icons/sl","react-icons/tb","react-icons/tfi","react-icons/ti","react-icons/vsc","react-icons/wi"],"trustHostHeader":false,"isExperimentalCompile":false},"htmlLimitedBots":"[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight","bundlePagesRouterDependencies":false,"configFileName":"next.config.ts","turbopack":{"root":"/home/runner/work/failproofai/failproofai"},"distDirRoot":".next"}
12
+ const nextConfig = {"env":{"NEXT_PUBLIC_APP_VERSION":"0.0.2-beta.5"},"typescript":{"ignoreBuildErrors":false},"typedRoutes":false,"distDir":"./.next","cleanDistDir":true,"assetPrefix":"","cacheMaxMemorySize":52428800,"configOrigin":"next.config.ts","useFileSystemPublicRoutes":true,"generateEtags":true,"pageExtensions":["tsx","ts","jsx","js"],"poweredByHeader":true,"compress":true,"images":{"deviceSizes":[640,750,828,1080,1200,1920,2048,3840],"imageSizes":[32,48,64,96,128,256,384],"path":"/_next/image","loader":"default","loaderFile":"","domains":[],"disableStaticImages":false,"minimumCacheTTL":14400,"formats":["image/webp"],"maximumRedirects":3,"maximumResponseBody":50000000,"dangerouslyAllowLocalIP":false,"dangerouslyAllowSVG":false,"contentSecurityPolicy":"script-src 'none'; frame-src 'none'; sandbox;","contentDispositionType":"attachment","localPatterns":[{"pathname":"**","search":""}],"remotePatterns":[],"qualities":[75],"unoptimized":true,"customCacheHandler":false},"devIndicators":{"position":"bottom-left"},"onDemandEntries":{"maxInactiveAge":60000,"pagesBufferLength":5},"basePath":"","sassOptions":{},"trailingSlash":false,"i18n":null,"productionBrowserSourceMaps":false,"excludeDefaultMomentLocales":true,"reactProductionProfiling":false,"reactStrictMode":null,"reactMaxHeadersLength":6000,"httpAgentOptions":{"keepAlive":true},"logging":{"serverFunctions":true,"browserToTerminal":"warn"},"compiler":{},"expireTime":31536000,"staticPageGenerationTimeout":60,"output":"standalone","modularizeImports":{"@mui/icons-material":{"transform":"@mui/icons-material/{{member}}"},"lodash":{"transform":"lodash/{{member}}"}},"outputFileTracingRoot":"/home/runner/work/failproofai/failproofai","cacheComponents":false,"cacheLife":{"default":{"stale":300,"revalidate":900,"expire":4294967294},"seconds":{"stale":30,"revalidate":1,"expire":60},"minutes":{"stale":300,"revalidate":60,"expire":3600},"hours":{"stale":300,"revalidate":3600,"expire":86400},"days":{"stale":300,"revalidate":86400,"expire":604800},"weeks":{"stale":300,"revalidate":604800,"expire":2592000},"max":{"stale":300,"revalidate":2592000,"expire":31536000}},"cacheHandlers":{},"experimental":{"appNewScrollHandler":false,"useSkewCookie":false,"cssChunking":true,"multiZoneDraftMode":false,"appNavFailHandling":false,"prerenderEarlyExit":true,"serverMinification":true,"linkNoTouchStart":false,"caseSensitiveRoutes":false,"cachedNavigations":false,"partialFallbacks":false,"dynamicOnHover":false,"varyParams":false,"prefetchInlining":false,"preloadEntriesOnStart":true,"clientRouterFilter":true,"clientRouterFilterRedirects":false,"fetchCacheKeyPrefix":"","proxyPrefetch":"flexible","optimisticClientCache":true,"manualClientBasePath":false,"cpus":3,"memoryBasedWorkersCount":false,"imgOptConcurrency":null,"imgOptTimeoutInSeconds":7,"imgOptMaxInputPixels":268402689,"imgOptSequentialRead":null,"imgOptSkipMetadata":null,"isrFlushToDisk":true,"workerThreads":false,"optimizeCss":false,"nextScriptWorkers":false,"scrollRestoration":false,"externalDir":false,"disableOptimizedLoading":false,"gzipSize":true,"craCompat":false,"esmExternals":true,"fullySpecified":false,"swcTraceProfiling":false,"forceSwcTransforms":false,"largePageDataBytes":128000,"typedEnv":false,"parallelServerCompiles":false,"parallelServerBuildTraces":false,"ppr":false,"authInterrupts":false,"webpackMemoryOptimizations":false,"optimizeServerReact":true,"strictRouteTypes":false,"viewTransition":false,"removeUncaughtErrorAndRejectionListeners":false,"validateRSCRequestHeaders":false,"staleTimes":{"dynamic":0,"static":300},"reactDebugChannel":true,"serverComponentsHmrCache":true,"staticGenerationMaxConcurrency":8,"staticGenerationMinPagesPerWorker":25,"transitionIndicator":false,"gestureTransition":false,"inlineCss":false,"useCache":false,"globalNotFound":false,"browserDebugInfoInTerminal":"warn","lockDistDir":true,"proxyClientMaxBodySize":10485760,"hideLogsAfterAbort":false,"mcpServer":true,"turbopackFileSystemCacheForDev":true,"turbopackFileSystemCacheForBuild":false,"turbopackInferModuleSideEffects":true,"turbopackPluginRuntimeStrategy":"childProcesses","optimizePackageImports":["lucide-react","date-fns","lodash-es","ramda","antd","react-bootstrap","ahooks","@ant-design/icons","@headlessui/react","@headlessui-float/react","@heroicons/react/20/solid","@heroicons/react/24/solid","@heroicons/react/24/outline","@visx/visx","@tremor/react","rxjs","@mui/material","@mui/icons-material","recharts","react-use","effect","@effect/schema","@effect/platform","@effect/platform-node","@effect/platform-browser","@effect/platform-bun","@effect/sql","@effect/sql-mssql","@effect/sql-mysql2","@effect/sql-pg","@effect/sql-sqlite-node","@effect/sql-sqlite-bun","@effect/sql-sqlite-wasm","@effect/sql-sqlite-react-native","@effect/rpc","@effect/rpc-http","@effect/typeclass","@effect/experimental","@effect/opentelemetry","@material-ui/core","@material-ui/icons","@tabler/icons-react","mui-core","react-icons/ai","react-icons/bi","react-icons/bs","react-icons/cg","react-icons/ci","react-icons/di","react-icons/fa","react-icons/fa6","react-icons/fc","react-icons/fi","react-icons/gi","react-icons/go","react-icons/gr","react-icons/hi","react-icons/hi2","react-icons/im","react-icons/io","react-icons/io5","react-icons/lia","react-icons/lib","react-icons/lu","react-icons/md","react-icons/pi","react-icons/ri","react-icons/rx","react-icons/si","react-icons/sl","react-icons/tb","react-icons/tfi","react-icons/ti","react-icons/vsc","react-icons/wi"],"trustHostHeader":false,"isExperimentalCompile":false},"htmlLimitedBots":"[\\w-]+-Google|Google-[\\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight","bundlePagesRouterDependencies":false,"configFileName":"next.config.ts","turbopack":{"root":"/home/runner/work/failproofai/failproofai"},"distDirRoot":".next"}
13
13
 
14
14
  process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig)
15
15
 
@@ -86,7 +86,7 @@ const PUBLISH_CMD_RE = /(?:npm\s+publish|bun\s+publish|pnpm\s+publish|yarn\s+npm
86
86
 
87
87
  // protectEnvVars
88
88
  const ENV_PRINTENV_RE = /(?:^|\s|;|&&|\|\|)(?:env|printenv)(?:\s|$|;|&&|\|)/;
89
- const ECHO_ENV_RE = /echo\s+.*\$[A-Za-z_]/;
89
+ const ECHO_ENV_RE = /echo\s+.*\$\{?[A-Za-z_]/;
90
90
  const EXPORT_RE = /(?:^|\s|;|&&|\|\|)export\s+\w+/;
91
91
  const PS_ENV_VAR_RE = /\$env:[A-Za-z_]/i;
92
92
  const PS_CHILDITEM_ENV_RE = /(?:Get-ChildItem|dir|gci|ls)\s+Env:/i;
@@ -103,7 +103,7 @@ const PS_ELEVATION_RE = /Start-Process\s+.*-Verb\s+RunAs/i;
103
103
  const RUNAS_RE = /(?:^|;|&&|\|\|)\s*runas\s/i;
104
104
 
105
105
  // blockCurlPipeSh
106
- const CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh)/;
106
+ const CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh|dash|ksh|csh|tcsh|fish|ash)\b/;
107
107
  const PS_WEB_PIPE_RE = /(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm)\s+.*\|\s*(?:Invoke-Expression|iex)/i;
108
108
 
109
109
  // blockForcePush
@@ -440,7 +440,8 @@ function rmTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
440
440
  if (rmIdx < 0) continue;
441
441
  // Only validate recursive rm segments — non-recursive rm has no catastrophic-deletion risk
442
442
  const flagTokens = tokens.slice(rmIdx + 1).filter((t) => /^-[^-]/.test(t));
443
- if (!/r/i.test(flagTokens.join(""))) continue;
443
+ const longFlagsInSeg = tokens.slice(rmIdx + 1).filter((t) => /^--/.test(t));
444
+ if (!/r/i.test(flagTokens.join("")) && !longFlagsInSeg.some(f => /^--recursive$/i.test(f))) continue;
444
445
  const pathArgs = tokens.slice(rmIdx + 1).filter((t) => !t.startsWith("-"));
445
446
  for (const target of pathArgs) {
446
447
  const normalized = target.replace(/\/\*$/, "").replace(/\/+$/, "") || "/";
@@ -465,7 +466,10 @@ function rmTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
465
466
  function blockRmRf(ctx: PolicyContext): PolicyResult {
466
467
  if (ctx.toolName !== "Bash") return allow();
467
468
  const cmd = getCommand(ctx);
468
- const hasDestructivePath = /(?:\/\s*$|\/\*|~)/.test(cmd);
469
+ const hasDestructivePath = parseArgvTokens(cmd).some((token) => {
470
+ const normalized = token.replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
471
+ return normalized === "/" || normalized === "~" || /^\/[A-Za-z_][\w.-]*$/.test(normalized);
472
+ });
469
473
 
470
474
  // Combined flags in one token: rm -rf /, rm -fr /
471
475
  if (hasDestructivePath && (
@@ -481,7 +485,10 @@ function blockRmRf(ctx: PolicyContext): PolicyResult {
481
485
  if (hasDestructivePath && /\brm\b/.test(cmd)) {
482
486
  const tokens = parseArgvTokens(cmd);
483
487
  const shortFlags = tokens.filter((t) => /^-[^-]/.test(t)).join("");
484
- if (/r/i.test(shortFlags) && /f/.test(shortFlags)) {
488
+ const longFlags = tokens.filter((t) => /^--/.test(t));
489
+ const hasRecursive = /r/i.test(shortFlags) || longFlags.some(f => /^--recursive$/i.test(f));
490
+ const hasForce = /f/.test(shortFlags) || longFlags.some(f => /^--force$/i.test(f));
491
+ if (hasRecursive && hasForce) {
485
492
  const allowPaths = ((ctx.params?.allowPaths ?? []) as string[]);
486
493
  if (rmTargetIsAllowed(cmd, allowPaths)) return allow();
487
494
  return deny("Catastrophic deletion blocked");
@@ -891,6 +898,41 @@ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult {
891
898
  const branch = getCurrentBranch(cwd);
892
899
  if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping PR check.");
893
900
 
901
+ const baseBranch = (ctx.params?.baseBranch as string) ?? "main";
902
+
903
+ // If on the base branch itself, no PR is needed
904
+ if (branch === baseBranch) {
905
+ return allow(`On base branch "${baseBranch}", skipping PR check.`);
906
+ }
907
+
908
+ // Check if branch has diverged from base in any meaningful way
909
+ try {
910
+ const ahead = execFileSync(
911
+ "git",
912
+ ["log", `origin/${baseBranch}..HEAD`, "--oneline"],
913
+ { cwd, encoding: "utf8", timeout: 5000 },
914
+ ).trim();
915
+
916
+ if (!ahead) {
917
+ // No commits ahead — branch is fully merged (regular merge / fast-forward)
918
+ return allow(`No commits ahead of origin/${baseBranch}, skipping PR check.`);
919
+ }
920
+
921
+ // Commits exist but might be from a squash-merged PR.
922
+ // Check actual file diff — if trees are identical, work is already in main.
923
+ const diff = execFileSync(
924
+ "git",
925
+ ["diff", "--stat", `origin/${baseBranch}`, "HEAD"],
926
+ { cwd, encoding: "utf8", timeout: 5000 },
927
+ ).trim();
928
+
929
+ if (!diff) {
930
+ return allow(`No file changes compared to origin/${baseBranch}, skipping PR check.`);
931
+ }
932
+ } catch {
933
+ // origin/{baseBranch} ref missing or git error — fall through to gh pr view
934
+ }
935
+
894
936
  // Check if a PR exists for this branch
895
937
  let prJson: string;
896
938
  try {
@@ -1282,6 +1324,13 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1282
1324
  defaultEnabled: false,
1283
1325
  category: "Workflow",
1284
1326
  beta: true,
1327
+ params: {
1328
+ baseBranch: {
1329
+ type: "string",
1330
+ description: "Base branch to compare against (default: main)",
1331
+ default: "main",
1332
+ },
1333
+ } satisfies PolicyParamsSchema,
1285
1334
  },
1286
1335
  {
1287
1336
  name: "require-ci-green-before-stop",
@@ -134,6 +134,7 @@ export async function handleHookEvent(eventType: string): Promise<number> {
134
134
  eventType,
135
135
  toolName: (parsed.tool_name as string) ?? null,
136
136
  policyName: result.policyName,
137
+ policyNames: result.policyNames,
137
138
  decision: result.decision,
138
139
  reason: result.reason,
139
140
  durationMs,
@@ -43,6 +43,7 @@ export interface HookActivityEntry {
43
43
  eventType: string;
44
44
  toolName: string | null;
45
45
  policyName: string | null;
46
+ policyNames?: string[];
46
47
  decision: "allow" | "deny" | "instruct";
47
48
  reason: string | null;
48
49
  durationMs: number;
@@ -186,7 +187,11 @@ function updateStats(entry: HookActivityEntry): void {
186
187
  const s = readStoredStats();
187
188
  s.totalEvents += 1;
188
189
  if (entry.decision === "deny") s.denyCount += 1;
189
- if (entry.policyName) {
190
+ if (entry.policyNames && entry.policyNames.length > 0) {
191
+ for (const name of entry.policyNames) {
192
+ s.policyMap[name] = (s.policyMap[name] ?? 0) + 1;
193
+ }
194
+ } else if (entry.policyName) {
190
195
  s.policyMap[entry.policyName] = (s.policyMap[entry.policyName] ?? 0) + 1;
191
196
  }
192
197
  // Write atomically: write to a PID-unique temp file then rename — prevents partial reads.
@@ -13,6 +13,7 @@ export interface EvaluationResult {
13
13
  stdout: string;
14
14
  stderr: string;
15
15
  policyName: string | null;
16
+ policyNames?: string[];
16
17
  reason: string | null;
17
18
  decision: "allow" | "deny" | "instruct";
18
19
  }
@@ -51,8 +52,8 @@ export async function evaluatePolicies(
51
52
  let instructPolicyName: string | null = null;
52
53
  let instructReason: string | null = null;
53
54
 
54
- // Track informational messages from allow decisions
55
- const allowMessages: string[] = [];
55
+ // Track informational messages from allow decisions (with policy attribution)
56
+ const allowEntries: Array<{ policyName: string; reason: string }> = [];
56
57
 
57
58
  for (const policy of policies) {
58
59
  // Inject params: merge policyParams[policy.name] over schema defaults
@@ -123,7 +124,7 @@ export async function evaluatePolicies(
123
124
  return {
124
125
  exitCode: 2,
125
126
  stdout: "",
126
- stderr: "",
127
+ stderr: reason,
127
128
  policyName: policy.name,
128
129
  reason,
129
130
  decision: "deny",
@@ -139,7 +140,7 @@ export async function evaluatePolicies(
139
140
 
140
141
  // Accumulate informational messages from allow decisions
141
142
  if (result.decision === "allow" && result.reason) {
142
- allowMessages.push(result.reason);
143
+ allowEntries.push({ policyName: policy.name, reason: result.reason });
143
144
  }
144
145
  }
145
146
 
@@ -175,15 +176,17 @@ export async function evaluatePolicies(
175
176
  }
176
177
 
177
178
  // All policies allowed — pass along any informational messages
178
- if (allowMessages.length > 0) {
179
- const combined = allowMessages.join("\n");
180
- const response = {
181
- hookSpecificOutput: {
182
- hookEventName: eventType,
183
- additionalContext: combined,
184
- },
185
- };
186
- return { exitCode: 0, stdout: JSON.stringify(response), stderr: "", policyName: null, reason: combined, decision: "allow" };
179
+ if (allowEntries.length > 0) {
180
+ const combined = allowEntries.map((e) => e.reason).join("\n");
181
+ const policyNames = allowEntries.map((e) => e.policyName);
182
+ const supportsHookSpecificOutput = eventType === "PreToolUse" || eventType === "PostToolUse" || eventType === "UserPromptSubmit";
183
+ const response = supportsHookSpecificOutput
184
+ ? { hookSpecificOutput: { hookEventName: eventType, additionalContext: `Note from failproofai: ${combined}` } }
185
+ : { reason: combined };
186
+ const stderrMsg = allowEntries
187
+ .map((e) => `[failproofai] ${e.policyName}: ${e.reason}`)
188
+ .join("\n");
189
+ return { exitCode: 0, stdout: JSON.stringify(response), stderr: stderrMsg + "\n", policyName: policyNames[0], policyNames, reason: combined, decision: "allow" };
187
190
  }
188
191
  return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
189
192
  }
package/dist/cli.mjs CHANGED
@@ -496,7 +496,8 @@ function rmTargetIsAllowed(cmd, allowPaths) {
496
496
  if (rmIdx < 0)
497
497
  continue;
498
498
  const flagTokens = tokens.slice(rmIdx + 1).filter((t) => /^-[^-]/.test(t));
499
- if (!/r/i.test(flagTokens.join("")))
499
+ const longFlagsInSeg = tokens.slice(rmIdx + 1).filter((t) => /^--/.test(t));
500
+ if (!/r/i.test(flagTokens.join("")) && !longFlagsInSeg.some((f) => /^--recursive$/i.test(f)))
500
501
  continue;
501
502
  const pathArgs = tokens.slice(rmIdx + 1).filter((t) => !t.startsWith("-"));
502
503
  for (const target of pathArgs) {
@@ -521,7 +522,10 @@ function blockRmRf(ctx) {
521
522
  if (ctx.toolName !== "Bash")
522
523
  return allow();
523
524
  const cmd = getCommand(ctx);
524
- const hasDestructivePath = /(?:\/\s*$|\/\*|~)/.test(cmd);
525
+ const hasDestructivePath = parseArgvTokens(cmd).some((token) => {
526
+ const normalized = token.replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
527
+ return normalized === "/" || normalized === "~" || /^\/[A-Za-z_][\w.-]*$/.test(normalized);
528
+ });
525
529
  if (hasDestructivePath && (/rm\s+-[^\s]*r[^\s]*f[^\s]*/.test(cmd) || /rm\s+-[^\s]*f[^\s]*r[^\s]*/.test(cmd))) {
526
530
  const allowPaths = ctx.params?.allowPaths ?? [];
527
531
  if (rmTargetIsAllowed(cmd, allowPaths))
@@ -531,7 +535,10 @@ function blockRmRf(ctx) {
531
535
  if (hasDestructivePath && /\brm\b/.test(cmd)) {
532
536
  const tokens = parseArgvTokens(cmd);
533
537
  const shortFlags = tokens.filter((t) => /^-[^-]/.test(t)).join("");
534
- if (/r/i.test(shortFlags) && /f/.test(shortFlags)) {
538
+ const longFlags = tokens.filter((t) => /^--/.test(t));
539
+ const hasRecursive = /r/i.test(shortFlags) || longFlags.some((f) => /^--recursive$/i.test(f));
540
+ const hasForce = /f/.test(shortFlags) || longFlags.some((f) => /^--force$/i.test(f));
541
+ if (hasRecursive && hasForce) {
535
542
  const allowPaths = ctx.params?.allowPaths ?? [];
536
543
  if (rmTargetIsAllowed(cmd, allowPaths))
537
544
  return allow();
@@ -847,6 +854,20 @@ function requirePrBeforeStop(ctx) {
847
854
  const branch = getCurrentBranch(cwd);
848
855
  if (!branch || branch === "HEAD")
849
856
  return allow("Detached HEAD, skipping PR check.");
857
+ const baseBranch = ctx.params?.baseBranch ?? "main";
858
+ if (branch === baseBranch) {
859
+ return allow(`On base branch "${baseBranch}", skipping PR check.`);
860
+ }
861
+ try {
862
+ const ahead = execFileSync("git", ["log", `origin/${baseBranch}..HEAD`, "--oneline"], { cwd, encoding: "utf8", timeout: 5000 }).trim();
863
+ if (!ahead) {
864
+ return allow(`No commits ahead of origin/${baseBranch}, skipping PR check.`);
865
+ }
866
+ const diff = execFileSync("git", ["diff", "--stat", `origin/${baseBranch}`, "HEAD"], { cwd, encoding: "utf8", timeout: 5000 }).trim();
867
+ if (!diff) {
868
+ return allow(`No file changes compared to origin/${baseBranch}, skipping PR check.`);
869
+ }
870
+ } catch {}
850
871
  let prJson;
851
872
  try {
852
873
  prJson = execSync("gh pr view --json number,url,state", {
@@ -939,7 +960,7 @@ var init_builtin_policies = __esm(() => {
939
960
  SCHEMA_ALTER_RE = /\bALTER\s+TABLE\b[\s\S]*\b(?:DROP\s+COLUMN|ADD\s+COLUMN|RENAME\s+(?:COLUMN|TO)|MODIFY\s+COLUMN)\b/i;
940
961
  PUBLISH_CMD_RE = /(?:npm\s+publish|bun\s+publish|pnpm\s+publish|yarn\s+npm\s+publish|twine\s+upload|poetry\s+publish|cargo\s+publish|gem\s+push)\b/;
941
962
  ENV_PRINTENV_RE = /(?:^|\s|;|&&|\|\|)(?:env|printenv)(?:\s|$|;|&&|\|)/;
942
- ECHO_ENV_RE = /echo\s+.*\$[A-Za-z_]/;
963
+ ECHO_ENV_RE = /echo\s+.*\$\{?[A-Za-z_]/;
943
964
  EXPORT_RE = /(?:^|\s|;|&&|\|\|)export\s+\w+/;
944
965
  PS_ENV_VAR_RE = /\$env:[A-Za-z_]/i;
945
966
  PS_CHILDITEM_ENV_RE = /(?:Get-ChildItem|dir|gci|ls)\s+Env:/i;
@@ -950,7 +971,7 @@ var init_builtin_policies = __esm(() => {
950
971
  SUDO_RE = /(?:^|;|&&|\|\|)\s*sudo\s/;
951
972
  PS_ELEVATION_RE = /Start-Process\s+.*-Verb\s+RunAs/i;
952
973
  RUNAS_RE = /(?:^|;|&&|\|\|)\s*runas\s/i;
953
- CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh)/;
974
+ CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh|dash|ksh|csh|tcsh|fish|ash)\b/;
954
975
  PS_WEB_PIPE_RE = /(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm)\s+.*\|\s*(?:Invoke-Expression|iex)/i;
955
976
  FORCE_PUSH_RE = /(?:--force|-f\b)/;
956
977
  SECRET_FILE_RE = /\.(?:pem|key)$/;
@@ -1272,7 +1293,14 @@ var init_builtin_policies = __esm(() => {
1272
1293
  match: { events: ["Stop"] },
1273
1294
  defaultEnabled: false,
1274
1295
  category: "Workflow",
1275
- beta: true
1296
+ beta: true,
1297
+ params: {
1298
+ baseBranch: {
1299
+ type: "string",
1300
+ description: "Base branch to compare against (default: main)",
1301
+ default: "main"
1302
+ }
1303
+ }
1276
1304
  },
1277
1305
  {
1278
1306
  name: "require-ci-green-before-stop",
@@ -1304,7 +1332,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
1304
1332
  };
1305
1333
  let instructPolicyName = null;
1306
1334
  let instructReason = null;
1307
- const allowMessages = [];
1335
+ const allowEntries = [];
1308
1336
  for (const policy of policies) {
1309
1337
  const schema = POLICY_PARAMS_MAP.get(policy.name);
1310
1338
  let ctx;
@@ -1365,7 +1393,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
1365
1393
  return {
1366
1394
  exitCode: 2,
1367
1395
  stdout: "",
1368
- stderr: "",
1396
+ stderr: reason,
1369
1397
  policyName: policy.name,
1370
1398
  reason,
1371
1399
  decision: "deny"
@@ -1377,7 +1405,7 @@ async function evaluatePolicies(eventType, payload, session, config) {
1377
1405
  hookLogInfo(`instruct by "${policy.name}": ${instructReason}`);
1378
1406
  }
1379
1407
  if (result.decision === "allow" && result.reason) {
1380
- allowMessages.push(result.reason);
1408
+ allowEntries.push({ policyName: policy.name, reason: result.reason });
1381
1409
  }
1382
1410
  }
1383
1411
  if (instructPolicyName && instructReason) {
@@ -1406,16 +1434,16 @@ async function evaluatePolicies(eventType, payload, session, config) {
1406
1434
  decision: "instruct"
1407
1435
  };
1408
1436
  }
1409
- if (allowMessages.length > 0) {
1410
- const combined = allowMessages.join(`
1437
+ if (allowEntries.length > 0) {
1438
+ const combined = allowEntries.map((e) => e.reason).join(`
1411
1439
  `);
1412
- const response = {
1413
- hookSpecificOutput: {
1414
- hookEventName: eventType,
1415
- additionalContext: combined
1416
- }
1417
- };
1418
- return { exitCode: 0, stdout: JSON.stringify(response), stderr: "", policyName: null, reason: combined, decision: "allow" };
1440
+ const policyNames = allowEntries.map((e) => e.policyName);
1441
+ const supportsHookSpecificOutput = eventType === "PreToolUse" || eventType === "PostToolUse" || eventType === "UserPromptSubmit";
1442
+ const response = supportsHookSpecificOutput ? { hookSpecificOutput: { hookEventName: eventType, additionalContext: `Note from failproofai: ${combined}` } } : { reason: combined };
1443
+ const stderrMsg = allowEntries.map((e) => `[failproofai] ${e.policyName}: ${e.reason}`).join(`
1444
+ `);
1445
+ return { exitCode: 0, stdout: JSON.stringify(response), stderr: stderrMsg + `
1446
+ `, policyName: policyNames[0], policyNames, reason: combined, decision: "allow" };
1419
1447
  }
1420
1448
  return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
1421
1449
  }
@@ -1711,7 +1739,11 @@ function updateStats(entry) {
1711
1739
  s.totalEvents += 1;
1712
1740
  if (entry.decision === "deny")
1713
1741
  s.denyCount += 1;
1714
- if (entry.policyName) {
1742
+ if (entry.policyNames && entry.policyNames.length > 0) {
1743
+ for (const name of entry.policyNames) {
1744
+ s.policyMap[name] = (s.policyMap[name] ?? 0) + 1;
1745
+ }
1746
+ } else if (entry.policyName) {
1715
1747
  s.policyMap[entry.policyName] = (s.policyMap[entry.policyName] ?? 0) + 1;
1716
1748
  }
1717
1749
  const tmpPath = join3(storeDir, `stats.json.${process.pid}.tmp`);
@@ -1731,7 +1763,7 @@ var init_hook_activity_store = __esm(() => {
1731
1763
  });
1732
1764
 
1733
1765
  // package.json
1734
- var version2 = "0.0.2-beta.3";
1766
+ var version2 = "0.0.2-beta.5";
1735
1767
  var init_package = () => {};
1736
1768
 
1737
1769
  // src/posthog-key.ts
@@ -1940,6 +1972,7 @@ async function handleHookEvent(eventType) {
1940
1972
  eventType,
1941
1973
  toolName: parsed.tool_name ?? null,
1942
1974
  policyName: result.policyName,
1975
+ policyNames: result.policyNames,
1943
1976
  decision: result.decision,
1944
1977
  reason: result.reason,
1945
1978
  durationMs,
@@ -2957,7 +2990,7 @@ import { realpathSync as realpathSync2 } from "fs";
2957
2990
  import { dirname as dirname5, resolve as resolve8 } from "path";
2958
2991
  import { fileURLToPath as fileURLToPath2 } from "url";
2959
2992
  // package.json
2960
- var version = "0.0.2-beta.3";
2993
+ var version = "0.0.2-beta.5";
2961
2994
 
2962
2995
  // bin/failproofai.mjs
2963
2996
  if (!process.env.FAILPROOFAI_PACKAGE_ROOT) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.2-beta.3",
3
+ "version": "0.0.2-beta.5",
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"