From 447ee975cf5d23a1e17f121cdf02e2ad29c79c62 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 17:35:44 -0700 Subject: [PATCH 1/3] [luv-338] fix: canonicalize OpenCode + Pi tool-input arg keys so path-checking builtins fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode's `read` slipped past `block-read-outside-cwd`: the shim canonicalized the tool name (`read` → `Read`) but forwarded `output.args` verbatim. OpenCode delivers `{ filePath }`; the policy reads `ctx.toolInput.file_path`, so `getFilePath()` returned "" and the target-empty short-circuit at builtin-policies.ts:799 allowed the read. Same family of bug on Pi with a different mismatch shape: Pi's read/write/edit use `path`. `block-read-outside-cwd` happens to work on Pi via the existing `tool_input.path` fallback at builtin-policies.ts:796, but `block-env-files` and `block-secrets-write` only check `file_path` and were silently no-op'd. Mirrors the TOOL_NAME_MAP pattern from #293 with two new per-tool input-key maps keyed by canonical PascalCase tool name: • OPENCODE_TOOL_INPUT_MAP — Read/Write/Edit: filePath→file_path, plus oldString/newString/replaceAll for Edit • PI_TOOL_INPUT_MAP — Read/Write/Edit: path→file_path (Pi's nested edits[{oldText,newText}] array doesn't flat-rename, no current builtin reads it) Both maps are mirrored inline in their shims so .opencode/plugins/ failproofai.mjs and pi-extension/index.ts stay self-contained. MCP mcp_* and any unmapped tool pass through unchanged. Existing users must re-run `failproofai policies --install --cli opencode` / `--cli pi` to regenerate their shims and pick up the fix. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + .../hooks/opencode-integration.e2e.test.ts | 22 +++++ __tests__/hooks/opencode-plugin-shim.test.ts | 90 +++++++++++++++++++ __tests__/hooks/pi-extension-shim.test.ts | 65 ++++++++++++++ pi-extension/index.ts | 40 ++++++++- src/hooks/integrations.ts | 37 ++++++-- src/hooks/types.ts | 52 +++++++++++ 7 files changed, 297 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcae67e3..429a85ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes - Read full session UUID from each Gemini JSONL's metadata header at project-page session-listing time (`lib/gemini-projects.ts`), so links route to a valid `[sessionId]` segment instead of the 8-hex filename prefix that the session detail route's `UUID_RE` check rejects (404). Hooks-section links were already correct because hook stdin carries the full UUID; this aligns the projects-section with that path (#336). +- Canonicalize OpenCode and Pi tool-input arg keys so the path-checking builtin policies actually fire on `read` / `write` / `edit` tool calls. OpenCode delivers args as `filePath` / `oldString` / `newString` / `replaceAll`; Pi delivers `path`. The failproofai builtins read `ctx.toolInput.file_path`, so the shape mismatch silently no-op'd `block-read-outside-cwd` (OpenCode), `block-env-files`, and `block-secrets-write` for both CLIs — letting an OpenCode session read paths outside its CWD without any deny, and letting Pi sessions write to `.env` / SSH-key paths unchecked. Note: `block-read-outside-cwd` already worked on Pi via an existing `tool_input.path` fallback at `src/hooks/builtin-policies.ts:796`, so only `block-env-files` and `block-secrets-write` were affected on Pi. Mirrors the `OPENCODE_TOOL_MAP` / `PI_TOOL_MAP` pattern from PR #293 with two new per-tool maps keyed by canonical PascalCase tool name: `OPENCODE_TOOL_INPUT_MAP` (Read / Write / Edit) and `PI_TOOL_INPUT_MAP` (Read / Write / Edit, top-level `path` only — Pi's nested `edits[{oldText,newText}]` array isn't a flat key rename). Both maps are mirrored inline in their respective shims so `.opencode/plugins/failproofai.mjs` and `pi-extension/index.ts` stay self-contained; MCP `mcp_*` and any unmapped tool pass through unchanged. Existing OpenCode users must regenerate their shim via `failproofai policies --install --cli opencode` to pick up the fix; Pi users must reinstall via `failproofai policies --install --cli pi` (#). - Route OpenCode project pages by encoded cwd (`encodeFolderName(worktree)`) instead of opencode's project name / basename, fixing the dashboard `/project/` 404 for OpenCode-only sessions and merging same-cwd OpenCode + other-CLI rows on the Projects page (#335). - `.failproofai/policies/workflow-policies.mjs`: drop the `## Unreleased` section; new `release-prep-check` policy + updated `changelog-check` instruct the agent to put entries under a dated `## ` heading so each PR ships release-ready, and all four workflow policies now anchor command-phrase matches to shell boundaries to avoid false-positives from HEREDOC bodies (#335). diff --git a/__tests__/e2e/hooks/opencode-integration.e2e.test.ts b/__tests__/e2e/hooks/opencode-integration.e2e.test.ts index ab516af2..99d9fc56 100644 --- a/__tests__/e2e/hooks/opencode-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/opencode-integration.e2e.test.ts @@ -208,6 +208,28 @@ describe("E2E: OpenCode integration — hook protocol", () => { } }); + it("Read of a path outside cwd is denied by block-read-outside-cwd (regression for arg-key canonicalization gap)", () => { + // Regression for the bug where opencode's `read` tool delivered + // `tool_input.filePath` (camelCase) but the policy reads + // `ctx.toolInput.file_path`, letting a read outside cwd slip through. + // The shim now canonicalizes the arg keys; this test asserts the policy + // fires when the binary sees the post-canonicalized Claude-shape payload. + const env = createOpenCodeEnv(); + try { + writeConfig(env.cwd, ["block-read-outside-cwd"]); + const result = runHook( + "PreToolUse", + OpenCodePayloads.preToolUse.read("/etc/passwd", env.cwd), + { homeDir: env.home, cli: "opencode" }, + ); + assertPreToolUseDeny(result); + const out = result.parsed?.hookSpecificOutput as Record | undefined; + expect(out?.permissionDecisionReason).toMatch(/outside project directory/i); + } finally { + env.cleanup(); + } + }); + it("Bash read of .opencode/plugins/failproofai.mjs is denied by the agent-settings guard", () => { const env = createOpenCodeEnv(); try { diff --git a/__tests__/hooks/opencode-plugin-shim.test.ts b/__tests__/hooks/opencode-plugin-shim.test.ts index 1229ef7d..291d6bf9 100644 --- a/__tests__/hooks/opencode-plugin-shim.test.ts +++ b/__tests__/hooks/opencode-plugin-shim.test.ts @@ -169,6 +169,96 @@ describe("OpenCode plugin shim — translation of plugin events to binary stdin" expect(stdin.tool_name).toBe("mcp_github_create_issue"); }); + it("tool.execute.before translates OpenCode camelCase arg keys to Claude snake_case for Read", async () => { + // Regression for the missing arg-key canonicalization that let a `read` + // tool call slip past `block-read-outside-cwd`: opencode delivers + // `{ filePath, offset, limit }` but the policy reads `ctx.toolInput.file_path`. + responses.push({ status: 0, stdout: "", stderr: "" }); + const { plugin } = await setup(); + const hooks = await plugin({ client: fakeClient(), directory: "/repo" }); + await hooks["tool.execute.before"]!( + { tool: "read", sessionID: "ses_1", callID: "c1" }, + { args: { filePath: "/etc/passwd", offset: 0, limit: 2000 } }, + ); + const stdin = JSON.parse(calls[0].opts.input!); + expect(stdin.tool_name).toBe("Read"); + // filePath is renamed; unmapped keys (offset, limit) pass through unchanged. + expect(stdin.tool_input).toEqual({ file_path: "/etc/passwd", offset: 0, limit: 2000 }); + }); + + it("tool.execute.before translates all four Edit arg keys", async () => { + responses.push({ status: 0, stdout: "", stderr: "" }); + const { plugin } = await setup(); + const hooks = await plugin({ client: fakeClient(), directory: "/repo" }); + await hooks["tool.execute.before"]!( + { tool: "edit", sessionID: "ses_1", callID: "c1" }, + { args: { filePath: "/repo/x", oldString: "a", newString: "b", replaceAll: true } }, + ); + const stdin = JSON.parse(calls[0].opts.input!); + expect(stdin.tool_name).toBe("Edit"); + expect(stdin.tool_input).toEqual({ + file_path: "/repo/x", + old_string: "a", + new_string: "b", + replace_all: true, + }); + }); + + it("tool.execute.before passes unknown-tool args through unchanged (MCP / extensions)", async () => { + responses.push({ status: 0, stdout: "", stderr: "" }); + const { plugin } = await setup(); + const hooks = await plugin({ client: fakeClient(), directory: "/repo" }); + await hooks["tool.execute.before"]!( + { tool: "mcp_github_create_issue", sessionID: "ses_1", callID: "c1" }, + { args: { title: "x", filePath: "/literal/key/name" } }, + ); + const stdin = JSON.parse(calls[0].opts.input!); + // Unknown tools pass through with the OpenCode camelCase shape — we don't + // rewrite keys we can't claim to understand. + expect(stdin.tool_input).toEqual({ title: "x", filePath: "/literal/key/name" }); + }); + + it("tool.execute.after also canonicalizes input args", async () => { + responses.push({ status: 0, stdout: "", stderr: "" }); + const { plugin } = await setup(); + const hooks = await plugin({ client: fakeClient(), directory: "/repo" }); + await hooks["tool.execute.after"]!( + { tool: "write", sessionID: "ses_1", callID: "c1", args: { filePath: "/repo/x", content: "hi" } }, + { title: "ok", output: "", metadata: {} }, + ); + const stdin = JSON.parse(calls[0].opts.input!); + expect(stdin.tool_name).toBe("Write"); + expect(stdin.tool_input).toEqual({ file_path: "/repo/x", content: "hi" }); + }); + + it("tool.execute.before canonicalizes every OPENCODE_TOOL_INPUT_MAP entry", async () => { + // Parity with the OPENCODE_TOOL_MAP coverage test below — keeps the + // shim's inline map in sync with the exported map in src/hooks/types.ts. + const { OPENCODE_TOOL_INPUT_MAP } = await import("../../src/hooks/types"); + const { plugin } = await setup(); + const hooks = await plugin({ client: fakeClient(), directory: "/repo" }); + // Reverse-canonical-name → opencode raw name lookup so we can drive each + // case via the lowercase tool ID the plugin actually receives. + const rawByCanonical: Record = { + Read: "read", + Write: "write", + Edit: "edit", + }; + for (const canonical of Object.keys(OPENCODE_TOOL_INPUT_MAP)) { + const raw = rawByCanonical[canonical]; + const map = OPENCODE_TOOL_INPUT_MAP[canonical]; + const args: Record = {}; + for (const camel of Object.keys(map)) args[camel] = `v_${camel}`; + responses.push({ status: 0, stdout: "", stderr: "" }); + await hooks["tool.execute.before"]!({ tool: raw, sessionID: "s", callID: "c" }, { args }); + const stdin = JSON.parse(calls[calls.length - 1].opts.input!); + for (const camel of Object.keys(map)) { + expect(stdin.tool_input[map[camel]]).toBe(`v_${camel}`); + expect(stdin.tool_input[camel]).toBeUndefined(); + } + } + }); + it("tool.execute.before canonicalizes every OPENCODE_TOOL_MAP entry", async () => { const { plugin } = await setup(); const hooks = await plugin({ client: fakeClient(), directory: "/repo" }); diff --git a/__tests__/hooks/pi-extension-shim.test.ts b/__tests__/hooks/pi-extension-shim.test.ts index 6bd6a96e..b73656c8 100644 --- a/__tests__/hooks/pi-extension-shim.test.ts +++ b/__tests__/hooks/pi-extension-shim.test.ts @@ -167,6 +167,71 @@ describe("pi-extension shim — sessionId resolution via on-disk discovery", () expect(captured.at(-1)?.payload.session_id).toBe(b); }); + it("translates Pi `path` arg to Claude `file_path` for read tool", () => { + // Regression for the same arg-key canonicalization gap that bit OpenCode: + // Pi's read tool delivers `{ path }`, but block-env-files / + // block-secrets-write only check `tool_input.file_path`. Without the + // PI_TOOL_INPUT_MAP, those policies silently no-op on Pi. + handlers.tool_call({ type: "tool_call", toolName: "read", input: { path: "/etc/passwd", offset: 0, limit: 100 }, cwd: "/proj" }); + const payload = captured.at(-1)?.payload as { tool_name: string; tool_input: Record }; + expect(payload.tool_name).toBe("Read"); + // path → file_path; unmapped offset/limit pass through unchanged. + expect(payload.tool_input).toEqual({ file_path: "/etc/passwd", offset: 0, limit: 100 }); + }); + + it("translates Pi `path` arg for write tool", () => { + handlers.tool_call({ type: "tool_call", toolName: "write", input: { path: "/proj/.env", content: "SECRET=1" }, cwd: "/proj" }); + const payload = captured.at(-1)?.payload as { tool_name: string; tool_input: Record }; + expect(payload.tool_name).toBe("Write"); + expect(payload.tool_input).toEqual({ file_path: "/proj/.env", content: "SECRET=1" }); + }); + + it("translates Pi `path` for edit tool top-level only — nested edits[] stays Pi-shape", () => { + // Pi's edit tool: { path, edits: [{oldText, newText}, …] } — different + // structurally from Claude's flat { file_path, old_string, new_string }. + // We can only translate the top-level `path` key; the nested array stays + // as-is because it isn't a flat key→key rename. + const edits = [{ oldText: "a", newText: "b" }]; + handlers.tool_call({ type: "tool_call", toolName: "edit", input: { path: "/proj/x", edits }, cwd: "/proj" }); + const payload = captured.at(-1)?.payload as { tool_name: string; tool_input: Record }; + expect(payload.tool_name).toBe("Edit"); + expect(payload.tool_input).toEqual({ file_path: "/proj/x", edits }); + }); + + it("passes unknown-tool args through unchanged", () => { + handlers.tool_call({ type: "tool_call", toolName: "mcp_github_create_issue", input: { title: "x", path: "/literal/key" }, cwd: "/proj" }); + const payload = captured.at(-1)?.payload as { tool_name: string; tool_input: Record }; + // Unknown tools (MCP / extensions) keep their raw arg shape — we don't + // rewrite keys we can't claim to understand. + expect(payload.tool_input).toEqual({ title: "x", path: "/literal/key" }); + }); + + it("tool_result also canonicalizes input args", () => { + handlers.tool_result({ type: "tool_result", toolName: "write", input: { path: "/proj/x", content: "hi" }, content: [], isError: false, cwd: "/proj" }); + const payload = captured.at(-1)?.payload as { tool_name: string; tool_input: Record }; + expect(payload.tool_name).toBe("Write"); + expect(payload.tool_input).toEqual({ file_path: "/proj/x", content: "hi" }); + }); + + it("PI_TOOL_INPUT_MAP coverage parity with the inline shim map", async () => { + // Drives every entry in the exported map through the shim to keep the + // two copies in sync — same pattern as the OPENCODE_TOOL_INPUT_MAP test. + const { PI_TOOL_INPUT_MAP } = await import("../../src/hooks/types"); + const rawByCanonical: Record = { Read: "read", Write: "write", Edit: "edit" }; + for (const canonical of Object.keys(PI_TOOL_INPUT_MAP)) { + const raw = rawByCanonical[canonical]; + const map = PI_TOOL_INPUT_MAP[canonical]; + const args: Record = {}; + for (const camel of Object.keys(map)) args[camel] = `v_${camel}`; + handlers.tool_call({ type: "tool_call", toolName: raw, input: args, cwd: "/proj" }); + const payload = captured.at(-1)?.payload as { tool_input: Record }; + for (const camel of Object.keys(map)) { + expect(payload.tool_input[map[camel]]).toBe(`v_${camel}`); + expect(payload.tool_input[camel]).toBeUndefined(); + } + } + }); + // Cleanup afterEach(() => { if (originalEnv === undefined) delete process.env.PI_SESSIONS_DIR; diff --git a/pi-extension/index.ts b/pi-extension/index.ts index f0f9e446..df4fc013 100644 --- a/pi-extension/index.ts +++ b/pi-extension/index.ts @@ -129,6 +129,36 @@ function canonicalizeToolName(piToolName: string | undefined): string | undefine return PI_TOOL_MAP[piToolName] ?? piToolName; } +/** + * Per-tool input-key translation. Pi's Read / Write / Edit tools deliver + * `path` (not `file_path`); failproofai's `block-env-files` and + * `block-secrets-write` builtins only read `file_path`, so without this map + * they silently no-op on Pi. `block-read-outside-cwd` already has a `path` + * fallback so it works either way. Pi's Edit tool nests `edits[{oldText, + * newText}]` which doesn't translate flatly to Claude's `{old_string, + * new_string}` — we only map the top-level `path`; the nested array stays + * Pi-shape (no current builtin reads it). + * + * Keep in sync with PI_TOOL_INPUT_MAP in src/hooks/types.ts. + */ +const PI_TOOL_INPUT_MAP: Record> = { + Read: { path: "file_path" }, + Write: { path: "file_path" }, + Edit: { path: "file_path" }, +}; + +function canonicalizeToolInput( + canonicalToolName: string | undefined, + args: Record | undefined, +): Record | undefined { + if (!args || typeof args !== "object" || !canonicalToolName) return args; + const map = PI_TOOL_INPUT_MAP[canonicalToolName]; + if (!map) return args; + const out: Record = {}; + for (const k of Object.keys(args)) out[map[k] ?? k] = args[k]; + return out; +} + /** Resolve the cwd for the policy payload. Pi events don't include cwd, so * fall back to the extension's process.cwd() — which is where Pi was * launched and where `.failproofai/` config lives. */ @@ -280,9 +310,10 @@ export default function failproofaiBridge(pi: PiExtensionApi) { // tool_call → PreToolUse. Block tool execution when failproofai denies. pi.on("tool_call", (event: unknown): unknown => { const e = event as PiToolCallEvent; + const canonicalTool = canonicalizeToolName(e.toolName); const decision = callPolicy("tool_call", { - tool_name: canonicalizeToolName(e.toolName), - tool_input: e.input, + tool_name: canonicalTool, + tool_input: canonicalizeToolInput(canonicalTool, e.input), session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), cwd: resolveCwd(e.cwd), hook_event_name: "PreToolUse", @@ -341,9 +372,10 @@ export default function failproofaiBridge(pi: PiExtensionApi) { // the activity store + stderr — but Pi keeps the original tool result. pi.on("tool_result", (event: unknown): unknown => { const e = event as PiToolResultEvent; + const canonicalTool = canonicalizeToolName(e.toolName); callPolicy("tool_result", { - tool_name: canonicalizeToolName(e.toolName), - tool_input: e.input ?? {}, + tool_name: canonicalTool, + tool_input: canonicalizeToolInput(canonicalTool, e.input) ?? {}, tool_response: { content: e.content, isError: e.isError }, session_id: resolveSessionId(e.sessionId, resolveCwd(e.cwd)), cwd: resolveCwd(e.cwd), diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index 11afee9b..7a9a9817 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -750,6 +750,28 @@ function canonicalizeTool(raw) { return TOOL_NAME_MAP[raw] != null ? TOOL_NAME_MAP[raw] : raw; } +// Per-tool input-key translation: opencode native tools deliver args as +// camelCase (\`filePath\`, \`oldString\`, …) but failproofai builtin policies +// (\`block-read-outside-cwd\`, \`block-env-files\`, \`block-secrets-write\`) +// read \`ctx.toolInput.file_path\` etc. Without this map every Read/Write/Edit +// path-check silently no-ops on opencode. Keys are PascalCase canonical tool +// names so the lookup pairs with canonicalizeTool's output. Tools outside the +// map (MCP \`mcp_*\`, plugins) pass through unchanged. Keep in sync with +// OPENCODE_TOOL_INPUT_MAP in failproofai/src/hooks/types.ts. +const TOOL_INPUT_MAP = { + Read: { filePath: "file_path" }, + Write: { filePath: "file_path" }, + Edit: { filePath: "file_path", oldString: "old_string", newString: "new_string", replaceAll: "replace_all" }, +}; +function canonicalizeToolInput(canonicalToolName, args) { + if (!args || typeof args !== "object") return args; + const map = TOOL_INPUT_MAP[canonicalToolName]; + if (!map) return args; + const out = {}; + for (const k of Object.keys(args)) out[map[k] != null ? map[k] : k] = args[k]; + return out; +} + const FAILPROOFAI_BIN = ${escapedBin}; const USE_NPX = ${useNpx}; @@ -848,11 +870,12 @@ export default async function failproofaiPlugin({ client, directory }) { // First-class PreToolUse hook. Note: tool args live on output.args (mutable). "tool.execute.before": async (input, output) => { + const canonicalTool = canonicalizeTool(input.tool); const r = runFailproofai("PreToolUse", { session_id: input.sessionID, cwd: directory, - tool_name: canonicalizeTool(input.tool), - tool_input: output.args, + tool_name: canonicalTool, + tool_input: canonicalizeToolInput(canonicalTool, output.args), hook_event_name: "PreToolUse", }, directory); await applyDecision(r, { client, sessionID: input.sessionID }, "PreToolUse"); @@ -860,11 +883,12 @@ export default async function failproofaiPlugin({ client, directory }) { // First-class PostToolUse hook. Note: tool args live on input.args here. "tool.execute.after": async (input, output) => { + const canonicalTool = canonicalizeTool(input.tool); const r = runFailproofai("PostToolUse", { session_id: input.sessionID, cwd: directory, - tool_name: canonicalizeTool(input.tool), - tool_input: input.args, + tool_name: canonicalTool, + tool_input: canonicalizeToolInput(canonicalTool, input.args), tool_response: { title: output.title, output: output.output, metadata: output.metadata }, hook_event_name: "PostToolUse", }, directory); @@ -873,11 +897,12 @@ export default async function failproofaiPlugin({ client, directory }) { // Cleaner deny UX for prompted tools — mutate output.status instead of throwing. "permission.ask": async (input, output) => { + const canonicalTool = canonicalizeTool(input.tool); const r = runFailproofai("PermissionRequest", { session_id: input.sessionID, cwd: directory, - tool_name: canonicalizeTool(input.tool) || input.command || "permission", - tool_input: input, + tool_name: canonicalTool || input.command || "permission", + tool_input: canonicalizeToolInput(canonicalTool, input), hook_event_name: "PermissionRequest", }, directory); try { diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 7ea4f390..cc71009b 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -279,6 +279,32 @@ export const OPENCODE_TOOL_MAP: Record = { todoread: "TodoRead", }; +/** + * Per-tool input-key translation: OpenCode camelCase → Claude snake_case, + * keyed by canonical (PascalCase) tool name so it pairs naturally with the + * output of OPENCODE_TOOL_MAP. Without this, builtin policies that read + * `ctx.toolInput.file_path` (`block-read-outside-cwd`, `block-env-files`, + * `block-secrets-write`) silently no-op on every OpenCode Read/Write/Edit + * call because OpenCode's native tools deliver args as `filePath` / `oldString` + * / `newString` / `replaceAll`. + * + * Tools outside this set (MCP `mcp_*`, third-party plugins) pass through + * unchanged so their schemas aren't corrupted. Mirrored inline in the shim + * template at src/hooks/integrations.ts:buildOpenCodePluginShim — the shim + * must be self-contained because opencode loads it as a JS module — so any + * change here MUST be mirrored there. + */ +export const OPENCODE_TOOL_INPUT_MAP: Record> = { + Read: { filePath: "file_path" }, + Write: { filePath: "file_path" }, + Edit: { + filePath: "file_path", + oldString: "old_string", + newString: "new_string", + replaceAll: "replace_all", + }, +}; + // ── Pi (pi-coding-agent) ─────────────────────────────────────────────────── // // Pi loads TypeScript extensions from packages registered in `.pi/settings.json` @@ -364,6 +390,32 @@ export const PI_TOOL_MAP: Record = { grep: "Grep", }; +/** + * Per-tool input-key translation: Pi's tool args use `path` for Read / Write / + * Edit (see https://github.com/earendil-works/pi packages/coding-agent/src/core/tools) + * but failproofai builtins read `ctx.toolInput.file_path`. `block-read-outside-cwd` + * already has a `ctx.toolInput.path` fallback (`src/hooks/builtin-policies.ts:796`) + * so it works on Pi via that path; without this map, however, + * `block-env-files` and `block-secrets-write` — which only check + * `ctx.toolInput.file_path` via `getFilePath()` — silently no-op on Pi. + * + * Pi's Edit tool delivers a nested `edits: [{oldText, newText}, …]` array + * shape that doesn't translate flatly to Claude's `{old_string, new_string, + * replace_all}`, so only the top-level `path` is mapped. Edit-content + * checks (sanitize-* on the edit body) remain Pi-shape — none of today's + * builtins look at the edit body. Tools outside this set pass through + * unchanged. + * + * Mirrored inline in pi-extension/index.ts (the extension must be self- + * contained — Pi loads it as an in-process JS module), so any change here + * MUST be mirrored there. + */ +export const PI_TOOL_INPUT_MAP: Record> = { + Read: { path: "file_path" }, + Write: { path: "file_path" }, + Edit: { path: "file_path" }, +}; + // ── Gemini CLI ───────────────────────────────────────────────────────────── // // Gemini CLI's hook contract is the closest thing to a Claude Code clone we've From 1a833918e0ad7fc0dc12ee5daa19ebda62762e49 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 17:36:59 -0700 Subject: [PATCH 2/3] [luv-338] docs: document OPENCODE_TOOL_INPUT_MAP and PI_TOOL_INPUT_MAP in configuration.mdx Mirror the existing tool-name canonicalization wording in the OpenCode and Pi sections. Calls out which builtin policies start firing as a result (block-read-outside-cwd / block-env-files / block-secrets-write on OpenCode; block-env-files / block-secrets-write on Pi). Co-Authored-By: Claude Opus 4.7 --- docs/configuration.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration.mdx b/docs/configuration.mdx index c202a7f5..a77129a7 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -196,8 +196,8 @@ The `policies --install` and `policies --uninstall` commands write to your agent - **OpenAI Codex**: `~/.codex/hooks.json` (user), `/.codex/hooks.json` (project) — Codex doesn't have a `local` scope - **GitHub Copilot CLI _(beta)_**: `~/.copilot/hooks/failproofai.json` (user), `/.github/hooks/failproofai.json` (project) — Copilot has no `local` scope. Hook entries use Copilot's OS-keyed `bash`/`powershell` command fields with `timeoutSec`; the file carries a top-level `version: 1` marker. Copilot CLI support is **beta** while we verify the `events.jsonl` record schema (which the public docs do not specify) against more real-world sessions. - **Cursor Agent _(beta)_**: `~/.cursor/hooks.json` (user), `/.cursor/hooks.json` (project) — Cursor has no `local` scope. Hook entries use the Claude-shaped `{type, command, timeout}` form (no `bash`/`powershell` split), but stored under camelCase event keys (`preToolUse`, `beforeSubmitPrompt`, …) in a flat array per Cursor's [hooks schema](https://cursor.com/docs/hooks); the file carries a top-level `version: 1` marker. The handler canonicalizes camelCase → PascalCase via `CURSOR_EVENT_MAP` so existing builtin policies fire unchanged. Cursor Agent support is **beta** while we verify Cursor's transcript on-disk format (not specified in the public docs) against more real-world installs. - - **OpenCode _(beta)_**: `~/.config/opencode/opencode.json` + `~/.config/opencode/plugins/failproofai.mjs` (user), `/.opencode/opencode.json` + `/.opencode/plugins/failproofai.mjs` (project) — OpenCode has no `local` scope. Unlike the other six CLIs, OpenCode has **no external-command hook system**: it loads in-process JS/TS plugins explicitly registered via the `plugin: []` array in `opencode.json` (auto-discovery from `.opencode/plugins/` is **not** how plugins load on opencode v1.14.33). Install drops a small generated plugin shim that subprocess-calls the failproofai binary and translates the binary's Claude-shape JSON response back into plugin semantics: `throw new Error()` for tool-event deny (cancels the tool call), `client.session.prompt(...)` for instruct AND for `Stop` / `SubagentStop` deny (submits the deny reason as the next user message — the only force-retry channel since `session.idle` is notification-only and throwing from it is a no-op), and no-op for allow. Sessions live in opencode's SQLite DB at `~/.local/share/opencode/opencode.db`; the dashboard's session viewer reads them via `opencode db --format json` and `opencode export `. OpenCode support is **beta** while we verify behavior across versions and against more real-world sessions. See the [OpenCode plugins docs](https://opencode.ai/docs/plugins/). - - **Pi _(beta)_**: `~/.pi/agent/settings.json` (user), `/.pi/settings.json` (project) — Pi has no `local` scope. Pi loads TypeScript extension packages at startup; the settings file is a flat string array `{"packages": ["./relative/path", …]}`. failproofai writes a single packages-array entry pointing at its bundled `pi-extension/` directory. The extension internally subscribes to Pi's `tool_call` / `user_bash` / `input` / `session_start` events and shells out to `failproofai --hook --cli pi`; the handler canonicalizes underscore_lower_snake_case → PascalCase via `PI_EVENT_MAP` so existing builtin policies fire unchanged. Pi support is **beta** while Pi's extension API and session-log layout stabilize. + - **OpenCode _(beta)_**: `~/.config/opencode/opencode.json` + `~/.config/opencode/plugins/failproofai.mjs` (user), `/.opencode/opencode.json` + `/.opencode/plugins/failproofai.mjs` (project) — OpenCode has no `local` scope. Unlike the other six CLIs, OpenCode has **no external-command hook system**: it loads in-process JS/TS plugins explicitly registered via the `plugin: []` array in `opencode.json` (auto-discovery from `.opencode/plugins/` is **not** how plugins load on opencode v1.14.33). Install drops a small generated plugin shim that subprocess-calls the failproofai binary and translates the binary's Claude-shape JSON response back into plugin semantics: `throw new Error()` for tool-event deny (cancels the tool call), `client.session.prompt(...)` for instruct AND for `Stop` / `SubagentStop` deny (submits the deny reason as the next user message — the only force-retry channel since `session.idle` is notification-only and throwing from it is a no-op), and no-op for allow. The shim canonicalizes both tool names (lowercase → PascalCase via `OPENCODE_TOOL_MAP`) and tool-input arg keys (camelCase → snake_case via `OPENCODE_TOOL_INPUT_MAP` for `Read` / `Write` / `Edit`, e.g. `filePath` → `file_path`, `oldString` → `old_string`) before forwarding to the binary, so path-checking builtins like `block-read-outside-cwd`, `block-env-files`, and `block-secrets-write` fire unchanged on OpenCode tool calls. Sessions live in opencode's SQLite DB at `~/.local/share/opencode/opencode.db`; the dashboard's session viewer reads them via `opencode db --format json` and `opencode export `. OpenCode support is **beta** while we verify behavior across versions and against more real-world sessions. See the [OpenCode plugins docs](https://opencode.ai/docs/plugins/). + - **Pi _(beta)_**: `~/.pi/agent/settings.json` (user), `/.pi/settings.json` (project) — Pi has no `local` scope. Pi loads TypeScript extension packages at startup; the settings file is a flat string array `{"packages": ["./relative/path", …]}`. failproofai writes a single packages-array entry pointing at its bundled `pi-extension/` directory. The extension internally subscribes to Pi's `tool_call` / `user_bash` / `input` / `session_start` events and shells out to `failproofai --hook --cli pi`; the handler canonicalizes underscore_lower_snake_case → PascalCase via `PI_EVENT_MAP` so existing builtin policies fire unchanged. Tool input args are also canonicalized via `PI_TOOL_INPUT_MAP` (Pi's Read / Write / Edit deliver `path` rather than `file_path`; mapping the top-level key lets `block-env-files` and `block-secrets-write` fire — `block-read-outside-cwd` already had a `path` fallback). Pi support is **beta** while Pi's extension API and session-log layout stabilize. - **Gemini CLI _(beta)_**: `~/.gemini/settings.json` (user), `/.gemini/settings.json` (project) — Gemini has no `local` scope (it documents a `system` scope at `/etc/gemini-cli/settings.json` which failproofai does not expose). Hook entries use Claude's `{type, command, timeout}` form wrapped in Gemini's `{matcher, hooks: [...]}` matcher schema with `matcher: "*"` by default. Events are PascalCase (`SessionStart`, `BeforeAgent`, `AfterAgent`, `BeforeModel`, `AfterModel`, `BeforeToolSelection`, `BeforeTool`, `AfterTool`, `PreCompress`, `Notification`, `SessionEnd`); the handler maps to Claude canonical names via `GEMINI_EVENT_MAP`. Tool names are snake_case (`run_shell_command`, `read_file`, `write_file`, `replace`, …) — the handler canonicalizes via `GEMINI_TOOL_MAP` so existing builtin policies fire unchanged. The policy evaluator emits Gemini's flat `{decision: "deny", reason}` shape (preferred per Gemini's "Golden Rule" exit-0 contract), `{hookSpecificOutput: {hookEventName, additionalContext}}` for context injection on BeforeAgent / AfterTool / SessionStart, and `{decision: "block", reason}` on AfterAgent for force-retry semantics. Gemini CLI support is **beta** while we widen real-world coverage. See the [Gemini CLI hooks docs](https://geminicli.com/docs/hooks/). - **`policies-config.json`** — tells failproofai which policies to evaluate and with what params (shared across all agent CLIs) From 61a515fc285c220d6ee8f61aebaac1258c82146b Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 17:42:53 -0700 Subject: [PATCH 3/3] [luv-338] chore: stamp PR number in CHANGELOG entry Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 429a85ba..4ca1468c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixes - Read full session UUID from each Gemini JSONL's metadata header at project-page session-listing time (`lib/gemini-projects.ts`), so links route to a valid `[sessionId]` segment instead of the 8-hex filename prefix that the session detail route's `UUID_RE` check rejects (404). Hooks-section links were already correct because hook stdin carries the full UUID; this aligns the projects-section with that path (#336). -- Canonicalize OpenCode and Pi tool-input arg keys so the path-checking builtin policies actually fire on `read` / `write` / `edit` tool calls. OpenCode delivers args as `filePath` / `oldString` / `newString` / `replaceAll`; Pi delivers `path`. The failproofai builtins read `ctx.toolInput.file_path`, so the shape mismatch silently no-op'd `block-read-outside-cwd` (OpenCode), `block-env-files`, and `block-secrets-write` for both CLIs — letting an OpenCode session read paths outside its CWD without any deny, and letting Pi sessions write to `.env` / SSH-key paths unchecked. Note: `block-read-outside-cwd` already worked on Pi via an existing `tool_input.path` fallback at `src/hooks/builtin-policies.ts:796`, so only `block-env-files` and `block-secrets-write` were affected on Pi. Mirrors the `OPENCODE_TOOL_MAP` / `PI_TOOL_MAP` pattern from PR #293 with two new per-tool maps keyed by canonical PascalCase tool name: `OPENCODE_TOOL_INPUT_MAP` (Read / Write / Edit) and `PI_TOOL_INPUT_MAP` (Read / Write / Edit, top-level `path` only — Pi's nested `edits[{oldText,newText}]` array isn't a flat key rename). Both maps are mirrored inline in their respective shims so `.opencode/plugins/failproofai.mjs` and `pi-extension/index.ts` stay self-contained; MCP `mcp_*` and any unmapped tool pass through unchanged. Existing OpenCode users must regenerate their shim via `failproofai policies --install --cli opencode` to pick up the fix; Pi users must reinstall via `failproofai policies --install --cli pi` (#). +- Canonicalize OpenCode and Pi tool-input arg keys so the path-checking builtin policies actually fire on `read` / `write` / `edit` tool calls. OpenCode delivers args as `filePath` / `oldString` / `newString` / `replaceAll`; Pi delivers `path`. The failproofai builtins read `ctx.toolInput.file_path`, so the shape mismatch silently no-op'd `block-read-outside-cwd` (OpenCode), `block-env-files`, and `block-secrets-write` for both CLIs — letting an OpenCode session read paths outside its CWD without any deny, and letting Pi sessions write to `.env` / SSH-key paths unchecked. Note: `block-read-outside-cwd` already worked on Pi via an existing `tool_input.path` fallback at `src/hooks/builtin-policies.ts:796`, so only `block-env-files` and `block-secrets-write` were affected on Pi. Mirrors the `OPENCODE_TOOL_MAP` / `PI_TOOL_MAP` pattern from PR #293 with two new per-tool maps keyed by canonical PascalCase tool name: `OPENCODE_TOOL_INPUT_MAP` (Read / Write / Edit) and `PI_TOOL_INPUT_MAP` (Read / Write / Edit, top-level `path` only — Pi's nested `edits[{oldText,newText}]` array isn't a flat key rename). Both maps are mirrored inline in their respective shims so `.opencode/plugins/failproofai.mjs` and `pi-extension/index.ts` stay self-contained; MCP `mcp_*` and any unmapped tool pass through unchanged. Existing OpenCode users must regenerate their shim via `failproofai policies --install --cli opencode` to pick up the fix; Pi users must reinstall via `failproofai policies --install --cli pi` (#337). - Route OpenCode project pages by encoded cwd (`encodeFolderName(worktree)`) instead of opencode's project name / basename, fixing the dashboard `/project/` 404 for OpenCode-only sessions and merging same-cwd OpenCode + other-CLI rows on the Projects page (#335). - `.failproofai/policies/workflow-policies.mjs`: drop the `## Unreleased` section; new `release-prep-check` policy + updated `changelog-check` instruct the agent to put entries under a dated `## ` heading so each PR ships release-ready, and all four workflow policies now anchor command-phrase matches to shell boundaries to avoid false-positives from HEREDOC bodies (#335).