failproofai 0.0.10-beta.8 → 0.0.10
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/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +7 -7
- 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/build-manifest.json +4 -4
- 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.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/build-manifest.json +4 -4
- package/.next/standalone/.next/server/app/_not-found/page/next-font-manifest.json +6 -2
- 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 +2 -2
- 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 +1 -30
- package/.next/standalone/.next/server/app/_not-found.rsc +21 -26
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +21 -26
- 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 +10 -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 +1 -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 +21 -21
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +21 -21
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -11
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +3 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +4 -4
- package/.next/standalone/.next/server/app/page/next-font-manifest.json +6 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +2 -2
- 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/build-manifest.json +4 -4
- package/.next/standalone/.next/server/app/policies/page/next-font-manifest.json +6 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- 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/build-manifest.json +4 -4
- package/.next/standalone/.next/server/app/project/[name]/page/next-font-manifest.json +6 -2
- 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 +3 -3
- 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/build-manifest.json +4 -4
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/next-font-manifest.json +6 -2
- 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 +3 -3
- 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/build-manifest.json +4 -4
- package/.next/standalone/.next/server/app/projects/page/next-font-manifest.json +6 -2
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +3 -3
- 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]__0fjhqi9._.js → [root-of-the-server]__044xt9.._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d_ob4n._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0fe7_q_._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +1 -1
- 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]__0370~qj._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0mup1hi._.js → [root-of-the-server]__0609ezh._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07_-mkc._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e9o9ri._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0og6yo7._.js → [root-of-the-server]__0l6swv1._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0logebz._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mi5ejy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0joez.y._.js → [root-of-the-server]__0podumr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rkxer-._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rl2kwi._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0vg0uey._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ye1w50._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +32 -7
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__10._f0s._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0xb8ngh._.js +3 -0
- 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_gemini-projects_ts_0sl~yqr._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +7 -7
- package/.next/standalone/.next/server/next-font-manifest.js +1 -1
- package/.next/standalone/.next/server/next-font-manifest.json +21 -2
- package/.next/standalone/.next/server/pages/404.html +1 -30
- 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/{0k2yq8zevk9bl.js → 0j171xiqge4rv.js} +1 -1
- package/.next/standalone/.next/static/chunks/0kqar56yl~41o.js +6 -0
- package/.next/standalone/.next/static/chunks/{07i9r0t6n4cpy.js → 0lt8ko3lw.5yt.js} +1 -1
- package/.next/standalone/.next/static/chunks/0ml1.ck_5t36i.js +1 -0
- package/.next/standalone/.next/static/chunks/{0km4.rc8uvt_t.js → 0pkl..xgo-qox.js} +1 -1
- package/.next/standalone/.next/static/chunks/{12simlrcfk3g2.js → 0rnqmir4cd5p9.js} +2 -2
- package/.next/standalone/.next/static/chunks/{0bi2r.m~yokoo.js → 0w6fzf.07a24u.js} +1 -1
- package/.next/standalone/.next/static/chunks/0xbo5nl6w4lka.js +1 -0
- package/.next/standalone/.next/static/chunks/12l2t63hkyo2q.js +1 -0
- package/.next/standalone/.next/static/chunks/{0tyw4u3~2isbh.js → 12pt~2f.c1sha.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0by8zx1no6kt4.js → 14lii11wmo450.js} +1 -1
- package/.next/standalone/.next/static/chunks/179yytvmam0ug.js +1 -0
- package/.next/standalone/.next/static/chunks/17rm86uz2nd5a.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-0o7k.hakttp4k.js → turbopack-05z7a19q43zfq.js} +1 -1
- package/.next/standalone/.next/static/media/4fa387ec64143e14-s.0q3udbd2bu5yp.woff2 +0 -0
- package/.next/standalone/.next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2 +0 -0
- package/.next/standalone/.next/static/media/bbc41e54d2fcbd21-s.0gw~uztddq1df.woff2 +0 -0
- package/.next/standalone/.opencode/plugins/failproofai.mjs +75 -15
- package/.next/standalone/app/actions/get-hooks-config.ts +25 -1
- package/.next/standalone/app/components/cli-badge.tsx +1 -1
- package/.next/standalone/app/globals.css +68 -111
- package/.next/standalone/app/layout.tsx +16 -56
- package/.next/standalone/app/policies/hooks-client.tsx +228 -44
- package/.next/standalone/components/navbar.tsx +16 -15
- package/.next/standalone/components/ui/button.tsx +4 -4
- package/.next/standalone/lib/gemini-projects.ts +64 -24
- package/.next/standalone/lib/opencode-projects.ts +9 -7
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/pi-extension/index.ts +113 -12
- package/.next/standalone/readme-arch-hq.gif +0 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +54 -241
- package/dist/cli.mjs +195 -75
- package/lib/gemini-projects.ts +64 -24
- package/lib/opencode-projects.ts +9 -7
- package/package.json +2 -2
- package/pi-extension/index.ts +113 -12
- package/scripts/launch.ts +6 -22
- package/scripts/parse-script-args.ts +1 -11
- package/scripts/translate-docs/config.ts +0 -1
- package/src/hooks/handler.ts +63 -6
- package/src/hooks/integrations.ts +31 -6
- package/src/hooks/policy-evaluator.ts +34 -2
- package/src/hooks/types.ts +52 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01hj~sd._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r6nu-._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04dywib._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__06sb2gn._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09jpajs._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0jm6jnh._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t2k4c5._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u1i~9~._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/_02_tcps._.js +0 -32
- package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/_11rg2a_._.js +0 -3
- package/.next/standalone/.next/static/chunks/0gq8kvc3blri~.js +0 -1
- package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +0 -1
- package/.next/standalone/.next/static/chunks/0s41ggdsb2alw.js +0 -3
- package/.next/standalone/.next/static/chunks/0t_7i~pqwbcww.js +0 -6
- package/.next/standalone/.next/static/chunks/0xr8w5io1-kb9.css +0 -1
- package/.next/standalone/.next/static/chunks/164g0yuhpb2pi.js +0 -1
- package/.next/standalone/components/logo.tsx +0 -36
- package/.next/standalone/components/theme-toggle.tsx +0 -37
- package/.next/standalone/contexts/ThemeContext.tsx +0 -69
- package/.next/standalone/failproofai-hq.gif +0 -0
- package/.next/standalone/public/exospheresmall-dark.png +0 -0
- package/.next/standalone/public/exospheresmall.png +0 -0
- /package/.next/standalone/.next/static/{0PSH56_4bbPBaHiyPkthl → dAuQps6jUwCz9X1Q5FFOO}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{0PSH56_4bbPBaHiyPkthl → dAuQps6jUwCz9X1Q5FFOO}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{0PSH56_4bbPBaHiyPkthl → dAuQps6jUwCz9X1Q5FFOO}/_ssgManifest.js +0 -0
package/lib/opencode-projects.ts
CHANGED
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
* https://opencode.ai/docs/plugins/ (plugin model context)
|
|
23
23
|
*/
|
|
24
24
|
import { execFileSync } from "node:child_process";
|
|
25
|
-
import { basename } from "node:path";
|
|
26
25
|
import { encodeFolderName } from "./paths";
|
|
27
26
|
import type { ProjectFolder, SessionFile } from "./projects";
|
|
28
27
|
import { runtimeCache } from "./runtime-cache";
|
|
@@ -91,10 +90,11 @@ function readProjectRows(): OpenCodeProjectRow[] | null {
|
|
|
91
90
|
|
|
92
91
|
/**
|
|
93
92
|
* Group sessions by `project_id` and produce one ProjectFolder per project.
|
|
94
|
-
* The folder name
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
93
|
+
* The folder name is `encodeFolderName(worktree)` (matches every other CLI's
|
|
94
|
+
* URL-slug encoding so the dashboard's `/project/[name]` route resolves), or
|
|
95
|
+
* the project_id when no worktree is recorded. `lastModified` is the max
|
|
96
|
+
* session `time_updated` for that project (or the project's own time_updated
|
|
97
|
+
* if no sessions exist yet).
|
|
98
98
|
*/
|
|
99
99
|
export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
|
|
100
100
|
const sessions = readSessionRows();
|
|
@@ -122,13 +122,15 @@ export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
|
|
|
122
122
|
|
|
123
123
|
// Emit one ProjectFolder per project that has at least one session OR a
|
|
124
124
|
// project row (covers projects opencode knows about but hasn't run yet).
|
|
125
|
+
// `name` is the dashboard's URL slug — must be `encodeFolderName(cwd)` to
|
|
126
|
+
// match every other CLI (and the resolver in `getOpenCodeSessionsByEncodedName`).
|
|
125
127
|
const seen = new Set<string>();
|
|
126
128
|
const out: ProjectFolder[] = [];
|
|
127
129
|
for (const [projectId, group] of groups) {
|
|
128
130
|
seen.add(projectId);
|
|
129
131
|
const proj = projectMap.get(projectId);
|
|
130
132
|
const worktree = proj?.worktree ?? group.rows[0]?.directory ?? null;
|
|
131
|
-
const name =
|
|
133
|
+
const name = worktree ? encodeFolderName(worktree) : projectId;
|
|
132
134
|
const path = worktree ?? "";
|
|
133
135
|
const lastModified = new Date(Math.max(group.latest, proj?.time_updated ?? 0));
|
|
134
136
|
out.push({
|
|
@@ -143,7 +145,7 @@ export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
|
|
|
143
145
|
for (const p of projects ?? []) {
|
|
144
146
|
if (seen.has(p.id)) continue;
|
|
145
147
|
const worktree = p.worktree ?? "";
|
|
146
|
-
const name =
|
|
148
|
+
const name = worktree ? encodeFolderName(worktree) : p.id;
|
|
147
149
|
const lastModified = new Date(p.time_updated);
|
|
148
150
|
out.push({
|
|
149
151
|
name,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "failproofai",
|
|
3
|
-
"version": "0.0.10
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
|
|
5
5
|
"bin": {
|
|
6
6
|
"failproofai": "./dist/cli.mjs"
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"access": "public"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
|
+
"@anthropic-ai/sdk": "^0.93.0",
|
|
74
75
|
"@tailwindcss/postcss": "^4.1.18",
|
|
75
76
|
"@tanstack/react-virtual": "^3.13.18",
|
|
76
77
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -91,7 +92,6 @@
|
|
|
91
92
|
"tailwind-merge": "^3.4.0",
|
|
92
93
|
"tailwindcss": "^4.1.18",
|
|
93
94
|
"typescript": "^6.0.2",
|
|
94
|
-
"@anthropic-ai/sdk": "^0.93.0",
|
|
95
95
|
"vitest": "^4.0.18"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
package/pi-extension/index.ts
CHANGED
|
@@ -129,6 +129,36 @@ function canonicalizeToolName(piToolName: string | undefined): string | undefine
|
|
|
129
129
|
return PI_TOOL_MAP[piToolName] ?? piToolName;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Per-tool input-key translation. Pi's Read / Write / Edit tools deliver
|
|
134
|
+
* `path` (not `file_path`); failproofai's `block-env-files` and
|
|
135
|
+
* `block-secrets-write` builtins only read `file_path`, so without this map
|
|
136
|
+
* they silently no-op on Pi. `block-read-outside-cwd` already has a `path`
|
|
137
|
+
* fallback so it works either way. Pi's Edit tool nests `edits[{oldText,
|
|
138
|
+
* newText}]` which doesn't translate flatly to Claude's `{old_string,
|
|
139
|
+
* new_string}` — we only map the top-level `path`; the nested array stays
|
|
140
|
+
* Pi-shape (no current builtin reads it).
|
|
141
|
+
*
|
|
142
|
+
* Keep in sync with PI_TOOL_INPUT_MAP in src/hooks/types.ts.
|
|
143
|
+
*/
|
|
144
|
+
const PI_TOOL_INPUT_MAP: Record<string, Record<string, string>> = {
|
|
145
|
+
Read: { path: "file_path" },
|
|
146
|
+
Write: { path: "file_path" },
|
|
147
|
+
Edit: { path: "file_path" },
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function canonicalizeToolInput(
|
|
151
|
+
canonicalToolName: string | undefined,
|
|
152
|
+
args: Record<string, unknown> | undefined,
|
|
153
|
+
): Record<string, unknown> | undefined {
|
|
154
|
+
if (!args || typeof args !== "object" || !canonicalToolName) return args;
|
|
155
|
+
const map = PI_TOOL_INPUT_MAP[canonicalToolName];
|
|
156
|
+
if (!map) return args;
|
|
157
|
+
const out: Record<string, unknown> = {};
|
|
158
|
+
for (const k of Object.keys(args)) out[map[k] ?? k] = args[k];
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
132
162
|
/** Resolve the cwd for the policy payload. Pi events don't include cwd, so
|
|
133
163
|
* fall back to the extension's process.cwd() — which is where Pi was
|
|
134
164
|
* launched and where `.failproofai/` config lives. */
|
|
@@ -201,6 +231,24 @@ function discoverPiSessionId(cwd: string): string | undefined {
|
|
|
201
231
|
* across multiple workspace roots) can't cross-attribute. Cleared on
|
|
202
232
|
* session_shutdown reasons `new`/`resume`/`fork` (Pi reuses the process). */
|
|
203
233
|
const cachedSessionIdByCwd = new Map<string, string>();
|
|
234
|
+
|
|
235
|
+
/** Pending Stop-policy deny reason from agent_end, keyed by sessionId.
|
|
236
|
+
* Drained by before_agent_start on the next user turn in the same Pi
|
|
237
|
+
* process. Cleared on every session_shutdown.
|
|
238
|
+
*
|
|
239
|
+
* Why this exists: Pi's agent_end has no Result type — the agent loop
|
|
240
|
+
* has already exited when it fires, so a deny return cannot keep Pi
|
|
241
|
+
* running the way Claude's exit-2-from-Stop does. The closest analog
|
|
242
|
+
* is to capture the deny here and re-inject it as a MANDATORY ACTION
|
|
243
|
+
* system-prompt addition on the NEXT before_agent_start, which fires
|
|
244
|
+
* after the user submits a prompt but before the agent loop runs.
|
|
245
|
+
* Best-effort: bounded by the Pi process lifetime — same bound Claude
|
|
246
|
+
* has on exit-2-from-Stop (kill the agent and the gate is missed).
|
|
247
|
+
*
|
|
248
|
+
* Why per-session not per-cwd: a Pi process can host multiple sessions
|
|
249
|
+
* via /resume and /fork; per-cwd would cross-attribute a stale block
|
|
250
|
+
* from a prior session into a fresh one. */
|
|
251
|
+
const pendingStopBlockBySession = new Map<string, string>();
|
|
204
252
|
function resolveSessionId(eventSessionId: string | undefined, cwd: string): string | undefined {
|
|
205
253
|
if (eventSessionId) {
|
|
206
254
|
cachedSessionIdByCwd.set(cwd, eventSessionId);
|
|
@@ -272,6 +320,17 @@ interface PiAgentEndEvent {
|
|
|
272
320
|
sessionId?: string;
|
|
273
321
|
}
|
|
274
322
|
|
|
323
|
+
/** Pi v0.73.x before_agent_start event payload. Fires once per turn,
|
|
324
|
+
* after the user submits a prompt but before the agent loop runs. */
|
|
325
|
+
interface PiBeforeAgentStartEvent {
|
|
326
|
+
type?: string;
|
|
327
|
+
prompt?: string;
|
|
328
|
+
/** The fully assembled system prompt for this turn — we append to it. */
|
|
329
|
+
systemPrompt?: string;
|
|
330
|
+
cwd?: string;
|
|
331
|
+
sessionId?: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
275
334
|
interface PiExtensionApi {
|
|
276
335
|
on(event: string, handler: (event: unknown) => unknown): void;
|
|
277
336
|
}
|
|
@@ -280,9 +339,10 @@ export default function failproofaiBridge(pi: PiExtensionApi) {
|
|
|
280
339
|
// tool_call → PreToolUse. Block tool execution when failproofai denies.
|
|
281
340
|
pi.on("tool_call", (event: unknown): unknown => {
|
|
282
341
|
const e = event as PiToolCallEvent;
|
|
342
|
+
const canonicalTool = canonicalizeToolName(e.toolName);
|
|
283
343
|
const decision = callPolicy("tool_call", {
|
|
284
|
-
tool_name:
|
|
285
|
-
tool_input: e.input,
|
|
344
|
+
tool_name: canonicalTool,
|
|
345
|
+
tool_input: canonicalizeToolInput(canonicalTool, e.input),
|
|
286
346
|
session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
|
|
287
347
|
cwd: resolveCwd(e.cwd),
|
|
288
348
|
hook_event_name: "PreToolUse",
|
|
@@ -341,9 +401,10 @@ export default function failproofaiBridge(pi: PiExtensionApi) {
|
|
|
341
401
|
// the activity store + stderr — but Pi keeps the original tool result.
|
|
342
402
|
pi.on("tool_result", (event: unknown): unknown => {
|
|
343
403
|
const e = event as PiToolResultEvent;
|
|
404
|
+
const canonicalTool = canonicalizeToolName(e.toolName);
|
|
344
405
|
callPolicy("tool_result", {
|
|
345
|
-
tool_name:
|
|
346
|
-
tool_input: e.input ?? {},
|
|
406
|
+
tool_name: canonicalTool,
|
|
407
|
+
tool_input: canonicalizeToolInput(canonicalTool, e.input) ?? {},
|
|
347
408
|
tool_response: { content: e.content, isError: e.isError },
|
|
348
409
|
session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)),
|
|
349
410
|
cwd: resolveCwd(e.cwd),
|
|
@@ -352,21 +413,51 @@ export default function failproofaiBridge(pi: PiExtensionApi) {
|
|
|
352
413
|
return undefined;
|
|
353
414
|
});
|
|
354
415
|
|
|
355
|
-
// agent_end → Stop.
|
|
356
|
-
// exited when this fires
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
416
|
+
// agent_end → Stop. Pi cannot veto agent_end (the agent loop has already
|
|
417
|
+
// exited when this fires — see the AgentEndEvent typedef in pi-coding-agent
|
|
418
|
+
// which has NO Result type). Instead we capture any deny reason and stash
|
|
419
|
+
// it keyed by sessionId for the next before_agent_start handler to drain.
|
|
420
|
+
// The 5 require-*-before-stop builtins thus enforce by gating the NEXT
|
|
421
|
+
// user turn's system prompt rather than by retrying the same loop. If the
|
|
422
|
+
// user kills Pi between turns, the gate is missed — same bound Claude has.
|
|
360
423
|
pi.on("agent_end", (event: unknown): unknown => {
|
|
361
424
|
const e = event as PiAgentEndEvent;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
425
|
+
const cwd = resolveCwd(e.cwd);
|
|
426
|
+
const sessionId = resolveSessionId(e.sessionId, cwd);
|
|
427
|
+
const decision = callPolicy("agent_end", {
|
|
428
|
+
session_id: sessionId,
|
|
429
|
+
cwd,
|
|
365
430
|
hook_event_name: "Stop",
|
|
366
431
|
});
|
|
432
|
+
if (decision.block && decision.reason && sessionId) {
|
|
433
|
+
pendingStopBlockBySession.set(sessionId, decision.reason);
|
|
434
|
+
debug(`agent_end deny stored for session=${sessionId}`);
|
|
435
|
+
}
|
|
367
436
|
return undefined;
|
|
368
437
|
});
|
|
369
438
|
|
|
439
|
+
// before_agent_start → drain any pending Stop-policy deny captured at
|
|
440
|
+
// agent_end. This is Pi's only first-class channel to influence the next
|
|
441
|
+
// turn before the LLM call: the result type accepts a `systemPrompt`
|
|
442
|
+
// replacement (chained across extensions) and an optional injected
|
|
443
|
+
// CustomMessage. We only return systemPrompt — sufficient for the LLM to
|
|
444
|
+
// see the MANDATORY ACTION directive immediately, and avoids polluting the
|
|
445
|
+
// visible conversation history with framework chrome. The reason text
|
|
446
|
+
// already carries the policy-attributed MANDATORY ACTION wording from
|
|
447
|
+
// policy-evaluator's Pi-Stop branch.
|
|
448
|
+
pi.on("before_agent_start", (event: unknown): unknown => {
|
|
449
|
+
const e = event as PiBeforeAgentStartEvent;
|
|
450
|
+
const cwd = resolveCwd(e.cwd);
|
|
451
|
+
const sessionId = resolveSessionId(e.sessionId, cwd);
|
|
452
|
+
if (!sessionId) return undefined;
|
|
453
|
+
const pending = pendingStopBlockBySession.get(sessionId);
|
|
454
|
+
if (!pending) return undefined;
|
|
455
|
+
pendingStopBlockBySession.delete(sessionId);
|
|
456
|
+
debug(`before_agent_start drains stop-block for session=${sessionId}`);
|
|
457
|
+
const base = e.systemPrompt ?? "";
|
|
458
|
+
return { systemPrompt: `${base}\n\n${pending}` };
|
|
459
|
+
});
|
|
460
|
+
|
|
370
461
|
// session_shutdown → SessionEnd. Observation-only; emits a SessionEnd
|
|
371
462
|
// record so per-session telemetry has a clean close. Reset the per-cwd
|
|
372
463
|
// sessionId cache for shutdown reasons that mean "Pi is starting a new
|
|
@@ -382,9 +473,19 @@ export default function failproofaiBridge(pi: PiExtensionApi) {
|
|
|
382
473
|
reason: e.reason,
|
|
383
474
|
hook_event_name: "SessionEnd",
|
|
384
475
|
});
|
|
476
|
+
// Capture sessionId BEFORE the cache reset so we delete the pending
|
|
477
|
+
// entry under the just-ending session's id. After resetSessionIdCache,
|
|
478
|
+
// a subsequent resolveSessionId would re-discover from disk and could
|
|
479
|
+
// bind to a different (stale) file — wrong key for the cleanup below.
|
|
480
|
+
const sessionId = resolveSessionId(e.sessionId, cwd);
|
|
385
481
|
if (e.reason === "new" || e.reason === "resume" || e.reason === "fork") {
|
|
386
482
|
resetSessionIdCache(cwd);
|
|
387
483
|
}
|
|
484
|
+
// Drop any pending Stop-policy deny for this session on every shutdown
|
|
485
|
+
// reason — `quit` ends the session for good (don't leak the entry into
|
|
486
|
+
// GC); `new`/`resume`/`fork` start a different session in the same
|
|
487
|
+
// process and must not inherit the prior session's gate.
|
|
488
|
+
if (sessionId) pendingStopBlockBySession.delete(sessionId);
|
|
388
489
|
return undefined;
|
|
389
490
|
});
|
|
390
491
|
}
|
package/scripts/launch.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared launch logic for dev.ts and start.ts.
|
|
3
3
|
*/
|
|
4
|
-
import { getDefaultClaudeProjectsPath } from "../lib/paths";
|
|
5
4
|
import { spawn } from "child_process";
|
|
6
5
|
import { realpathSync, existsSync } from "node:fs";
|
|
7
6
|
import { resolve, dirname } from "node:path";
|
|
@@ -11,31 +10,17 @@ import { diagnoseShadow } from "./install-diagnosis.mjs";
|
|
|
11
10
|
import { version } from "../package.json";
|
|
12
11
|
|
|
13
12
|
export function launch(mode: "dev" | "start"): void {
|
|
14
|
-
const {
|
|
13
|
+
const { loggingLevel, disableTelemetry, allowedDevOrigins, remainingArgs } = parseScriptArgs(process.argv.slice(2));
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/_/ \\__,_/_/_/ .___/_/ \\____/\\____/_/ /_/ |_/___/
|
|
22
|
-
/_/ v${version}
|
|
23
|
-
`);
|
|
15
|
+
// Plain-text title + a labeled `Version` line that lines up with the
|
|
16
|
+
// `Star us` / `Docs` / `Slack` lines below (all four labels pad to the
|
|
17
|
+
// same column so the values form a clean right-hand column).
|
|
18
|
+
console.log(`\n failproof ai\n`);
|
|
19
|
+
console.log(` 📦 Version: ${version}`);
|
|
24
20
|
console.log(` ⭐ Star us: https://github.com/exospherehost/failproofai`);
|
|
25
21
|
console.log(` 📖 Docs: https://befailproof.ai`);
|
|
26
22
|
console.log(` 💬 Slack: https://join.slack.com/t/failproofai/shared_invite/zt-3v63b7k5e-O3NBHmj8X6n9gZSGDx6ggQ\n`);
|
|
27
23
|
|
|
28
|
-
let claudeProjectsPath = parsedPath;
|
|
29
|
-
|
|
30
|
-
if (!claudeProjectsPath) {
|
|
31
|
-
claudeProjectsPath = getDefaultClaudeProjectsPath();
|
|
32
|
-
console.log(`Using default .claude projects path: ${claudeProjectsPath}`);
|
|
33
|
-
} else {
|
|
34
|
-
console.log(`Using custom .claude projects path: ${claudeProjectsPath}`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
process.env.CLAUDE_PROJECTS_PATH = claudeProjectsPath;
|
|
38
|
-
|
|
39
24
|
let cmd: string;
|
|
40
25
|
let cmdArgs: string[];
|
|
41
26
|
if (mode === "start") {
|
|
@@ -98,7 +83,6 @@ export function launch(mode: "dev" | "start"): void {
|
|
|
98
83
|
stdio: "inherit",
|
|
99
84
|
env: {
|
|
100
85
|
...process.env,
|
|
101
|
-
CLAUDE_PROJECTS_PATH: claudeProjectsPath,
|
|
102
86
|
...(loggingLevel ? { FAILPROOFAI_LOG_LEVEL: loggingLevel } : {}),
|
|
103
87
|
...(disableTelemetry ? { FAILPROOFAI_TELEMETRY_DISABLED: "1" } : {}),
|
|
104
88
|
...(allowedDevOrigins ? { FAILPROOFAI_ALLOWED_DEV_ORIGINS: allowedDevOrigins.join(",") } : {}),
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
import { resolve } from "path";
|
|
5
5
|
|
|
6
6
|
export interface ParsedScriptArgs {
|
|
7
|
-
claudeProjectsPath: string | undefined;
|
|
8
7
|
loggingLevel: string | undefined;
|
|
9
8
|
disableTelemetry: boolean;
|
|
10
9
|
allowedDevOrigins: string[] | undefined;
|
|
@@ -30,7 +29,6 @@ function parseStringFlag(
|
|
|
30
29
|
|
|
31
30
|
export function parseScriptArgs(argv: string[]): ParsedScriptArgs {
|
|
32
31
|
const args = [...argv];
|
|
33
|
-
let claudeProjectsPath: string | undefined;
|
|
34
32
|
let loggingLevel: string | undefined;
|
|
35
33
|
let disableTelemetry = false;
|
|
36
34
|
let allowedDevOrigins: string[] | undefined;
|
|
@@ -42,14 +40,6 @@ export function parseScriptArgs(argv: string[]): ParsedScriptArgs {
|
|
|
42
40
|
const flag = eqIdx >= 0 ? arg.slice(0, eqIdx) : arg;
|
|
43
41
|
const inlineValue = eqIdx >= 0 ? arg.slice(eqIdx + 1) : null;
|
|
44
42
|
|
|
45
|
-
if (flag === "--projects-path" || flag === "-p") {
|
|
46
|
-
const { value, spliceCount } = parseStringFlag(flag, "a path argument", inlineValue, args, i);
|
|
47
|
-
claudeProjectsPath = value;
|
|
48
|
-
args.splice(i, spliceCount);
|
|
49
|
-
i--;
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
43
|
if (flag === "--logging") {
|
|
54
44
|
const raw = inlineValue ?? args[i + 1];
|
|
55
45
|
if (raw === undefined || (inlineValue === null && raw.startsWith("-"))) {
|
|
@@ -83,5 +73,5 @@ export function parseScriptArgs(argv: string[]): ParsedScriptArgs {
|
|
|
83
73
|
}
|
|
84
74
|
}
|
|
85
75
|
|
|
86
|
-
return {
|
|
76
|
+
return { loggingLevel, disableTelemetry, allowedDevOrigins, remainingArgs: args };
|
|
87
77
|
}
|
package/src/hooks/handler.ts
CHANGED
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
COPILOT_TOOL_MAP,
|
|
24
24
|
CURSOR_TOOL_MAP,
|
|
25
25
|
CODEX_TOOL_MAP,
|
|
26
|
+
OPENCODE_TOOL_MAP,
|
|
27
|
+
OPENCODE_TOOL_INPUT_MAP,
|
|
28
|
+
PI_TOOL_MAP,
|
|
29
|
+
PI_TOOL_INPUT_MAP,
|
|
26
30
|
} from "./types";
|
|
27
31
|
import type { PolicyFunction, PolicyResult } from "./policy-types";
|
|
28
32
|
import { readMergedHooksConfig } from "./hooks-config";
|
|
@@ -87,9 +91,13 @@ function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType
|
|
|
87
91
|
* • Cursor: PascalCase per Cursor docs but uses `Shell` for the bash-
|
|
88
92
|
* equivalent — CURSOR_TOOL_MAP rewrites `Shell → Bash`; other
|
|
89
93
|
* tool names already canonical and pass through
|
|
90
|
-
* • OpenCode:
|
|
91
|
-
*
|
|
92
|
-
*
|
|
94
|
+
* • OpenCode: lowercase IDs (`bash`, `read`, …) — OPENCODE_TOOL_MAP. The
|
|
95
|
+
* OpenCode plugin shim ALSO canonicalizes inline as defense-in-
|
|
96
|
+
* depth; both passes are idempotent. Handler-side coverage
|
|
97
|
+
* here means a stale user-scope shim that pre-dates #337 still
|
|
98
|
+
* gets the canonicalization, without forcing a re-install.
|
|
99
|
+
* • Pi: lowercase IDs (`bash`, `read`, …) — PI_TOOL_MAP. Same dual-
|
|
100
|
+
* canonicalization story as OpenCode (shim + handler).
|
|
93
101
|
* • Gemini: snake_case — GEMINI_TOOL_MAP
|
|
94
102
|
*
|
|
95
103
|
* Unknown tool names (MCP `mcp_*`, third-party extensions, Skills) pass
|
|
@@ -101,9 +109,48 @@ function canonicalizeToolName(raw: string | undefined, cli: IntegrationType): st
|
|
|
101
109
|
if (cli === "cursor") return CURSOR_TOOL_MAP[raw] ?? raw;
|
|
102
110
|
if (cli === "codex") return CODEX_TOOL_MAP[raw] ?? raw;
|
|
103
111
|
if (cli === "gemini") return GEMINI_TOOL_MAP[raw] ?? raw;
|
|
112
|
+
if (cli === "opencode") return OPENCODE_TOOL_MAP[raw] ?? raw;
|
|
113
|
+
if (cli === "pi") return PI_TOOL_MAP[raw] ?? raw;
|
|
104
114
|
return raw;
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Canonicalize per-CLI tool-input keys to the snake_case shape that builtin
|
|
119
|
+
* policies read (e.g. `file_path`, `old_string`). OpenCode delivers args as
|
|
120
|
+
* camelCase (`filePath`, `oldString`, `newString`, `replaceAll`); Pi delivers
|
|
121
|
+
* `path` for Read/Write/Edit. Without translation, `getFilePath()` reads "" and
|
|
122
|
+
* the path-checking builtins (`block-read-outside-cwd`, `block-env-files`,
|
|
123
|
+
* `block-secrets-write`) silently no-op.
|
|
124
|
+
*
|
|
125
|
+
* Both CLIs' shims canonicalize inline before the JSON crosses to this binary.
|
|
126
|
+
* Handler-side coverage here is defense-in-depth: a user-scope shim that pre-
|
|
127
|
+
* dates #337 still passes the raw camelCase keys, and we want those installs
|
|
128
|
+
* to start enforcing the moment failproofai upgrades — without requiring a
|
|
129
|
+
* `failproofai policies --install --cli opencode` re-run.
|
|
130
|
+
*
|
|
131
|
+
* Idempotent: when the shim already canonicalized, the keys are snake_case
|
|
132
|
+
* and the per-tool map's camelCase keys don't match, so the loop is a no-op.
|
|
133
|
+
*
|
|
134
|
+
* Tools outside the per-CLI map (MCP `mcp_*`, third-party extensions) pass
|
|
135
|
+
* through unchanged so their schemas aren't corrupted.
|
|
136
|
+
*/
|
|
137
|
+
function canonicalizeToolInput(
|
|
138
|
+
toolName: string | undefined,
|
|
139
|
+
rawInput: unknown,
|
|
140
|
+
cli: IntegrationType,
|
|
141
|
+
): unknown {
|
|
142
|
+
if (!toolName || !rawInput || typeof rawInput !== "object") return rawInput;
|
|
143
|
+
let perToolMap: Record<string, string> | undefined;
|
|
144
|
+
if (cli === "opencode") perToolMap = OPENCODE_TOOL_INPUT_MAP[toolName];
|
|
145
|
+
else if (cli === "pi") perToolMap = PI_TOOL_INPUT_MAP[toolName];
|
|
146
|
+
if (!perToolMap) return rawInput;
|
|
147
|
+
const out: Record<string, unknown> = {};
|
|
148
|
+
for (const [k, v] of Object.entries(rawInput as Record<string, unknown>)) {
|
|
149
|
+
out[perToolMap[k] ?? k] = v;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
107
154
|
export async function handleHookEvent(
|
|
108
155
|
eventType: string,
|
|
109
156
|
cli: IntegrationType = "claude",
|
|
@@ -151,15 +198,25 @@ export async function handleHookEvent(
|
|
|
151
198
|
|
|
152
199
|
// Canonicalize tool name in place so both the policy-registry tool-name
|
|
153
200
|
// filter and policy bodies (`ctx.toolName === "Bash"`) see the canonical
|
|
154
|
-
// form.
|
|
155
|
-
//
|
|
156
|
-
// telemetry tagging consistent (they read from `parsed.tool_name`).
|
|
201
|
+
// form. Mutating `parsed.tool_name` keeps the activity store + telemetry
|
|
202
|
+
// tagging consistent (they read from `parsed.tool_name`).
|
|
157
203
|
const rawToolName = parsed.tool_name as string | undefined;
|
|
158
204
|
const canonicalToolName = canonicalizeToolName(rawToolName, cli);
|
|
159
205
|
if (canonicalToolName !== rawToolName) {
|
|
160
206
|
parsed.tool_name = canonicalToolName;
|
|
161
207
|
}
|
|
162
208
|
|
|
209
|
+
// Canonicalize tool-input keys for OpenCode + Pi (no-op for other CLIs).
|
|
210
|
+
// Defense-in-depth against stale shims that still pass camelCase /
|
|
211
|
+
// Pi-shape keys to the binary. The per-CLI shim ALSO canonicalizes; both
|
|
212
|
+
// passes are idempotent because the camelCase keys won't match a
|
|
213
|
+
// snake_case input.
|
|
214
|
+
const rawInput = parsed.tool_input;
|
|
215
|
+
const canonicalInput = canonicalizeToolInput(canonicalToolName, rawInput, cli);
|
|
216
|
+
if (canonicalInput !== rawInput) {
|
|
217
|
+
parsed.tool_input = canonicalInput;
|
|
218
|
+
}
|
|
219
|
+
|
|
163
220
|
// Extract session metadata from payload
|
|
164
221
|
const sessionId = parsed.session_id as string | undefined;
|
|
165
222
|
const session: SessionMetadata = {
|
|
@@ -750,6 +750,28 @@ function canonicalizeTool(raw) {
|
|
|
750
750
|
return TOOL_NAME_MAP[raw] != null ? TOOL_NAME_MAP[raw] : raw;
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
// Per-tool input-key translation: opencode native tools deliver args as
|
|
754
|
+
// camelCase (\`filePath\`, \`oldString\`, …) but failproofai builtin policies
|
|
755
|
+
// (\`block-read-outside-cwd\`, \`block-env-files\`, \`block-secrets-write\`)
|
|
756
|
+
// read \`ctx.toolInput.file_path\` etc. Without this map every Read/Write/Edit
|
|
757
|
+
// path-check silently no-ops on opencode. Keys are PascalCase canonical tool
|
|
758
|
+
// names so the lookup pairs with canonicalizeTool's output. Tools outside the
|
|
759
|
+
// map (MCP \`mcp_*\`, plugins) pass through unchanged. Keep in sync with
|
|
760
|
+
// OPENCODE_TOOL_INPUT_MAP in failproofai/src/hooks/types.ts.
|
|
761
|
+
const TOOL_INPUT_MAP = {
|
|
762
|
+
Read: { filePath: "file_path" },
|
|
763
|
+
Write: { filePath: "file_path" },
|
|
764
|
+
Edit: { filePath: "file_path", oldString: "old_string", newString: "new_string", replaceAll: "replace_all" },
|
|
765
|
+
};
|
|
766
|
+
function canonicalizeToolInput(canonicalToolName, args) {
|
|
767
|
+
if (!args || typeof args !== "object") return args;
|
|
768
|
+
const map = TOOL_INPUT_MAP[canonicalToolName];
|
|
769
|
+
if (!map) return args;
|
|
770
|
+
const out = {};
|
|
771
|
+
for (const k of Object.keys(args)) out[map[k] != null ? map[k] : k] = args[k];
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
|
|
753
775
|
const FAILPROOFAI_BIN = ${escapedBin};
|
|
754
776
|
const USE_NPX = ${useNpx};
|
|
755
777
|
|
|
@@ -848,11 +870,12 @@ export default async function failproofaiPlugin({ client, directory }) {
|
|
|
848
870
|
|
|
849
871
|
// First-class PreToolUse hook. Note: tool args live on output.args (mutable).
|
|
850
872
|
"tool.execute.before": async (input, output) => {
|
|
873
|
+
const canonicalTool = canonicalizeTool(input.tool);
|
|
851
874
|
const r = runFailproofai("PreToolUse", {
|
|
852
875
|
session_id: input.sessionID,
|
|
853
876
|
cwd: directory,
|
|
854
|
-
tool_name:
|
|
855
|
-
tool_input: output.args,
|
|
877
|
+
tool_name: canonicalTool,
|
|
878
|
+
tool_input: canonicalizeToolInput(canonicalTool, output.args),
|
|
856
879
|
hook_event_name: "PreToolUse",
|
|
857
880
|
}, directory);
|
|
858
881
|
await applyDecision(r, { client, sessionID: input.sessionID }, "PreToolUse");
|
|
@@ -860,11 +883,12 @@ export default async function failproofaiPlugin({ client, directory }) {
|
|
|
860
883
|
|
|
861
884
|
// First-class PostToolUse hook. Note: tool args live on input.args here.
|
|
862
885
|
"tool.execute.after": async (input, output) => {
|
|
886
|
+
const canonicalTool = canonicalizeTool(input.tool);
|
|
863
887
|
const r = runFailproofai("PostToolUse", {
|
|
864
888
|
session_id: input.sessionID,
|
|
865
889
|
cwd: directory,
|
|
866
|
-
tool_name:
|
|
867
|
-
tool_input: input.args,
|
|
890
|
+
tool_name: canonicalTool,
|
|
891
|
+
tool_input: canonicalizeToolInput(canonicalTool, input.args),
|
|
868
892
|
tool_response: { title: output.title, output: output.output, metadata: output.metadata },
|
|
869
893
|
hook_event_name: "PostToolUse",
|
|
870
894
|
}, directory);
|
|
@@ -873,11 +897,12 @@ export default async function failproofaiPlugin({ client, directory }) {
|
|
|
873
897
|
|
|
874
898
|
// Cleaner deny UX for prompted tools — mutate output.status instead of throwing.
|
|
875
899
|
"permission.ask": async (input, output) => {
|
|
900
|
+
const canonicalTool = canonicalizeTool(input.tool);
|
|
876
901
|
const r = runFailproofai("PermissionRequest", {
|
|
877
902
|
session_id: input.sessionID,
|
|
878
903
|
cwd: directory,
|
|
879
|
-
tool_name:
|
|
880
|
-
tool_input: input,
|
|
904
|
+
tool_name: canonicalTool || input.command || "permission",
|
|
905
|
+
tool_input: canonicalizeToolInput(canonicalTool, input),
|
|
881
906
|
hook_event_name: "PermissionRequest",
|
|
882
907
|
}, directory);
|
|
883
908
|
try {
|
|
@@ -184,8 +184,22 @@ export async function evaluatePolicies(
|
|
|
184
184
|
// and translates `permission === "deny"` into a `{block: true, reason}`
|
|
185
185
|
// return value from its `pi.on("tool_call", ...)` handler. Pi has no
|
|
186
186
|
// event-specific decision wrappers, so all events flow through the
|
|
187
|
-
// same flat shape
|
|
187
|
+
// same flat shape — except Stop, where we emit the MANDATORY ACTION
|
|
188
|
+
// wording so the shim can re-inject it as a system-prompt suffix on
|
|
189
|
+
// the next before_agent_start (Pi cannot veto agent_end directly).
|
|
190
|
+
// Mirrors the Cursor/Gemini/Copilot/OpenCode Stop branches above.
|
|
188
191
|
if (session?.cli === "pi") {
|
|
192
|
+
if (eventType === "Stop") {
|
|
193
|
+
const reasonText = `MANDATORY ACTION REQUIRED from failproofai (policy: ${policy.name}): ${reason}\n\nYou MUST complete the above action NOW. Do NOT ask the user for confirmation — execute the required action, then attempt to finish your task again.`;
|
|
194
|
+
return {
|
|
195
|
+
exitCode: 0,
|
|
196
|
+
stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
|
|
197
|
+
stderr: "",
|
|
198
|
+
policyName: policy.name,
|
|
199
|
+
reason,
|
|
200
|
+
decision: "deny",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
189
203
|
const response = {
|
|
190
204
|
permission: "deny",
|
|
191
205
|
reason: blockedMessage,
|
|
@@ -416,8 +430,26 @@ export async function evaluatePolicies(
|
|
|
416
430
|
// Pi: instruct emits `{permission: "allow", reason}`. The shim won't
|
|
417
431
|
// block (no `"deny"`); it surfaces `reason` to the user where possible
|
|
418
432
|
// (Pi has no first-class `additional_context` channel in its tool-call
|
|
419
|
-
// return shape, so we log it).
|
|
433
|
+
// return shape, so we log it). Stop is the exception — we emit a
|
|
434
|
+
// `permission: "deny"` with the MANDATORY ACTION wording so the shim
|
|
435
|
+
// captures it for next-turn before_agent_start injection. Same handoff
|
|
436
|
+
// contract as the deny branch above.
|
|
420
437
|
if (session?.cli === "pi") {
|
|
438
|
+
if (eventType === "Stop") {
|
|
439
|
+
const policyAttribution = policyNames.length === 1
|
|
440
|
+
? `policy: ${policyNames[0]}`
|
|
441
|
+
: `policies: ${policyNames.join(", ")}`;
|
|
442
|
+
const reasonText = `MANDATORY ACTION REQUIRED from failproofai (${policyAttribution}): ${combined}\n\nYou MUST complete the above action(s) NOW. Do NOT ask the user for confirmation — execute the required action(s), then attempt to finish your task again.`;
|
|
443
|
+
return {
|
|
444
|
+
exitCode: 0,
|
|
445
|
+
stdout: JSON.stringify({ permission: "deny", reason: reasonText }),
|
|
446
|
+
stderr: "",
|
|
447
|
+
policyName: policyNames[0],
|
|
448
|
+
policyNames,
|
|
449
|
+
reason: combined,
|
|
450
|
+
decision: "instruct",
|
|
451
|
+
};
|
|
452
|
+
}
|
|
421
453
|
const response = {
|
|
422
454
|
permission: "allow",
|
|
423
455
|
reason: `Instruction from failproofai: ${combined}`,
|
package/src/hooks/types.ts
CHANGED
|
@@ -279,6 +279,32 @@ export const OPENCODE_TOOL_MAP: Record<string, string> = {
|
|
|
279
279
|
todoread: "TodoRead",
|
|
280
280
|
};
|
|
281
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Per-tool input-key translation: OpenCode camelCase → Claude snake_case,
|
|
284
|
+
* keyed by canonical (PascalCase) tool name so it pairs naturally with the
|
|
285
|
+
* output of OPENCODE_TOOL_MAP. Without this, builtin policies that read
|
|
286
|
+
* `ctx.toolInput.file_path` (`block-read-outside-cwd`, `block-env-files`,
|
|
287
|
+
* `block-secrets-write`) silently no-op on every OpenCode Read/Write/Edit
|
|
288
|
+
* call because OpenCode's native tools deliver args as `filePath` / `oldString`
|
|
289
|
+
* / `newString` / `replaceAll`.
|
|
290
|
+
*
|
|
291
|
+
* Tools outside this set (MCP `mcp_*`, third-party plugins) pass through
|
|
292
|
+
* unchanged so their schemas aren't corrupted. Mirrored inline in the shim
|
|
293
|
+
* template at src/hooks/integrations.ts:buildOpenCodePluginShim — the shim
|
|
294
|
+
* must be self-contained because opencode loads it as a JS module — so any
|
|
295
|
+
* change here MUST be mirrored there.
|
|
296
|
+
*/
|
|
297
|
+
export const OPENCODE_TOOL_INPUT_MAP: Record<string, Record<string, string>> = {
|
|
298
|
+
Read: { filePath: "file_path" },
|
|
299
|
+
Write: { filePath: "file_path" },
|
|
300
|
+
Edit: {
|
|
301
|
+
filePath: "file_path",
|
|
302
|
+
oldString: "old_string",
|
|
303
|
+
newString: "new_string",
|
|
304
|
+
replaceAll: "replace_all",
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
282
308
|
// ── Pi (pi-coding-agent) ───────────────────────────────────────────────────
|
|
283
309
|
//
|
|
284
310
|
// Pi loads TypeScript extensions from packages registered in `.pi/settings.json`
|
|
@@ -364,6 +390,32 @@ export const PI_TOOL_MAP: Record<string, string> = {
|
|
|
364
390
|
grep: "Grep",
|
|
365
391
|
};
|
|
366
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Per-tool input-key translation: Pi's tool args use `path` for Read / Write /
|
|
395
|
+
* Edit (see https://github.com/earendil-works/pi packages/coding-agent/src/core/tools)
|
|
396
|
+
* but failproofai builtins read `ctx.toolInput.file_path`. `block-read-outside-cwd`
|
|
397
|
+
* already has a `ctx.toolInput.path` fallback (`src/hooks/builtin-policies.ts:796`)
|
|
398
|
+
* so it works on Pi via that path; without this map, however,
|
|
399
|
+
* `block-env-files` and `block-secrets-write` — which only check
|
|
400
|
+
* `ctx.toolInput.file_path` via `getFilePath()` — silently no-op on Pi.
|
|
401
|
+
*
|
|
402
|
+
* Pi's Edit tool delivers a nested `edits: [{oldText, newText}, …]` array
|
|
403
|
+
* shape that doesn't translate flatly to Claude's `{old_string, new_string,
|
|
404
|
+
* replace_all}`, so only the top-level `path` is mapped. Edit-content
|
|
405
|
+
* checks (sanitize-* on the edit body) remain Pi-shape — none of today's
|
|
406
|
+
* builtins look at the edit body. Tools outside this set pass through
|
|
407
|
+
* unchanged.
|
|
408
|
+
*
|
|
409
|
+
* Mirrored inline in pi-extension/index.ts (the extension must be self-
|
|
410
|
+
* contained — Pi loads it as an in-process JS module), so any change here
|
|
411
|
+
* MUST be mirrored there.
|
|
412
|
+
*/
|
|
413
|
+
export const PI_TOOL_INPUT_MAP: Record<string, Record<string, string>> = {
|
|
414
|
+
Read: { path: "file_path" },
|
|
415
|
+
Write: { path: "file_path" },
|
|
416
|
+
Edit: { path: "file_path" },
|
|
417
|
+
};
|
|
418
|
+
|
|
367
419
|
// ── Gemini CLI ─────────────────────────────────────────────────────────────
|
|
368
420
|
//
|
|
369
421
|
// Gemini CLI's hook contract is the closest thing to a Claude Code clone we've
|