failproofai 0.0.2-beta.2 → 0.0.2-beta.4

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 (130) 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/server/app/_global-error/page/server-reference-manifest.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  9. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  10. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  12. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  18. package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
  19. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  20. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  21. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
  22. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  24. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  25. package/.next/standalone/.next/server/app/index.html +1 -1
  26. package/.next/standalone/.next/server/app/index.rsc +16 -16
  27. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  29. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  30. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
  31. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  33. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  36. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  39. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  42. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  43. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  46. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/chunks/[root-of-the-server]__02nt~6d._.js +1 -1
  49. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  50. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0103jwf._.js → [root-of-the-server]__05ib_c3._.js} +2 -2
  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]__0ovwjau._.js → [root-of-the-server]__0od~yp1._.js} +2 -2
  56. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0okos0k._.js +2 -2
  57. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +5 -5
  58. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
  59. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
  60. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +1 -1
  63. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  64. package/.next/standalone/.next/server/pages/404.html +2 -2
  65. package/.next/standalone/.next/server/pages/500.html +1 -1
  66. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  68. package/.next/standalone/.next/static/chunks/{0sm1iqi3m~xiz.js → 03qghe4e2_.ul.js} +1 -1
  69. package/.next/standalone/.next/static/chunks/{0jrzwsyo7wo26.js → 0_eej2~ju.yds.js} +1 -1
  70. package/.next/standalone/.next/static/chunks/{0tl2f-3yc.rqc.js → 0bc69j4t8njpq.js} +1 -1
  71. package/.next/standalone/.next/static/chunks/0nnxt7uoz_cvj.css +1 -0
  72. package/.next/standalone/.next/static/chunks/{0uftmw5od9kdz.js → 0qaojcc.nvqd8.js} +1 -1
  73. package/.next/standalone/.next/static/chunks/{001k0zayn2o.s.js → 0xg4wy053mmhs.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/{0pdd7~yp8ytu6.js → 12mgwr8gh_kqo.js} +2 -2
  75. package/.next/standalone/.next/static/chunks/{0tbr0o7vwc~-s.js → 134~t05vpu75e.js} +1 -1
  76. package/.next/standalone/.next/static/chunks/{0wtcha31~i7rm.js → 17veghz_js0u3.js} +1 -1
  77. package/.next/standalone/Dockerfile.docs +12 -0
  78. package/.next/standalone/README.md +59 -46
  79. package/.next/standalone/app/components/session-hooks-panel.tsx +31 -6
  80. package/.next/standalone/app/policies/hooks-client.tsx +31 -6
  81. package/.next/standalone/dist/cli.mjs +234 -27
  82. package/.next/standalone/dist/index.js +2 -2
  83. package/.next/standalone/docs/{architecture.md → architecture.mdx} +40 -23
  84. package/.next/standalone/docs/{built-in-policies.md → built-in-policies.mdx} +134 -12
  85. package/.next/standalone/docs/cli/dashboard.mdx +28 -0
  86. package/.next/standalone/docs/cli/environment-variables.mdx +34 -0
  87. package/.next/standalone/docs/cli/hook.mdx +30 -0
  88. package/.next/standalone/docs/cli/install-policies.mdx +48 -0
  89. package/.next/standalone/docs/cli/list-policies.mdx +31 -0
  90. package/.next/standalone/docs/cli/remove-policies.mdx +44 -0
  91. package/.next/standalone/docs/cli/version.mdx +12 -0
  92. package/.next/standalone/docs/{configuration.md → configuration.mdx} +16 -16
  93. package/.next/standalone/docs/{custom-hooks.md → custom-policies.mdx} +80 -42
  94. package/.next/standalone/docs/{dashboard.md → dashboard.mdx} +26 -29
  95. package/.next/standalone/docs/docs.json +31 -4
  96. package/.next/standalone/docs/examples.mdx +253 -0
  97. package/.next/standalone/docs/for-agents.mdx +38 -0
  98. package/.next/standalone/docs/getting-started.mdx +134 -0
  99. package/.next/standalone/docs/introduction.mdx +57 -0
  100. package/.next/standalone/docs/logo/dark.svg +21 -0
  101. package/.next/standalone/docs/logo/light.svg +21 -0
  102. package/.next/standalone/docs/{package-aliases.md → package-aliases.mdx} +5 -5
  103. package/.next/standalone/docs/{testing.md → testing.mdx} +11 -11
  104. package/.next/standalone/package.json +6 -9
  105. package/.next/standalone/scripts/publish-aliases.mjs +4 -2
  106. package/.next/standalone/skills-lock.json +10 -0
  107. package/.next/standalone/src/hooks/builtin-policies.ts +271 -25
  108. package/.next/standalone/src/hooks/handler.ts +1 -0
  109. package/.next/standalone/src/hooks/hook-activity-store.ts +6 -1
  110. package/.next/standalone/src/hooks/policy-evaluator.ts +23 -2
  111. package/.next/standalone/src/hooks/policy-helpers.ts +2 -2
  112. package/.next/standalone/vitest.config.e2e.mts +3 -0
  113. package/.next/standalone/vitest.config.mts +3 -0
  114. package/README.md +59 -46
  115. package/dist/cli.mjs +234 -27
  116. package/dist/index.js +2 -2
  117. package/package.json +6 -9
  118. package/scripts/publish-aliases.mjs +4 -2
  119. package/src/hooks/builtin-policies.ts +271 -25
  120. package/src/hooks/handler.ts +1 -0
  121. package/src/hooks/hook-activity-store.ts +6 -1
  122. package/src/hooks/policy-evaluator.ts +23 -2
  123. package/src/hooks/policy-helpers.ts +2 -2
  124. package/.next/standalone/.next/static/chunks/15jpradyu_531.css +0 -1
  125. package/.next/standalone/docs/cli-reference.md +0 -175
  126. package/.next/standalone/docs/getting-started.md +0 -128
  127. package/.next/standalone/docs/introduction.md +0 -47
  128. /package/.next/standalone/.next/static/{JksWDLwDoPy6bcczVWlff → N7CmoNJD3b7hE1pCaP_Gs}/_buildManifest.js +0 -0
  129. /package/.next/standalone/.next/static/{JksWDLwDoPy6bcczVWlff → N7CmoNJD3b7hE1pCaP_Gs}/_clientMiddlewareManifest.js +0 -0
  130. /package/.next/standalone/.next/static/{JksWDLwDoPy6bcczVWlff → N7CmoNJD3b7hE1pCaP_Gs}/_ssgManifest.js +0 -0
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { resolve, join } from "node:path";
5
5
  import { readFile, writeFile, stat, open } from "node:fs/promises";
6
- import { execSync } from "node:child_process";
6
+ import { execSync, execFileSync } from "node:child_process";
7
7
  import { homedir } from "node:os";
8
8
  import type { BuiltinPolicyDefinition, PolicyContext, PolicyResult, PolicyParamsSchema } from "./policy-types";
9
9
  import { allow, deny, instruct } from "./policy-helpers";
@@ -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
@@ -145,12 +145,29 @@ const TMUX_DETACH_RE = /\btmux\s+(?:new-session|new)\b[^|&;]*-d\b/;
145
145
  const DISOWN_RE = /\bdisown\b/;
146
146
  const BACKGROUND_AMPERSAND_RE = /(?<![&|])\s?&\s*(?:$|#|;)/;
147
147
 
148
- // blockWorkOnMain: caches the current branch per cwd to avoid repeated execSync calls.
148
+ // Caches the current branch per cwd to avoid repeated execSync calls.
149
149
  // Trade-off: if the user switches branches externally mid-session, the cache serves
150
150
  // the stale value until the process restarts. This is acceptable since branch switches
151
151
  // during an active Claude session are rare.
152
152
  const gitBranchCache = new Map<string, string>();
153
153
 
154
+ function getCurrentBranch(cwd: string): string | null {
155
+ try {
156
+ let branch = gitBranchCache.get(cwd);
157
+ if (branch === undefined) {
158
+ branch = execSync("git rev-parse --abbrev-ref HEAD", {
159
+ cwd,
160
+ encoding: "utf8",
161
+ timeout: 3000,
162
+ }).trim();
163
+ gitBranchCache.set(cwd, branch);
164
+ }
165
+ return branch || null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
154
171
  /**
155
172
  * Check if a command matches an allow pattern using token-by-token comparison.
156
173
  * The "*" token is a wildcard. Extra command tokens beyond the pattern are allowed,
@@ -423,7 +440,8 @@ function rmTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
423
440
  if (rmIdx < 0) continue;
424
441
  // Only validate recursive rm segments — non-recursive rm has no catastrophic-deletion risk
425
442
  const flagTokens = tokens.slice(rmIdx + 1).filter((t) => /^-[^-]/.test(t));
426
- 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;
427
445
  const pathArgs = tokens.slice(rmIdx + 1).filter((t) => !t.startsWith("-"));
428
446
  for (const target of pathArgs) {
429
447
  const normalized = target.replace(/\/\*$/, "").replace(/\/+$/, "") || "/";
@@ -448,7 +466,10 @@ function rmTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
448
466
  function blockRmRf(ctx: PolicyContext): PolicyResult {
449
467
  if (ctx.toolName !== "Bash") return allow();
450
468
  const cmd = getCommand(ctx);
451
- 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
+ });
452
473
 
453
474
  // Combined flags in one token: rm -rf /, rm -fr /
454
475
  if (hasDestructivePath && (
@@ -464,7 +485,10 @@ function blockRmRf(ctx: PolicyContext): PolicyResult {
464
485
  if (hasDestructivePath && /\brm\b/.test(cmd)) {
465
486
  const tokens = parseArgvTokens(cmd);
466
487
  const shortFlags = tokens.filter((t) => /^-[^-]/.test(t)).join("");
467
- 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) {
468
492
  const allowPaths = ((ctx.params?.allowPaths ?? []) as string[]);
469
493
  if (rmTargetIsAllowed(cmd, allowPaths)) return allow();
470
494
  return deny("Catastrophic deletion blocked");
@@ -627,24 +651,14 @@ function blockWorkOnMain(ctx: PolicyContext): PolicyResult {
627
651
  const cwd = ctx.session?.cwd;
628
652
  if (!cwd) return allow();
629
653
 
630
- try {
631
- let branch = gitBranchCache.get(cwd);
632
- if (branch === undefined) {
633
- branch = execSync("git rev-parse --abbrev-ref HEAD", {
634
- cwd,
635
- encoding: "utf8",
636
- timeout: 3000,
637
- }).trim();
638
- gitBranchCache.set(cwd, branch);
639
- }
640
- const protectedBranches = ((ctx.params?.protectedBranches ?? ["main", "master"]) as string[]);
641
- if (protectedBranches.includes(branch)) {
642
- return deny(
643
- `Git ${cmd.match(/git\s+(\S+)/)?.[1] ?? "operation"} on ${branch} is blocked. Create a feature branch first.`,
644
- );
645
- }
646
- } catch {
647
- return allow();
654
+ const branch = getCurrentBranch(cwd);
655
+ if (!branch) return allow();
656
+
657
+ const protectedBranches = ((ctx.params?.protectedBranches ?? ["main", "master"]) as string[]);
658
+ if (protectedBranches.includes(branch)) {
659
+ return deny(
660
+ `Git ${cmd.match(/git\s+(\S+)/)?.[1] ?? "operation"} on ${branch} is blocked. Create a feature branch first.`,
661
+ );
648
662
  }
649
663
  return allow();
650
664
  }
@@ -786,6 +800,195 @@ function warnBackgroundProcess(ctx: PolicyContext): PolicyResult {
786
800
  return allow();
787
801
  }
788
802
 
803
+ // -- Workflow (Stop event) policies --
804
+
805
+ function requireCommitBeforeStop(ctx: PolicyContext): PolicyResult {
806
+ const cwd = ctx.session?.cwd;
807
+ if (!cwd) return allow("No working directory available, skipping commit check.");
808
+
809
+ try {
810
+ const status = execSync("git status --porcelain", {
811
+ cwd,
812
+ encoding: "utf8",
813
+ timeout: 5000,
814
+ }).trim();
815
+
816
+ if (status.length > 0) {
817
+ return deny(
818
+ "You have uncommitted changes in the working directory. Commit all changes before stopping.",
819
+ );
820
+ }
821
+ return allow("All changes are committed.");
822
+ } catch {
823
+ return allow("Not a git repository, skipping commit check.");
824
+ }
825
+ }
826
+
827
+ function requirePushBeforeStop(ctx: PolicyContext): PolicyResult {
828
+ const cwd = ctx.session?.cwd;
829
+ if (!cwd) return allow("No working directory available, skipping push check.");
830
+
831
+ try {
832
+ const remotes = execSync("git remote", {
833
+ cwd,
834
+ encoding: "utf8",
835
+ timeout: 3000,
836
+ }).trim();
837
+
838
+ if (!remotes) return allow("No git remote configured, skipping push check.");
839
+
840
+ const remote = (ctx.params?.remote as string) ?? "origin";
841
+
842
+ const branch = getCurrentBranch(cwd);
843
+ if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping push check.");
844
+
845
+ // Check if remote tracking branch exists
846
+ let hasTracking = false;
847
+ try {
848
+ execFileSync("git", ["rev-parse", "--verify", `${remote}/${branch}`], {
849
+ cwd,
850
+ encoding: "utf8",
851
+ timeout: 3000,
852
+ });
853
+ hasTracking = true;
854
+ } catch {
855
+ // Remote tracking branch does not exist
856
+ }
857
+
858
+ if (!hasTracking) {
859
+ return deny(
860
+ `Branch "${branch}" has not been pushed to remote "${remote}". ` +
861
+ `Push your branch with: git push -u ${remote} ${branch}`,
862
+ );
863
+ }
864
+
865
+ // Check for unpushed commits
866
+ const unpushed = execFileSync("git", ["log", `${remote}/${branch}..HEAD`, "--oneline"], {
867
+ cwd,
868
+ encoding: "utf8",
869
+ timeout: 5000,
870
+ }).trim();
871
+
872
+ if (unpushed.length > 0) {
873
+ const commitCount = unpushed.split("\n").length;
874
+ return deny(
875
+ `You have ${commitCount} unpushed commit${commitCount > 1 ? "s" : ""} on branch "${branch}". ` +
876
+ `Push your changes with: git push`,
877
+ );
878
+ }
879
+
880
+ return allow(`All commits pushed to "${remote}".`);
881
+ } catch {
882
+ return allow("Could not check push status, skipping.");
883
+ }
884
+ }
885
+
886
+ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult {
887
+ const cwd = ctx.session?.cwd;
888
+ if (!cwd) return allow("No working directory available, skipping PR check.");
889
+
890
+ try {
891
+ // Check if gh CLI is available
892
+ try {
893
+ execSync("gh --version", { cwd, encoding: "utf8", timeout: 3000 });
894
+ } catch {
895
+ return allow("GitHub CLI (gh) not installed, skipping PR check.");
896
+ }
897
+
898
+ const branch = getCurrentBranch(cwd);
899
+ if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping PR check.");
900
+
901
+ // Check if a PR exists for this branch
902
+ let prJson: string;
903
+ try {
904
+ prJson = execSync("gh pr view --json number,url,state", {
905
+ cwd,
906
+ encoding: "utf8",
907
+ timeout: 15000,
908
+ }).trim();
909
+ } catch {
910
+ // gh pr view exits non-zero when no PR exists
911
+ return deny(
912
+ `No pull request found for branch "${branch}". ` +
913
+ `Create one with: gh pr create`,
914
+ );
915
+ }
916
+
917
+ const pr = JSON.parse(prJson) as { number: number; url: string; state: string };
918
+
919
+ if (pr.state === "OPEN") {
920
+ return allow(`PR #${pr.number} exists: ${pr.url}`);
921
+ }
922
+
923
+ return deny(
924
+ `Pull request for branch "${branch}" is ${pr.state.toLowerCase()}. Create a new PR with: gh pr create`,
925
+ );
926
+ } catch {
927
+ return allow("Could not check PR status, skipping.");
928
+ }
929
+ }
930
+
931
+ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
932
+ const cwd = ctx.session?.cwd;
933
+ if (!cwd) return allow("No working directory available, skipping CI check.");
934
+
935
+ try {
936
+ // Check if gh CLI is available
937
+ try {
938
+ execSync("gh --version", { cwd, encoding: "utf8", timeout: 3000 });
939
+ } catch {
940
+ return allow("GitHub CLI (gh) not installed, skipping CI check.");
941
+ }
942
+
943
+ const branch = getCurrentBranch(cwd);
944
+ if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping CI check.");
945
+
946
+ const runsJson = execFileSync(
947
+ "gh",
948
+ ["run", "list", "--branch", branch, "--limit", "5", "--json", "status,conclusion,name"],
949
+ {
950
+ cwd,
951
+ encoding: "utf8",
952
+ timeout: 15000,
953
+ },
954
+ ).trim();
955
+
956
+ if (!runsJson || runsJson === "[]") return allow(`No CI runs found for branch "${branch}".`);
957
+
958
+ const runs = JSON.parse(runsJson) as Array<{
959
+ status: string;
960
+ conclusion: string;
961
+ name: string;
962
+ }>;
963
+
964
+ if (runs.length === 0) return allow(`No CI runs found for branch "${branch}".`);
965
+
966
+ const failing = runs.filter(
967
+ (r) => r.status === "completed" && r.conclusion !== "success" && r.conclusion !== "skipped",
968
+ );
969
+ if (failing.length > 0) {
970
+ const names = failing.map((r) => `"${r.name}"`).join(", ");
971
+ return deny(
972
+ `CI checks are failing on branch "${branch}": ${names}. Fix the failing checks before stopping.`,
973
+ );
974
+ }
975
+
976
+ const pending = runs.filter(
977
+ (r) => r.status === "in_progress" || r.status === "queued" || r.status === "waiting",
978
+ );
979
+ if (pending.length > 0) {
980
+ const names = pending.map((r) => `"${r.name}"`).join(", ");
981
+ return deny(
982
+ `CI checks are still running on branch "${branch}": ${names}. Wait for all checks to complete and verify they pass.`,
983
+ );
984
+ }
985
+
986
+ return allow(`All CI checks passed on branch "${branch}".`);
987
+ } catch {
988
+ return allow("Could not check CI status, skipping.");
989
+ }
990
+ }
991
+
789
992
  // -- Registry --
790
993
 
791
994
  export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
@@ -1053,6 +1256,49 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1053
1256
  defaultEnabled: false,
1054
1257
  category: "AI Behavior",
1055
1258
  },
1259
+ {
1260
+ name: "require-commit-before-stop",
1261
+ description: "Require all changes to be committed before Claude stops",
1262
+ fn: requireCommitBeforeStop,
1263
+ match: { events: ["Stop"] },
1264
+ defaultEnabled: false,
1265
+ category: "Workflow",
1266
+ beta: true,
1267
+ },
1268
+ {
1269
+ name: "require-push-before-stop",
1270
+ description: "Require all commits to be pushed to remote before Claude stops",
1271
+ fn: requirePushBeforeStop,
1272
+ match: { events: ["Stop"] },
1273
+ defaultEnabled: false,
1274
+ category: "Workflow",
1275
+ beta: true,
1276
+ params: {
1277
+ remote: {
1278
+ type: "string",
1279
+ description: "Remote name to push to (default: origin)",
1280
+ default: "origin",
1281
+ },
1282
+ } satisfies PolicyParamsSchema,
1283
+ },
1284
+ {
1285
+ name: "require-pr-before-stop",
1286
+ description: "Require a pull request to exist for the current branch before Claude stops",
1287
+ fn: requirePrBeforeStop,
1288
+ match: { events: ["Stop"] },
1289
+ defaultEnabled: false,
1290
+ category: "Workflow",
1291
+ beta: true,
1292
+ },
1293
+ {
1294
+ name: "require-ci-green-before-stop",
1295
+ description: "Require CI checks to pass on the current branch before Claude stops",
1296
+ fn: requireCiGreenBeforeStop,
1297
+ match: { events: ["Stop"] },
1298
+ defaultEnabled: false,
1299
+ category: "Workflow",
1300
+ beta: true,
1301
+ },
1056
1302
  ];
1057
1303
 
1058
1304
  export function registerBuiltinPolicies(enabledNames: string[]): void {
@@ -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,6 +52,9 @@ export async function evaluatePolicies(
51
52
  let instructPolicyName: string | null = null;
52
53
  let instructReason: string | null = null;
53
54
 
55
+ // Track informational messages from allow decisions (with policy attribution)
56
+ const allowEntries: Array<{ policyName: string; reason: string }> = [];
57
+
54
58
  for (const policy of policies) {
55
59
  // Inject params: merge policyParams[policy.name] over schema defaults
56
60
  const schema = POLICY_PARAMS_MAP.get(policy.name);
@@ -120,7 +124,7 @@ export async function evaluatePolicies(
120
124
  return {
121
125
  exitCode: 2,
122
126
  stdout: "",
123
- stderr: "",
127
+ stderr: reason,
124
128
  policyName: policy.name,
125
129
  reason,
126
130
  decision: "deny",
@@ -133,6 +137,11 @@ export async function evaluatePolicies(
133
137
  instructReason = result.reason ?? `Instruction from policy: ${policy.name}`;
134
138
  hookLogInfo(`instruct by "${policy.name}": ${instructReason}`);
135
139
  }
140
+
141
+ // Accumulate informational messages from allow decisions
142
+ if (result.decision === "allow" && result.reason) {
143
+ allowEntries.push({ policyName: policy.name, reason: result.reason });
144
+ }
136
145
  }
137
146
 
138
147
  // No deny — check if we accumulated an instruct
@@ -166,6 +175,18 @@ export async function evaluatePolicies(
166
175
  };
167
176
  }
168
177
 
169
- // All policies allowed
178
+ // All policies allowed — pass along any informational messages
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" };
190
+ }
170
191
  return { exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow" };
171
192
  }
@@ -3,8 +3,8 @@
3
3
  */
4
4
  import type { PolicyResult } from "./policy-types";
5
5
 
6
- export function allow(): PolicyResult {
7
- return { decision: "allow" };
6
+ export function allow(reason?: string): PolicyResult {
7
+ return reason ? { decision: "allow", reason } : { decision: "allow" };
8
8
  }
9
9
 
10
10
  export function deny(reason: string): PolicyResult {
@@ -17,5 +17,8 @@ export default defineConfig({
17
17
  // forks pool: true process isolation — tests spawn subprocesses,
18
18
  // thread workers share globalThis which can interfere.
19
19
  pool: "forks",
20
+ env: {
21
+ FAILPROOFAI_TELEMETRY_DISABLED: "1",
22
+ },
20
23
  },
21
24
  });
@@ -16,5 +16,8 @@ export default defineConfig({
16
16
  include: ["__tests__/**/*.test.{ts,tsx}"],
17
17
  exclude: ["__tests__/e2e/**"],
18
18
  css: false,
19
+ env: {
20
+ FAILPROOFAI_TELEMETRY_DISABLED: "1",
21
+ },
19
22
  },
20
23
  });
package/README.md CHANGED
@@ -15,21 +15,21 @@
15
15
  [![CI](https://img.shields.io/github/actions/workflow/status/exospherehost/failproofai/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/exospherehost/failproofai/actions)
16
16
  [![Discord](https://img.shields.io/discord/1234567890?style=flat-square&label=Discord&color=5865F2)](https://discord.com/invite/zT92CAgvkj)
17
17
 
18
- Open-source hooks, policies, and project visualization for **Claude Code** & the **Agents SDK**.
18
+ The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously - for **Claude Code** & the **Agents SDK**.
19
19
 
20
- - **Hooks & Policies** 35+ built-in security policies that run as Claude Code hooks. Block dangerous commands, sanitize secrets, restrict file access, and more.
21
- - **Custom Policies** Write your own policies in JavaScript. Same `allow`/`deny`/`instruct` API as built-in policies, with full async support.
22
- - **Policy Parameters** Tune built-in policies without writing code: configure allowlists, protected branches, thresholds, and custom patterns.
23
- - **Session Viewer** Browse Claude Code projects and sessions locally. Inspect tool calls, messages, and per-session hook activity side-by-side.
20
+ - **30 Built-in Policies** - Catch common agent failure modes out of the box. Block destructive commands, prevent secret leakage, keep agents inside project boundaries, detect loops, and more.
21
+ - **Custom Policies** - Write your own reliability rules in JavaScript. Use the `allow`/`deny`/`instruct` API to enforce conventions, prevent drift, gate operations, or integrate with external systems.
22
+ - **Easy Configuration** - Tune any policy without writing code. Set allowlists, protected branches, thresholds per-project or globally. Three-scope config merges automatically.
23
+ - **Agent Monitor** - See what your agents did while you were away. Browse sessions, inspect every tool call, and review exactly where policies fired.
24
24
 
25
- Everything runs locally no data leaves your machine.
25
+ Everything runs locally - no data leaves your machine.
26
26
 
27
27
  ---
28
28
 
29
29
  ## Requirements
30
30
 
31
31
  - Node.js >= 20.9.0
32
- - Bun >= 1.3.0 (optional only needed for development / building from source)
32
+ - Bun >= 1.3.0 (optional - only needed for development / building from source)
33
33
 
34
34
  ---
35
35
 
@@ -59,7 +59,7 @@ Writes hook entries into `~/.claude/settings.json`. Claude Code will now invoke
59
59
  failproofai
60
60
  ```
61
61
 
62
- Opens `http://localhost:8020` browse sessions, inspect logs, manage policies.
62
+ Opens `http://localhost:8020` - browse sessions, inspect logs, manage policies.
63
63
 
64
64
  ### 3. Check what's active
65
65
 
@@ -128,7 +128,7 @@ Policy configuration lives in `~/.failproofai/policies-config.json` (global) or
128
128
  }
129
129
  ```
130
130
 
131
- **Three config scopes** are merged automatically (project → local → global). See [docs/configuration.md](docs/configuration.md) for full merge rules.
131
+ **Three config scopes** are merged automatically (project → local → global). See [docs/configuration.mdx](docs/configuration.mdx) for full merge rules.
132
132
 
133
133
  ---
134
134
 
@@ -136,40 +136,40 @@ Policy configuration lives in `~/.failproofai/policies-config.json` (global) or
136
136
 
137
137
  | Policy | Description | Configurable |
138
138
  |--------|-------------|:---:|
139
- | `block-sudo` | Block sudo commands | `allowPatterns` |
140
- | `block-rm-rf` | Block recursive deletions | `allowPaths` |
141
- | `block-curl-pipe-sh` | Block curl\|bash and wget\|bash | |
139
+ | `block-sudo` | Prevent agents from running privileged system commands | `allowPatterns` |
140
+ | `block-rm-rf` | Prevent accidental recursive file deletion | `allowPaths` |
141
+ | `block-curl-pipe-sh` | Prevent agents from piping untrusted scripts to shell | |
142
142
  | `block-failproofai-commands` | Prevent self-uninstallation | |
143
- | `sanitize-jwt` | Redact JWT tokens from tool output | |
144
- | `sanitize-api-keys` | Redact API keys from tool output | `additionalPatterns` |
145
- | `sanitize-connection-strings` | Redact database credentials from tool output | |
146
- | `sanitize-private-key-content` | Redact PEM private key blocks | |
147
- | `sanitize-bearer-tokens` | Redact Authorization Bearer tokens | |
148
- | `block-env-files` | Block access to .env files | |
149
- | `protect-env-vars` | Block commands that print environment variables | |
150
- | `block-read-outside-cwd` | Block reading files outside the project | `allowPaths` |
151
- | `block-secrets-write` | Block writes to private key and certificate files | `additionalPatterns` |
152
- | `block-push-master` | Block pushing to main/master | `protectedBranches` |
153
- | `block-work-on-main` | Block checking out main/master | `protectedBranches` |
154
- | `block-force-push` | Block `git push --force` | |
155
- | `warn-git-amend` | Warn on `git commit --amend` | |
156
- | `warn-git-stash-drop` | Warn on `git stash drop` | |
157
- | `warn-all-files-staged` | Warn on `git add -A` | |
158
- | `warn-destructive-sql` | Warn on DROP/DELETE SQL statements | |
159
- | `warn-schema-alteration` | Warn on ALTER TABLE statements | |
160
- | `warn-large-file-write` | Warn on large file writes | `thresholdKb` |
161
- | `warn-package-publish` | Warn on `npm publish` | |
162
- | `warn-background-process` | Warn on background process launches | |
163
- | `warn-global-package-install` | Warn on global package installs | |
143
+ | `sanitize-jwt` | Stop JWT tokens from leaking into agent context | |
144
+ | `sanitize-api-keys` | Stop API keys from leaking into agent context | `additionalPatterns` |
145
+ | `sanitize-connection-strings` | Stop database credentials from leaking into agent context | |
146
+ | `sanitize-private-key-content` | Redact PEM private key blocks from output | |
147
+ | `sanitize-bearer-tokens` | Redact Authorization Bearer tokens from output | |
148
+ | `block-env-files` | Keep agents from reading .env files | |
149
+ | `protect-env-vars` | Prevent agents from printing environment variables | |
150
+ | `block-read-outside-cwd` | Keep agents inside project boundaries | `allowPaths` |
151
+ | `block-secrets-write` | Prevent writes to private key and certificate files | `additionalPatterns` |
152
+ | `block-push-master` | Prevent accidental pushes to main/master | `protectedBranches` |
153
+ | `block-work-on-main` | Keep agents off protected branches | `protectedBranches` |
154
+ | `block-force-push` | Prevent `git push --force` | |
155
+ | `warn-git-amend` | Remind agents before amending commits | |
156
+ | `warn-git-stash-drop` | Remind agents before dropping stashes | |
157
+ | `warn-all-files-staged` | Catch accidental `git add -A` | |
158
+ | `warn-destructive-sql` | Catch DROP/DELETE SQL before execution | |
159
+ | `warn-schema-alteration` | Catch ALTER TABLE before execution | |
160
+ | `warn-large-file-write` | Catch unexpectedly large file writes | `thresholdKb` |
161
+ | `warn-package-publish` | Catch accidental `npm publish` | |
162
+ | `warn-background-process` | Catch unintended background process launches | |
163
+ | `warn-global-package-install` | Catch unintended global package installs | |
164
164
  | …and more | | |
165
165
 
166
- Full policy details and parameter reference: [docs/built-in-policies.md](docs/built-in-policies.md)
166
+ Full policy details and parameter reference: [docs/built-in-policies.mdx](docs/built-in-policies.mdx)
167
167
 
168
168
  ---
169
169
 
170
170
  ## Custom policies
171
171
 
172
- Create a `.js` file with your own policies:
172
+ Write your own policies to keep agents reliable and on-task:
173
173
 
174
174
  ```js
175
175
  import { customPolicies, allow, deny, instruct } from "failproofai";
@@ -197,8 +197,9 @@ failproofai policies --install --custom ./my-policies.js
197
197
 
198
198
  | Function | Effect |
199
199
  |----------|--------|
200
- | `allow()` | Permit the tool call |
201
- | `deny(message)` | Block the tool call; message shown to Claude |
200
+ | `allow()` | Permit the operation |
201
+ | `allow(message)` | Permit and send informational context to Claude *(beta)* |
202
+ | `deny(message)` | Block the operation; message shown to Claude |
202
203
  | `instruct(message)` | Add context to Claude's prompt; does not block |
203
204
 
204
205
  ### Context object (`ctx`)
@@ -213,7 +214,7 @@ failproofai policies --install --custom ./my-policies.js
213
214
  | `session.sessionId` | `string` | Session identifier |
214
215
  | `session.transcriptPath` | `string` | Path to the session transcript file |
215
216
 
216
- Custom hooks support transitive local imports, async/await, and access to `process.env`. Errors in custom hooks are fail-open (logged to `~/.failproofai/hook.log`, built-in policies continue). See [docs/custom-hooks.md](docs/custom-hooks.md) for the full guide.
217
+ Custom hooks support transitive local imports, async/await, and access to `process.env`. Errors are fail-open (logged to `~/.failproofai/hook.log`, built-in policies continue). See [docs/custom-hooks.mdx](docs/custom-hooks.mdx) for the full guide.
217
218
 
218
219
  ---
219
220
 
@@ -233,14 +234,26 @@ FAILPROOFAI_TELEMETRY_DISABLED=1 failproofai
233
234
 
234
235
  | Guide | Description |
235
236
  |-------|-------------|
236
- | [Getting Started](docs/getting-started.md) | Installation and first steps |
237
- | [CLI Reference](docs/cli-reference.md) | All commands and flags |
238
- | [Configuration](docs/configuration.md) | Config file format and scope merging |
239
- | [Built-in Policies](docs/built-in-policies.md) | All 35+ policies with parameters |
240
- | [Custom Hooks](docs/custom-hooks.md) | Write your own policies |
241
- | [Dashboard](docs/dashboard.md) | Session viewer and policy management |
242
- | [Architecture](docs/architecture.md) | How the hook system works |
243
- | [Testing](docs/testing.md) | Running tests and writing new ones |
237
+ | [Getting Started](docs/getting-started.mdx) | Installation and first steps |
238
+ | [Built-in Policies](docs/built-in-policies.mdx) | All 30 built-in policies with parameters |
239
+ | [Custom Policies](docs/custom-policies.mdx) | Write your own policies |
240
+ | [Configuration](docs/configuration.mdx) | Config file format and scope merging |
241
+ | [Dashboard](docs/dashboard.mdx) | Monitor sessions and review policy activity |
242
+ | [Architecture](docs/architecture.mdx) | How the hook system works |
243
+ | [Testing](docs/testing.mdx) | Running tests and writing new ones |
244
+
245
+ ### Run docs locally
246
+
247
+ ```bash
248
+ docker build -f Dockerfile.docs -t failproofai-docs .
249
+ docker run --rm -p 3000:3000 failproofai-docs
250
+ ```
251
+
252
+ Opens the Mintlify docs site at `http://localhost:3000`. The container watches for changes if you mount the docs directory:
253
+
254
+ ```bash
255
+ docker run --rm -p 3000:3000 -v $(pwd)/docs:/app/docs failproofai-docs
256
+ ```
244
257
 
245
258
  ---
246
259