failproofai 0.0.9 → 0.0.10-beta.0

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 (197) hide show
  1. package/.next/standalone/.cursor/hooks.json +47 -0
  2. package/.next/standalone/.gemini/settings.json +147 -0
  3. package/.next/standalone/.next/BUILD_ID +1 -1
  4. package/.next/standalone/.next/build-manifest.json +3 -3
  5. package/.next/standalone/.next/prerender-manifest.json +3 -3
  6. package/.next/standalone/.next/required-server-files.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  16. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  23. package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
  24. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  25. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  26. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
  27. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +2 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +16 -16
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  36. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  37. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/page.js +1 -1
  41. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  44. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  45. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  58. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0.~nmr9._.js +3 -0
  61. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0yspgjy._.js → [root-of-the-server]__010i6f5._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08px0ym._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +3 -0
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dtn9lr._.js +3 -0
  65. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kjo7d_._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wu7fr7._.js +3 -0
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +3 -0
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z4c5dj._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zso~62._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-2wr.c._.js +4 -0
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.~m-w2._.js +4 -0
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09icjsf._.js → [root-of-the-server]__0709m8.._.js} +3 -3
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bz245.._.js +4 -0
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dl0kgt._.js +4 -0
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gmhxyo._.js +4 -0
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mup1hi._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ohb3gc._.js +4 -0
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qbpe_v._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s~gy6y._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +152 -6
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0_b7pgn._.js → [root-of-the-server]__0ymn496._.js} +2 -2
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01g_w_e._.js → [root-of-the-server]__10h.ggz._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +3 -0
  87. package/.next/standalone/.next/server/chunks/ssr/{_07a1g.3._.js → _0zx~s__._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/lib_copilot-projects_ts_0r8xkn8._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/lib_cursor-projects_ts_0qt1scg._.js +3 -0
  95. package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/ssr/lib_pi-projects_ts_103tsh1._.js +3 -0
  98. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  99. package/.next/standalone/.next/server/pages/404.html +2 -2
  100. package/.next/standalone/.next/server/pages/500.html +1 -1
  101. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  102. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  103. package/.next/standalone/.next/static/chunks/{0n-_j_6fo6jex.js → 00ay03h8bq4b~.js} +2 -2
  104. package/.next/standalone/.next/static/chunks/{11kt_9zaooda3.js → 0agmlhk5ml7x5.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
  106. package/.next/standalone/.next/static/chunks/{095l4hc7-h.~~.js → 0en4v5k2nnxks.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
  108. package/.next/standalone/.next/static/chunks/{0756i.7omnnl6.js → 0s6nux54y~l~r.js} +1 -1
  109. package/.next/standalone/.next/static/chunks/{0t~iusm_fxoao.js → 0tpse0wu2wwo0.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
  111. package/.next/standalone/.next/static/chunks/{0u-ys71jc4y68.js → 1400rtd5ywbt..js} +2 -2
  112. package/.next/standalone/.next/static/chunks/{09ose_165ra4d.js → 14lmf8boay-zu.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{0pr7k36o_.du1.js → 17htukxga7bil.js} +1 -1
  114. package/.next/standalone/.opencode/opencode.json +4 -0
  115. package/.next/standalone/.opencode/plugins/failproofai.mjs +131 -0
  116. package/.next/standalone/.pi/settings.json +5 -0
  117. package/.next/standalone/app/components/cli-badge.tsx +7 -11
  118. package/.next/standalone/app/components/project-list.tsx +32 -4
  119. package/.next/standalone/app/policies/hooks-client.tsx +31 -15
  120. package/.next/standalone/app/project/[name]/page.tsx +52 -16
  121. package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +92 -15
  122. package/.next/standalone/assets/logos/copilot-dark.svg +1 -0
  123. package/.next/standalone/assets/logos/copilot-light.svg +1 -0
  124. package/.next/standalone/assets/logos/cursor-dark.svg +1 -0
  125. package/.next/standalone/assets/logos/cursor-light.svg +1 -0
  126. package/.next/standalone/assets/logos/gemini-dark.svg +13 -0
  127. package/.next/standalone/assets/logos/gemini-light.svg +13 -0
  128. package/.next/standalone/assets/logos/opencode-dark.svg +1 -0
  129. package/.next/standalone/assets/logos/opencode-light.svg +1 -0
  130. package/.next/standalone/assets/logos/pi-dark.svg +7 -0
  131. package/.next/standalone/assets/logos/pi-light.svg +7 -0
  132. package/.next/standalone/lib/cli-registry.ts +107 -0
  133. package/.next/standalone/lib/codex-projects.ts +3 -3
  134. package/.next/standalone/lib/copilot-projects.ts +224 -0
  135. package/.next/standalone/lib/copilot-sessions.ts +395 -0
  136. package/.next/standalone/lib/cursor-projects.ts +312 -0
  137. package/.next/standalone/lib/cursor-sessions.ts +467 -0
  138. package/.next/standalone/lib/gemini-projects.ts +203 -0
  139. package/.next/standalone/lib/gemini-sessions.ts +365 -0
  140. package/.next/standalone/lib/opencode-projects.ts +232 -0
  141. package/.next/standalone/lib/opencode-sessions.ts +237 -0
  142. package/.next/standalone/lib/pi-projects.ts +230 -0
  143. package/.next/standalone/lib/pi-sessions.ts +325 -0
  144. package/.next/standalone/lib/projects.ts +67 -31
  145. package/.next/standalone/next.config.ts +5 -4
  146. package/.next/standalone/package.json +2 -1
  147. package/.next/standalone/pi-extension/index.ts +373 -0
  148. package/.next/standalone/pi-extension/package.json +12 -0
  149. package/.next/standalone/server.js +1 -1
  150. package/README.md +37 -3
  151. package/bin/failproofai.mjs +61 -21
  152. package/dist/cli.mjs +2248 -246
  153. package/lib/cli-registry.ts +107 -0
  154. package/lib/codex-projects.ts +3 -3
  155. package/lib/copilot-projects.ts +224 -0
  156. package/lib/copilot-sessions.ts +395 -0
  157. package/lib/cursor-projects.ts +312 -0
  158. package/lib/cursor-sessions.ts +467 -0
  159. package/lib/gemini-projects.ts +203 -0
  160. package/lib/gemini-sessions.ts +365 -0
  161. package/lib/opencode-projects.ts +232 -0
  162. package/lib/opencode-sessions.ts +237 -0
  163. package/lib/pi-projects.ts +230 -0
  164. package/lib/pi-sessions.ts +325 -0
  165. package/lib/projects.ts +67 -31
  166. package/package.json +2 -1
  167. package/pi-extension/index.ts +373 -0
  168. package/pi-extension/package.json +12 -0
  169. package/scripts/translate-docs/mdx-translator.ts +56 -2
  170. package/scripts/translate-docs/translator.ts +1 -1
  171. package/src/hooks/builtin-policies.ts +84 -14
  172. package/src/hooks/handler.ts +67 -5
  173. package/src/hooks/install-prompt.ts +33 -10
  174. package/src/hooks/integrations.ts +1007 -6
  175. package/src/hooks/policy-evaluator.ts +299 -3
  176. package/src/hooks/resolve-permission-mode.ts +23 -0
  177. package/src/hooks/types.ts +307 -3
  178. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +0 -3
  179. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +0 -3
  180. package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +0 -3
  181. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +0 -3
  182. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +0 -4
  183. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +0 -4
  184. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +0 -3
  185. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +0 -4
  186. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +0 -3
  187. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +0 -4
  188. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +0 -4
  189. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +0 -3
  190. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +0 -4
  191. package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +0 -3
  192. package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +0 -1
  193. package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +0 -1
  194. package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
  195. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_buildManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_clientMiddlewareManifest.js +0 -0
  197. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_ssgManifest.js +0 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * failproofai policy bridge for Pi (pi-coding-agent).
3
+ *
4
+ * This extension is loaded by Pi at startup and registered via
5
+ * `pi install <abs-path-to-this-dir> [-l]` (or by hand-authoring an entry in
6
+ * `<scope>/.pi/settings.json`). It subscribes to Pi's `tool_call`, `user_bash`,
7
+ * `input`, and `session_start` events and forwards them to the failproofai
8
+ * binary as `failproofai --hook <Event> --cli pi`. failproofai prints a
9
+ * decision JSON to stdout; this shim parses it and translates into Pi's
10
+ * `{ block: true, reason }` return shape so policy `deny` decisions cancel
11
+ * tool execution.
12
+ *
13
+ * Marker comment for failproofai's installer detection (do not remove):
14
+ * __failproofai_hook__: true
15
+ *
16
+ * Binary resolution. failproofai ships two entrypoints:
17
+ * • dist/cli.mjs — bundled, node-compatible (production npm install)
18
+ * • bin/failproofai.mjs — source, requires `bun` (dev / monorepo)
19
+ *
20
+ * dist/cli.mjs is preferred because spawning `node bin/failproofai.mjs`
21
+ * fails with ERR_IMPORT_ATTRIBUTE_MISSING (the source `import package.json`
22
+ * needs `with { type: "json" }` under node, which bun handles transparently
23
+ * but the build:cli step transpiles away in dist/cli.mjs). When dist/cli.mjs
24
+ * isn't present, fall back to running bin/failproofai.mjs with `bun`. Pi
25
+ * spawns extensions with an undefined cwd contract, so paths are resolved
26
+ * relative to this file via `import.meta.url`, NOT process.cwd().
27
+ */
28
+ import { spawnSync } from "node:child_process";
29
+ import { resolve, dirname, join } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { existsSync, readdirSync, statSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+
34
+ const HERE = dirname(fileURLToPath(import.meta.url));
35
+ const DIST_BIN = resolve(HERE, "..", "dist", "cli.mjs");
36
+ const SRC_BIN = resolve(HERE, "..", "bin", "failproofai.mjs");
37
+ // Prefer the bundled dist/cli.mjs (node-compatible); fall back to source +
38
+ // bun for dev workflows where dist/ hasn't been built yet.
39
+ function resolveSpawn(): { cmd: string; args: string[] } {
40
+ if (process.env.FAILPROOFAI_BINARY_OVERRIDE) {
41
+ return { cmd: "node", args: [process.env.FAILPROOFAI_BINARY_OVERRIDE] };
42
+ }
43
+ if (existsSync(DIST_BIN)) {
44
+ return { cmd: "node", args: [DIST_BIN] };
45
+ }
46
+ return { cmd: "bun", args: [SRC_BIN] };
47
+ }
48
+
49
+ interface PolicyDecision {
50
+ permission?: "allow" | "deny";
51
+ reason?: string;
52
+ }
53
+
54
+ /**
55
+ * Spawn `failproofai --hook <eventName> --cli pi`, write the JSON payload to
56
+ * stdin, and parse the flat `{permission, reason}` JSON we expect failproofai
57
+ * to print on stdout. Fail-open on any subprocess / parse error.
58
+ */
59
+ /** Optional stderr trace for debugging the shim. Enabled with
60
+ * FAILPROOFAI_PI_DEBUG=1; silent otherwise. */
61
+ function debug(msg: string): void {
62
+ if (process.env.FAILPROOFAI_PI_DEBUG === "1") {
63
+ process.stderr.write(`[failproofai-pi-shim] ${msg}\n`);
64
+ }
65
+ }
66
+
67
+ function callPolicy(eventName: string, payload: unknown): { block: boolean; reason: string } {
68
+ const { cmd, args } = resolveSpawn();
69
+ debug(`callPolicy event=${eventName} cmd=${cmd}`);
70
+ try {
71
+ const result = spawnSync(
72
+ cmd,
73
+ [...args, "--hook", eventName, "--cli", "pi"],
74
+ {
75
+ input: JSON.stringify(payload),
76
+ encoding: "utf8",
77
+ timeout: 60_000,
78
+ },
79
+ );
80
+ if (result.status !== 0) return { block: false, reason: "" };
81
+ const stdout = (result.stdout || "").trim();
82
+ if (!stdout) return { block: false, reason: "" };
83
+ const parsed = JSON.parse(stdout) as PolicyDecision;
84
+ if (parsed.permission === "deny") {
85
+ debug(`DENY reason=${parsed.reason}`);
86
+ return { block: true, reason: parsed.reason ?? "Blocked by failproofai" };
87
+ }
88
+ } catch (err) {
89
+ debug(`EXCEPTION ${err instanceof Error ? err.message : String(err)}`);
90
+ // Fail-open: never block tool execution because of an infra failure.
91
+ }
92
+ return { block: false, reason: "" };
93
+ }
94
+
95
+ interface PiToolCallEvent {
96
+ type?: string;
97
+ toolName?: string;
98
+ toolCallId?: string;
99
+ input?: Record<string, unknown>;
100
+ cwd?: string;
101
+ sessionId?: string;
102
+ }
103
+
104
+ /**
105
+ * Pi emits tool names in lowercase (`bash`, `read`, `edit`, `write`).
106
+ * failproofai's builtin policies match on Claude-shaped capitalized names
107
+ * (`Bash`, `Read`, `Edit`, `Write`). Map between the two so existing
108
+ * tool-name match clauses fire on Pi sessions.
109
+ */
110
+ function canonicalizeToolName(piToolName: string | undefined): string | undefined {
111
+ if (!piToolName) return undefined;
112
+ return piToolName.charAt(0).toUpperCase() + piToolName.slice(1);
113
+ }
114
+
115
+ /** Resolve the cwd for the policy payload. Pi events don't include cwd, so
116
+ * fall back to the extension's process.cwd() — which is where Pi was
117
+ * launched and where `.failproofai/` config lives. */
118
+ function resolveCwd(eventCwd: string | undefined): string {
119
+ return eventCwd ?? process.cwd();
120
+ }
121
+
122
+ /**
123
+ * Pi (verified empirically against pi-coding-agent v0.71.1) does NOT
124
+ * populate `event.sessionId` on any of its events — `session_start`,
125
+ * `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`,
126
+ * `session_shutdown` all leave it undefined. Without help the shim can't
127
+ * tag activity records with a session id, so the dashboard renders
128
+ * `Session ID: —` for every Pi row.
129
+ *
130
+ * What Pi DOES do: at session start it creates a JSONL transcript at
131
+ * `~/.pi/agent/sessions/<encodedCwd>/<isoTimestamp>_<uuid>.jsonl` where
132
+ * the filename encodes the sessionId. We discover ours by scanning the
133
+ * encoded-cwd directory for the most-recently-modified matching file.
134
+ *
135
+ * Strategy: scan once and cache. Pi runs one session per process so the
136
+ * cache is per-process and lives for the session's lifetime. If Pi ever
137
+ * multiplexes, we'd need a keyed map.
138
+ */
139
+ const PI_FILE_RE = /^[\d-]+T[\d-]+Z_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
140
+
141
+ /** Encode a cwd into Pi's on-disk session-dir name. Pi strips the leading
142
+ * `/` before replacing remaining slashes with `-`, e.g.
143
+ * `/home/u/repo` → `--home-u-repo--`. */
144
+ function piEncodeCwd(cwd: string): string {
145
+ const inner = cwd.replace(/^\/+/, "").replace(/\//g, "-");
146
+ return `--${inner}--`;
147
+ }
148
+
149
+ /** Process start boundary — files older than this aren't from the current
150
+ * Pi session. Captured at module load so cold-start in a cwd with stale
151
+ * transcripts doesn't pin a previous session's UUID. We allow a small
152
+ * tolerance below `processStartMs` because mtime resolution and clock
153
+ * skew can put a "current" file's mtime a few hundred ms before module
154
+ * load on slow startup. */
155
+ const PROCESS_START_MS = Date.now();
156
+ const STALE_TOLERANCE_MS = 2_000;
157
+
158
+ /** Find the newest `<ts>_<uuid>.jsonl` file under `~/.pi/agent/sessions/<encodedCwd>/`
159
+ * whose mtime indicates it belongs to the CURRENT Pi process (≥ process
160
+ * start, with a small tolerance). Files older than that are stale
161
+ * transcripts from prior sessions in the same cwd — caching their UUID
162
+ * would cross-attribute every event of the new session.
163
+ * Returns undefined when the dir doesn't exist, has no matching file, or
164
+ * every matching file is stale. */
165
+ function discoverPiSessionId(cwd: string): string | undefined {
166
+ const root = process.env.PI_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
167
+ const dir = join(root, piEncodeCwd(cwd));
168
+ let entries: string[];
169
+ try { entries = readdirSync(dir); } catch { return undefined; }
170
+ const boundary = PROCESS_START_MS - STALE_TOLERANCE_MS;
171
+ let best: { sessionId: string; mtime: number } | undefined;
172
+ for (const name of entries) {
173
+ const m = PI_FILE_RE.exec(name);
174
+ if (!m) continue;
175
+ let mtime: number;
176
+ try { mtime = statSync(join(dir, name)).mtimeMs; } catch { continue; }
177
+ if (mtime < boundary) continue;
178
+ if (!best || mtime > best.mtime) best = { sessionId: m[1], mtime };
179
+ }
180
+ return best?.sessionId;
181
+ }
182
+
183
+ /** sessionId cache, keyed by cwd. Per-cwd so a multi-cwd Pi (extension running
184
+ * across multiple workspace roots) can't cross-attribute. Cleared on
185
+ * session_shutdown reasons `new`/`resume`/`fork` (Pi reuses the process). */
186
+ const cachedSessionIdByCwd = new Map<string, string>();
187
+ function resolveSessionId(eventSessionId: string | undefined, cwd: string): string | undefined {
188
+ if (eventSessionId) {
189
+ cachedSessionIdByCwd.set(cwd, eventSessionId);
190
+ return eventSessionId;
191
+ }
192
+ const cached = cachedSessionIdByCwd.get(cwd);
193
+ if (cached) return cached;
194
+ // Pi v0.71.1 never sets sessionId — discover from disk.
195
+ const discovered = discoverPiSessionId(cwd);
196
+ if (discovered) cachedSessionIdByCwd.set(cwd, discovered);
197
+ return discovered;
198
+ }
199
+ /** Clear the cached sessionId for a cwd. Called on session_shutdown reasons
200
+ * that indicate a new session is starting in the same process (`new`,
201
+ * `resume`, `fork`). Without this, the next session would inherit the prior
202
+ * sessionId until disk discovery refreshed it. */
203
+ function resetSessionIdCache(cwd: string): void {
204
+ cachedSessionIdByCwd.delete(cwd);
205
+ }
206
+
207
+ interface PiUserBashEvent {
208
+ type?: string;
209
+ command?: string;
210
+ cwd?: string;
211
+ sessionId?: string;
212
+ }
213
+
214
+ interface PiInputEvent {
215
+ type?: string;
216
+ text?: string;
217
+ source?: string;
218
+ cwd?: string;
219
+ sessionId?: string;
220
+ }
221
+
222
+ interface PiSessionStartEvent {
223
+ type?: string;
224
+ reason?: string;
225
+ cwd?: string;
226
+ sessionId?: string;
227
+ }
228
+
229
+ interface PiSessionShutdownEvent {
230
+ type?: string;
231
+ /** "quit" | "reload" | "new" | "resume" | "fork" per pi-coding-agent v0.72.1 */
232
+ reason?: string;
233
+ targetSessionFile?: string;
234
+ cwd?: string;
235
+ sessionId?: string;
236
+ }
237
+
238
+ interface PiToolResultEvent {
239
+ type?: string;
240
+ toolCallId?: string;
241
+ toolName?: string;
242
+ input?: Record<string, unknown>;
243
+ /** TextContent | ImageContent — opaque to us; forwarded as-is. */
244
+ content?: unknown[];
245
+ isError?: boolean;
246
+ cwd?: string;
247
+ sessionId?: string;
248
+ }
249
+
250
+ interface PiAgentEndEvent {
251
+ type?: string;
252
+ /** AgentMessage[] — opaque; not forwarded (Stop policies don't need it). */
253
+ messages?: unknown[];
254
+ cwd?: string;
255
+ sessionId?: string;
256
+ }
257
+
258
+ interface PiExtensionApi {
259
+ on(event: string, handler: (event: unknown) => unknown): void;
260
+ }
261
+
262
+ export default function failproofaiBridge(pi: PiExtensionApi) {
263
+ // tool_call → PreToolUse. Block tool execution when failproofai denies.
264
+ pi.on("tool_call", (event: unknown): unknown => {
265
+ const e = event as PiToolCallEvent;
266
+ const decision = callPolicy("tool_call", {
267
+ tool_name: canonicalizeToolName(e.toolName),
268
+ tool_input: e.input,
269
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
270
+ cwd: resolveCwd(e.cwd),
271
+ hook_event_name: "PreToolUse",
272
+ });
273
+ if (decision.block) return { block: true, reason: decision.reason };
274
+ return undefined;
275
+ });
276
+
277
+ // user_bash → PreToolUse with synthesized toolName=Bash.
278
+ pi.on("user_bash", (event: unknown): unknown => {
279
+ const e = event as PiUserBashEvent;
280
+ const decision = callPolicy("user_bash", {
281
+ tool_name: "Bash",
282
+ tool_input: { command: e.command },
283
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
284
+ cwd: resolveCwd(e.cwd),
285
+ hook_event_name: "PreToolUse",
286
+ });
287
+ if (decision.block) return { block: true, reason: decision.reason };
288
+ return undefined;
289
+ });
290
+
291
+ // input → UserPromptSubmit. Honor block decisions if Pi accepts them
292
+ // (Pi's docs describe block on input but it's not exhaustively tested).
293
+ pi.on("input", (event: unknown): unknown => {
294
+ const e = event as PiInputEvent;
295
+ const decision = callPolicy("input", {
296
+ prompt: e.text,
297
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
298
+ cwd: resolveCwd(e.cwd),
299
+ hook_event_name: "UserPromptSubmit",
300
+ });
301
+ if (decision.block) return { block: true, reason: decision.reason };
302
+ return undefined;
303
+ });
304
+
305
+ // session_start → SessionStart. Observe-only; we still forward so the
306
+ // activity feed records the session and any UserPromptSubmit policies that
307
+ // need session_id continuity see the metadata.
308
+ pi.on("session_start", (event: unknown): unknown => {
309
+ const e = event as PiSessionStartEvent;
310
+ callPolicy("session_start", {
311
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
312
+ cwd: resolveCwd(e.cwd),
313
+ reason: e.reason,
314
+ hook_event_name: "SessionStart",
315
+ });
316
+ return undefined;
317
+ });
318
+
319
+ // tool_result → PostToolUse. Observation-only on Pi: ToolResultEventResult
320
+ // exposes {content, details, isError} for mutation but no `block`. We
321
+ // forward to the failproofai binary so PostToolUse builtins (sanitize-jwt,
322
+ // sanitize-api-keys, sanitize-connection-strings, sanitize-private-key-
323
+ // content, sanitize-bearer-tokens) run and get their decisions logged to
324
+ // the activity store + stderr — but Pi keeps the original tool result.
325
+ pi.on("tool_result", (event: unknown): unknown => {
326
+ const e = event as PiToolResultEvent;
327
+ callPolicy("tool_result", {
328
+ tool_name: canonicalizeToolName(e.toolName),
329
+ tool_input: e.input ?? {},
330
+ tool_response: { content: e.content, isError: e.isError },
331
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
332
+ cwd: resolveCwd(e.cwd),
333
+ hook_event_name: "PostToolUse",
334
+ });
335
+ return undefined;
336
+ });
337
+
338
+ // agent_end → Stop. Observation-only on Pi: the agent loop has already
339
+ // exited when this fires, so a deny decision cannot keep Pi running the
340
+ // way Claude's exit-2-from-Stop can. We still forward so the 5
341
+ // require-*-before-stop builtins run and log their findings (visible in
342
+ // the dashboard's activity feed and stderr) — best-effort visibility.
343
+ pi.on("agent_end", (event: unknown): unknown => {
344
+ const e = event as PiAgentEndEvent;
345
+ callPolicy("agent_end", {
346
+ session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
347
+ cwd: resolveCwd(e.cwd),
348
+ hook_event_name: "Stop",
349
+ });
350
+ return undefined;
351
+ });
352
+
353
+ // session_shutdown → SessionEnd. Observation-only; emits a SessionEnd
354
+ // record so per-session telemetry has a clean close. Reset the per-cwd
355
+ // sessionId cache for shutdown reasons that mean "Pi is starting a new
356
+ // session in the same process" — without the reset, the next session's
357
+ // events would inherit the prior session's id until disk discovery
358
+ // refreshed it.
359
+ pi.on("session_shutdown", (event: unknown): unknown => {
360
+ const e = event as PiSessionShutdownEvent;
361
+ const cwd = resolveCwd(e.cwd);
362
+ callPolicy("session_shutdown", {
363
+ session_id: resolveSessionId(e.sessionId, cwd),
364
+ cwd,
365
+ reason: e.reason,
366
+ hook_event_name: "SessionEnd",
367
+ });
368
+ if (e.reason === "new" || e.reason === "resume" || e.reason === "fork") {
369
+ resetSessionIdCache(cwd);
370
+ }
371
+ return undefined;
372
+ });
373
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@failproofai/pi-extension",
3
+ "version": "0.0.1",
4
+ "description": "failproofai policy bridge for Pi (pi-coding-agent)",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "private": true,
8
+ "keywords": [
9
+ "pi-extension",
10
+ "failproofai"
11
+ ]
12
+ }
@@ -14,6 +14,59 @@ import type { TranslationResult, TranslationCache } from "./types";
14
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
15
  const DOCS_DIR = join(__dirname, "..", "..", "docs");
16
16
 
17
+ /**
18
+ * Strip stray ASCII `"` that appear right after a JSX attribute's closing
19
+ * quote — e.g. `<Tab title="Tab „Richtlinien"">`. The translator sometimes
20
+ * wraps an inner phrase in language-specific typographic quotes (`„…"`,
21
+ * `「…」`, etc.) but uses an ASCII `"` for the closing instead of the
22
+ * proper U+201D, which terminates the attribute and leaves the real
23
+ * closing `"` as a stray character that breaks `mintlify validate`.
24
+ *
25
+ * Also drops unmatched typographic opening quotes inside the same attribute
26
+ * value so the rendered title doesn't end with a dangling `„` after we strip
27
+ * the extras.
28
+ */
29
+ export function sanitizeJsxAttributes(content: string): string {
30
+ // Each pair must use an OPENER that is unambiguously an opener — i.e. the
31
+ // codepoint never serves as a CLOSER of a different pair. That's why we
32
+ // skip English curly “…” (U+201C/U+201D): U+201C is also the German
33
+ // closer, so processing English curly after German would strip the very
34
+ // German closer we just preserved.
35
+ const openings: Array<[string, string]> = [
36
+ ["„", "“"], // German „ … "
37
+ ["«", "»"], // French « … »
38
+ ["‹", "›"], // French single ‹ … ›
39
+ ["「", "」"], // Japanese 「 … 」
40
+ ["『", "』"], // Japanese 『 … 』
41
+ ];
42
+ return content.replace(
43
+ /([a-zA-Z_-]+=")([^"\n]*)"+(?=\s|\/|>)/g,
44
+ (match, prefix: string, value: string) => {
45
+ // If the original had exactly one closing " (i.e. no extras),
46
+ // leave it alone — the regex's `"+` would still match a single
47
+ // quote, so we need to re-check the match length to be safe.
48
+ const expectedMinLen = `${prefix}${value}"`.length;
49
+ if (match.length === expectedMinLen) return match;
50
+ let cleaned = value;
51
+ for (const [open, close] of openings) {
52
+ const opens = cleaned.split(open).length - 1;
53
+ const closes = cleaned.split(close).length - 1;
54
+ // Drop only the surplus unmatched openers, removing from the right.
55
+ // A value like `„Foo“ und „Bar` (one matched pair plus one stray
56
+ // opener) keeps the leading `„Foo“` intact and only the dangling
57
+ // `„Bar` opener gets stripped.
58
+ let surplus = opens - closes;
59
+ while (surplus-- > 0) {
60
+ const i = cleaned.lastIndexOf(open);
61
+ if (i < 0) break;
62
+ cleaned = cleaned.slice(0, i) + cleaned.slice(i + open.length);
63
+ }
64
+ }
65
+ return `${prefix}${cleaned}"`;
66
+ },
67
+ );
68
+ }
69
+
17
70
  /**
18
71
  * Rewrite internal doc links to include the language prefix.
19
72
  * e.g. href="/built-in-policies" -> href="/es/built-in-policies"
@@ -94,8 +147,9 @@ export async function translateMdxPage(
94
147
  options.model,
95
148
  );
96
149
 
97
- // Rewrite internal links
98
- const withLinks = rewriteInternalLinks(translated, lang);
150
+ // Strip stray quote artifacts from JSX attribute values, then rewrite links
151
+ const sanitized = sanitizeJsxAttributes(translated);
152
+ const withLinks = rewriteInternalLinks(sanitized, lang);
99
153
 
100
154
  // Write output
101
155
  mkdirSync(dirname(outputPath), { recursive: true });
@@ -15,7 +15,7 @@ const SYSTEM_PROMPT = `You are a professional technical documentation translator
15
15
  ## Rules
16
16
 
17
17
  1. **Preserve all code blocks exactly as-is** — never translate content inside backtick-fenced code blocks (\`\`\`...\`\`\`) or inline code (\`...\`).
18
- 2. **Preserve MDX component syntax** — tags like <Card>, <CardGroup>, <CodeGroup>, <Steps>, <Step>, <Note>, <Tip>, <Tabs>, <Tab>, <Warning> must remain unchanged. Their attribute names (title, icon, href, cols) must remain in English. Only translate the text content of the \`title\` attribute and the text body between tags.
18
+ 2. **Preserve MDX component syntax** — tags like <Card>, <CardGroup>, <CodeGroup>, <Steps>, <Step>, <Note>, <Tip>, <Tabs>, <Tab>, <Warning> must remain unchanged. Their attribute names (title, icon, href, cols) must remain in English. Only translate the text content of the \`title\` attribute and the text body between tags. **Never put an ASCII straight \`"\` inside a \`title="…"\` (or any JSX attribute value)** — it terminates the attribute and breaks MDX parsing. If the target language would normally wrap a word in quotation marks (e.g. German „…", Japanese 「…」), drop the inner quotes inside attribute values and rely on the surrounding tag for emphasis.
19
19
  3. **Preserve YAML frontmatter keys** — only translate the string values of \`title\` and \`description\`. Keep the \`icon\` value unchanged.
20
20
  4. **Preserve all URLs and paths** — never modify href values, image paths, or links.
21
21
  5. **Preserve Markdown structure** — headers (#, ##), lists (-, *), tables (|), bold (**), italic (*), links ([text](url)) must keep their Markdown formatting.
@@ -12,27 +12,71 @@ import { hookLogWarn } from "./hook-logger";
12
12
 
13
13
  /**
14
14
  * Whether `resolved` lives under an agent CLI's home directory
15
- * (~/.claude/ or ~/.codex/). Used to whitelist agent self-reads of their own
16
- * config and transcripts.
15
+ * (~/.claude/, ~/.codex/, ~/.copilot/, ~/.cursor/, ~/.pi/, ~/.gemini/, or any
16
+ * of OpenCode's three home-side dirs). Used to whitelist agent self-reads of
17
+ * their own config and transcripts.
18
+ *
19
+ * OpenCode splits its data across three locations (verified live on
20
+ * opencode v1.14.33 via `opencode debug paths`):
21
+ * • ~/.config/opencode/ — config + plugins
22
+ * • ~/.local/share/opencode/ — sessions, snapshots, opencode.db (SQLite)
23
+ * • ~/.opencode/ — legacy fallback path
17
24
  */
18
25
  function isAgentInternalPath(resolved: string): boolean {
19
- for (const dir of [".claude", ".codex"]) {
20
- const root = join(homedir(), dir);
21
- if (resolved === root || resolved.startsWith(root + "/")) return true;
26
+ // Normalize backslashes to forward slashes so the same `startsWith` check
27
+ // works on Windows. `resolve()` returns forward slashes on POSIX but
28
+ // backslashes on Windows; `join(homedir(), ...)` follows the same OS
29
+ // convention. Comparing both sides under a single forward-slash form
30
+ // avoids per-OS branching.
31
+ const normResolved = resolved.replaceAll("\\", "/");
32
+ for (const dir of [".claude", ".codex", ".copilot", ".cursor", ".opencode", ".pi", ".gemini"]) {
33
+ const root = join(homedir(), dir).replaceAll("\\", "/");
34
+ if (normResolved === root || normResolved.startsWith(root + "/")) return true;
35
+ }
36
+ for (const sub of [join(".config", "opencode"), join(".local", "share", "opencode")]) {
37
+ const root = join(homedir(), sub).replaceAll("\\", "/");
38
+ if (normResolved === root || normResolved.startsWith(root + "/")) return true;
22
39
  }
23
40
  return false;
24
41
  }
25
42
 
26
43
  /**
27
44
  * Whether `resolved` is a settings/hooks file for an agent CLI:
28
- * • Claude Code: `.claude/settings.json`, `.claude/settings.local.json`, etc.
29
- * • Codex: `.codex/hooks.json`
45
+ * • Claude Code: `.claude/settings.json`, `.claude/settings.local.json`, etc.
46
+ * • Codex: `.codex/hooks.json`
47
+ * • Copilot CLI: `.copilot/hooks/*.json`, `.github/hooks/*.json`
48
+ * • Cursor Agent: `.cursor/hooks.json`
49
+ * • OpenCode: `.opencode/opencode.{json,jsonc}`,
50
+ * `.opencode/plugins/*.{mjs,js,ts}`,
51
+ * `~/.config/opencode/{opencode.json,opencode.jsonc,config.json}`,
52
+ * `~/.config/opencode/plugins/*.{mjs,js,ts}`
53
+ * • Pi: `.pi/settings.json` (project) and `.pi/agent/settings.json`
54
+ * (user); also the Pi-managed extension dir
55
+ * `.pi/extensions/` / `.pi/agent/extensions/`.
56
+ * • Gemini CLI: `.gemini/settings.json` (both project and user scope —
57
+ * user is `~/.gemini/settings.json`); also the Gemini-managed
58
+ * hooks scripts dir `.gemini/hooks/`.
30
59
  * These must NEVER be edited by the agent itself — that would let it disable
31
60
  * its own protections.
32
61
  */
33
62
  function isAgentSettingsFile(resolved: string): boolean {
34
63
  if (/[\\/]\.claude[\\/]settings(?:\.[^/\\]+)?\.json$/.test(resolved)) return true;
35
64
  if (/[\\/]\.codex[\\/]hooks\.json$/.test(resolved)) return true;
65
+ if (/[\\/]\.copilot[\\/]hooks[\\/][^/\\]+\.json$/.test(resolved)) return true;
66
+ if (/[\\/]\.github[\\/]hooks[\\/][^/\\]+\.json$/.test(resolved)) return true;
67
+ if (/[\\/]\.cursor[\\/]hooks\.json$/.test(resolved)) return true;
68
+ // OpenCode: project config + plugins, user config + plugins, legacy config.
69
+ if (/[\\/]\.opencode[\\/]opencode\.jsonc?$/.test(resolved)) return true;
70
+ if (/[\\/]\.opencode[\\/]plugins[\\/][^/\\]+\.(?:mjs|js|ts)$/.test(resolved)) return true;
71
+ if (/[\\/]\.config[\\/]opencode[\\/]opencode\.jsonc?$/.test(resolved)) return true;
72
+ if (/[\\/]\.config[\\/]opencode[\\/]config\.json$/.test(resolved)) return true;
73
+ if (/[\\/]\.config[\\/]opencode[\\/]plugins[\\/][^/\\]+\.(?:mjs|js|ts)$/.test(resolved)) return true;
74
+ // Pi: settings + extensions dirs (project and user-scope variants).
75
+ if (/[\\/]\.pi[\\/](?:agent[\\/])?settings\.json$/.test(resolved)) return true;
76
+ if (/[\\/]\.pi[\\/](?:agent[\\/])?extensions[\\/]/.test(resolved)) return true;
77
+ // Gemini: settings.json + hooks dir referenced by `command: $GEMINI_PROJECT_DIR/.gemini/hooks/...`.
78
+ if (/[\\/]\.gemini[\\/]settings\.json$/.test(resolved)) return true;
79
+ if (/[\\/]\.gemini[\\/]hooks[\\/]/.test(resolved)) return true;
36
80
  return false;
37
81
  }
38
82
 
@@ -735,7 +779,7 @@ function blockReadOutsideCwd(ctx: PolicyContext): PolicyResult {
735
779
  for (const p of paths) {
736
780
  const resolved = resolve(cwd, p);
737
781
  if (isClaudeSettingsFile(resolved)) {
738
- return deny(`Reading Claude settings file blocked: ${resolved}`);
782
+ return deny(`Reading agent settings file blocked: ${resolved}`);
739
783
  }
740
784
  if (isClaudeInternalPath(resolved)) continue; // Whitelist ~/.claude/
741
785
  if (resolved === "/dev/null") continue; // Harmless special file
@@ -758,7 +802,7 @@ function blockReadOutsideCwd(ctx: PolicyContext): PolicyResult {
758
802
 
759
803
  // Block settings files in any .claude directory before whitelisting
760
804
  if (isClaudeSettingsFile(resolved)) {
761
- return deny(`Reading Claude settings file blocked: ${resolved}`);
805
+ return deny(`Reading agent settings file blocked: ${resolved}`);
762
806
  }
763
807
 
764
808
  // Whitelist ~/.claude/ — Claude Code's own config, plans, memory, and settings
@@ -1365,17 +1409,44 @@ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
1365
1409
  const branch = getCurrentBranch(cwd);
1366
1410
  if (!branch || branch === "HEAD") return allow("Detached HEAD, skipping CI check.");
1367
1411
 
1368
- // 1. GitHub Actions workflow runs
1412
+ // Resolve HEAD up front — the workflow-runs filter below uses it to
1413
+ // ignore runs targeting prior commits on the same branch (otherwise a
1414
+ // stale failure on commit X is still reported after the fix on Y lands).
1415
+ // Third-party checks and commit statuses (queried by SHA below) already
1416
+ // scope to HEAD via getThirdPartyCheckRuns / getCommitStatuses.
1417
+ const sha = getHeadSha(cwd);
1418
+
1419
+ // 1. GitHub Actions workflow runs (filtered to current HEAD, deduped by name)
1369
1420
  let workflowRuns: CiCheck[] = [];
1370
1421
  try {
1422
+ // --limit 20 (was 5): a busy branch can push the latest run for some
1423
+ // workflow out of the top-5 window after the SHA filter. 20 covers
1424
+ // ~4 commits worth of runs for a 5-workflow repo without being slow.
1371
1425
  const runsJson = execFileSync(
1372
1426
  "gh",
1373
- ["run", "list", "--branch", branch, "--limit", "5", "--json", "status,conclusion,name"],
1427
+ ["run", "list", "--branch", branch, "--limit", "20", "--json", "status,conclusion,name,headSha"],
1374
1428
  { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 15000 },
1375
1429
  ).trim();
1376
1430
 
1377
1431
  if (runsJson && runsJson !== "[]") {
1378
- workflowRuns = JSON.parse(runsJson) as CiCheck[];
1432
+ const allWorkflowRuns = JSON.parse(runsJson) as Array<CiCheck & { headSha?: string }>;
1433
+ // Filter to runs targeting the current HEAD commit only — not
1434
+ // historical runs for prior commits on the same branch. When `sha`
1435
+ // is unavailable (e.g. brand-new repo with no commits) fall back
1436
+ // to the unfiltered list so the policy still has something to act on.
1437
+ const headRuns = sha
1438
+ ? allWorkflowRuns.filter((r) => r.headSha === sha)
1439
+ : allWorkflowRuns;
1440
+ // Dedupe by workflow name, keeping the first occurrence (gh run list
1441
+ // returns newest-first). This handles GitHub's "Re-run all jobs" which
1442
+ // creates a fresh run record with the same name + headSha — without
1443
+ // dedupe the older failed record would still trip the deny.
1444
+ const seen = new Set<string>();
1445
+ workflowRuns = headRuns.filter((r) => {
1446
+ if (seen.has(r.name)) return false;
1447
+ seen.add(r.name);
1448
+ return true;
1449
+ });
1379
1450
  }
1380
1451
  } catch {
1381
1452
  // fail-open for workflow runs; continue to check third-party checks
@@ -1384,7 +1455,6 @@ function requireCiGreenBeforeStop(ctx: PolicyContext): PolicyResult {
1384
1455
  // 2. Third-party check runs (CodeRabbit, SonarCloud, Codecov, etc.)
1385
1456
  let thirdPartyChecks: CiCheck[] = [];
1386
1457
  let commitStatuses: CiCheck[] = [];
1387
- const sha = getHeadSha(cwd);
1388
1458
  if (sha) {
1389
1459
  thirdPartyChecks = getThirdPartyCheckRuns(cwd, sha);
1390
1460
  commitStatuses = getCommitStatuses(cwd, sha);
@@ -1879,7 +1949,7 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1879
1949
  },
1880
1950
  {
1881
1951
  name: "require-ci-green-before-stop",
1882
- description: "Require CI checks to pass on the current branch before Claude stops",
1952
+ description: "Require CI checks to pass on the current HEAD commit before Claude stops (ignores stale runs on prior commits)",
1883
1953
  fn: requireCiGreenBeforeStop,
1884
1954
  match: { events: ["Stop"] },
1885
1955
  defaultEnabled: false,