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.
- package/.next/standalone/.cursor/hooks.json +47 -0
- package/.next/standalone/.gemini/settings.json +147 -0
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +2 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/policies/page.js +1 -1
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +2 -2
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0.~nmr9._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0yspgjy._.js → [root-of-the-server]__010i6f5._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__08px0ym._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dtn9lr._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kjo7d_._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wu7fr7._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z4c5dj._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zso~62._.js +3 -0
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-2wr.c._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.~m-w2._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09icjsf._.js → [root-of-the-server]__0709m8.._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bz245.._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dl0kgt._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gmhxyo._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mup1hi._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ohb3gc._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qbpe_v._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s~gy6y._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +152 -6
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0_b7pgn._.js → [root-of-the-server]__0ymn496._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01g_w_e._.js → [root-of-the-server]__10h.ggz._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_07a1g.3._.js → _0zx~s__._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_copilot-projects_ts_0r8xkn8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_cursor-projects_ts_0qt1scg._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_pi-projects_ts_103tsh1._.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
- package/.next/standalone/.next/static/chunks/{0n-_j_6fo6jex.js → 00ay03h8bq4b~.js} +2 -2
- package/.next/standalone/.next/static/chunks/{11kt_9zaooda3.js → 0agmlhk5ml7x5.js} +1 -1
- package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
- package/.next/standalone/.next/static/chunks/{095l4hc7-h.~~.js → 0en4v5k2nnxks.js} +1 -1
- package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
- package/.next/standalone/.next/static/chunks/{0756i.7omnnl6.js → 0s6nux54y~l~r.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0t~iusm_fxoao.js → 0tpse0wu2wwo0.js} +1 -1
- package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
- package/.next/standalone/.next/static/chunks/{0u-ys71jc4y68.js → 1400rtd5ywbt..js} +2 -2
- package/.next/standalone/.next/static/chunks/{09ose_165ra4d.js → 14lmf8boay-zu.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0pr7k36o_.du1.js → 17htukxga7bil.js} +1 -1
- package/.next/standalone/.opencode/opencode.json +4 -0
- package/.next/standalone/.opencode/plugins/failproofai.mjs +131 -0
- package/.next/standalone/.pi/settings.json +5 -0
- package/.next/standalone/app/components/cli-badge.tsx +7 -11
- package/.next/standalone/app/components/project-list.tsx +32 -4
- package/.next/standalone/app/policies/hooks-client.tsx +31 -15
- package/.next/standalone/app/project/[name]/page.tsx +52 -16
- package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +92 -15
- package/.next/standalone/assets/logos/copilot-dark.svg +1 -0
- package/.next/standalone/assets/logos/copilot-light.svg +1 -0
- package/.next/standalone/assets/logos/cursor-dark.svg +1 -0
- package/.next/standalone/assets/logos/cursor-light.svg +1 -0
- package/.next/standalone/assets/logos/gemini-dark.svg +13 -0
- package/.next/standalone/assets/logos/gemini-light.svg +13 -0
- package/.next/standalone/assets/logos/opencode-dark.svg +1 -0
- package/.next/standalone/assets/logos/opencode-light.svg +1 -0
- package/.next/standalone/assets/logos/pi-dark.svg +7 -0
- package/.next/standalone/assets/logos/pi-light.svg +7 -0
- package/.next/standalone/lib/cli-registry.ts +107 -0
- package/.next/standalone/lib/codex-projects.ts +3 -3
- package/.next/standalone/lib/copilot-projects.ts +224 -0
- package/.next/standalone/lib/copilot-sessions.ts +395 -0
- package/.next/standalone/lib/cursor-projects.ts +312 -0
- package/.next/standalone/lib/cursor-sessions.ts +467 -0
- package/.next/standalone/lib/gemini-projects.ts +203 -0
- package/.next/standalone/lib/gemini-sessions.ts +365 -0
- package/.next/standalone/lib/opencode-projects.ts +232 -0
- package/.next/standalone/lib/opencode-sessions.ts +237 -0
- package/.next/standalone/lib/pi-projects.ts +230 -0
- package/.next/standalone/lib/pi-sessions.ts +325 -0
- package/.next/standalone/lib/projects.ts +67 -31
- package/.next/standalone/next.config.ts +5 -4
- package/.next/standalone/package.json +2 -1
- package/.next/standalone/pi-extension/index.ts +373 -0
- package/.next/standalone/pi-extension/package.json +12 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +37 -3
- package/bin/failproofai.mjs +61 -21
- package/dist/cli.mjs +2248 -246
- package/lib/cli-registry.ts +107 -0
- package/lib/codex-projects.ts +3 -3
- package/lib/copilot-projects.ts +224 -0
- package/lib/copilot-sessions.ts +395 -0
- package/lib/cursor-projects.ts +312 -0
- package/lib/cursor-sessions.ts +467 -0
- package/lib/gemini-projects.ts +203 -0
- package/lib/gemini-sessions.ts +365 -0
- package/lib/opencode-projects.ts +232 -0
- package/lib/opencode-sessions.ts +237 -0
- package/lib/pi-projects.ts +230 -0
- package/lib/pi-sessions.ts +325 -0
- package/lib/projects.ts +67 -31
- package/package.json +2 -1
- package/pi-extension/index.ts +373 -0
- package/pi-extension/package.json +12 -0
- package/scripts/translate-docs/mdx-translator.ts +56 -2
- package/scripts/translate-docs/translator.ts +1 -1
- package/src/hooks/builtin-policies.ts +84 -14
- package/src/hooks/handler.ts +67 -5
- package/src/hooks/install-prompt.ts +33 -10
- package/src/hooks/integrations.ts +1007 -6
- package/src/hooks/policy-evaluator.ts +299 -3
- package/src/hooks/resolve-permission-mode.ts +23 -0
- package/src/hooks/types.ts +307 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +0 -3
- package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +0 -1
- package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +0 -1
- package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
- /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_clientMiddlewareManifest.js +0 -0
- /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
|
-
//
|
|
98
|
-
const
|
|
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
|
|
16
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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:
|
|
29
|
-
* • Codex:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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", "
|
|
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
|
-
|
|
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
|
|
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,
|