From 33ebd51d30dc58328966bce503213b9d4c9d24e7 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 19:25:18 -0700 Subject: [PATCH 1/2] [luv-340] fix: regenerate OpenCode dev shim + add handler-side canonicalization for OpenCode/Pi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-layer fix for `block-read-outside-cwd` silently no-op'ing on OpenCode inside this repo. PR #337 added `OPENCODE_TOOL_INPUT_MAP` to the production shim template at `src/hooks/integrations.ts:buildOpenCodePluginShim`, but the dev shim at `.opencode/plugins/failproofai.mjs` is hand-maintained and was never regenerated, so contributors running `opencode` from inside this repo could still read paths outside the repo cwd — `getFilePath()` returned "" (camelCase `filePath` didn't match the policy's snake_case `file_path` lookup) and the target-empty short-circuit at `src/hooks/builtin-policies.ts:799` returned allow. Layer 1 (dev shim): regenerate `.opencode/plugins/failproofai.mjs` to mirror the production template — `TOOL_NAME_MAP` + `TOOL_INPUT_MAP` + idempotent `canonicalizeTool` / `canonicalizeToolInput`. Also pick up the `applyDecision` async-await change from #318 that the dev shim had been missing, so Stop / SubagentStop force-retry actually awaits the SDK call before the plugin handler returns. Layer 2 (handler): add per-CLI canonicalization for OpenCode + Pi in `src/hooks/handler.ts` as defense-in-depth, so a user-scope shim that pre-dates #337 automatically starts enforcing the moment failproofai upgrades — without forcing a `failproofai policies --install --cli opencode` re-run. The shim canonicalization stays in place; both passes are idempotent because the per-CLI maps key on camelCase / Pi-shape and don't match already-canonical snake_case input. New `canonicalizeToolInput` helper is gated by `cli === "opencode" / "pi"` so Claude / Codex / Cursor / Copilot / Gemini sessions are unaffected. New unit tests in `__tests__/hooks/handler.test.ts` cover: - Stale-shim shapes (`read` + `filePath` for OpenCode, `read` + `path` for Pi) - Per-CLI map coverage parity (12 OpenCode + 6 Pi tool names, four Edit keys) - Idempotency (handler doesn't corrupt fresh-shim shapes) - Unknown-tool passthrough (MCP `mcp_*` tool names + args) - Per-CLI gate (Claude payloads with literal `filePath` keys are not rewritten) Smoke-tested against the exact pattern from the bug report: an opencode `read` with a camelCase `filePath` pointing outside the repo cwd is now blocked with a clear `Access outside project directory blocked` deny reason. In-cwd reads still pass. Co-Authored-By: Claude Opus 4.7 --- .opencode/plugins/failproofai.mjs | 90 ++++++++++-- CHANGELOG.md | 5 + __tests__/hooks/handler.test.ts | 222 ++++++++++++++++++++++++++++++ src/hooks/handler.ts | 69 +++++++++- 4 files changed, 365 insertions(+), 21 deletions(-) diff --git a/.opencode/plugins/failproofai.mjs b/.opencode/plugins/failproofai.mjs index 2cb31425..784ab192 100644 --- a/.opencode/plugins/failproofai.mjs +++ b/.opencode/plugins/failproofai.mjs @@ -10,6 +10,12 @@ // Do NOT install this repo via `failproofai policies --install --cli // opencode --scope project` — it would overwrite this dev path with the // portable npx form. +// +// IMPORTANT: keep TOOL_NAME_MAP / TOOL_INPUT_MAP in sync with both: +// • src/hooks/types.ts (OPENCODE_TOOL_MAP / OPENCODE_TOOL_INPUT_MAP) +// • src/hooks/integrations.ts (buildOpenCodePluginShim production template) +// When #337 landed, this dev shim drifted and `block-read-outside-cwd` +// silently no-op'd on every opencode `read` call inside this repo. import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; @@ -22,6 +28,49 @@ const BUS_EVENT_MAP = { "session.idle": "Stop", }; +// Map opencode lowercase tool IDs (`input.tool`) → Claude PascalCase canonical +// names. Builtin failproofai policies match on PascalCase via case-sensitive +// `Array.includes`, so without this every Bash/Read/Write/Edit builtin +// silently no-ops under opencode. +const TOOL_NAME_MAP = { + bash: "Bash", + read: "Read", + write: "Write", + edit: "Edit", + apply_patch: "Edit", + glob: "Glob", + grep: "Grep", + list: "LS", + webfetch: "WebFetch", + websearch: "WebSearch", + todowrite: "TodoWrite", + todoread: "TodoRead", +}; +function canonicalizeTool(raw) { + if (!raw) return 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. +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; +} + function runFailproofai(eventName, payload, directory) { const r = spawnSync("bun", [FAILPROOFAI_DEV_BIN, "--hook", eventName, "--cli", "opencode"], { input: JSON.stringify(payload), @@ -32,7 +81,7 @@ function runFailproofai(eventName, payload, directory) { return { exitCode: r.status ?? 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; } -function applyDecision(result, ctx) { +async function applyDecision(result, ctx, eventName) { if (result.exitCode === 2) { throw new Error((result.stderr || "").trim() || "Blocked by failproofai"); } @@ -46,12 +95,21 @@ function applyDecision(result, ctx) { if (out && out.decision && out.decision.behavior === "deny") { throw new Error((out.decision.message) || "Blocked by failproofai"); } + // For Stop / SubagentStop the prompt is the only force-retry channel + // (session.idle already fired), so AWAIT to ensure the SDK round-trip + // completes before the plugin handler returns. For tool events keep + // fire-and-forget so we don't add latency to every tool call. const ctxText = out && out.additionalContext; if (ctxText && ctx && ctx.client && ctx.sessionID) { - Promise.resolve(ctx.client.session.prompt({ + const prompt = ctx.client.session.prompt({ path: { id: ctx.sessionID }, body: { parts: [{ type: "text", text: ctxText }] }, - })).catch(() => {}); + }); + if (eventName === "Stop" || eventName === "SubagentStop") { + try { await prompt; } catch { /* swallow — agent is exiting anyway */ } + } else { + Promise.resolve(prompt).catch(() => {}); + } } } @@ -65,7 +123,6 @@ export default async function failproofaiPlugin({ client, directory }) { const role = info.role || props.role; if (role !== "user") return; const sessionID = info.sessionID || info.sessionId || info.session_id || props.sessionID; - // Reconstruct the user prompt text so prompt-based policies see it. let prompt = ""; const parts = info.parts || props.parts || []; if (Array.isArray(parts)) { @@ -77,7 +134,7 @@ export default async function failproofaiPlugin({ client, directory }) { const r = runFailproofai("UserPromptSubmit", { session_id: sessionID, cwd: directory, hook_event_name: "UserPromptSubmit", prompt, }, directory); - applyDecision(r, { client, sessionID }); + await applyDecision(r, { client, sessionID }, "UserPromptSubmit"); return; } const claudeEvent = BUS_EVENT_MAP[event.type]; @@ -87,42 +144,45 @@ export default async function failproofaiPlugin({ client, directory }) { const r = runFailproofai(claudeEvent, { session_id: sessionID, cwd: directory, hook_event_name: claudeEvent, }, directory); - applyDecision(r, { client, sessionID }); + await applyDecision(r, { client, sessionID }, claudeEvent); }, "tool.execute.before": async (input, output) => { + const canonicalTool = canonicalizeTool(input.tool); const r = runFailproofai("PreToolUse", { session_id: input.sessionID, cwd: directory, - tool_name: input.tool, - tool_input: output.args, + tool_name: canonicalTool, + tool_input: canonicalizeToolInput(canonicalTool, output.args), hook_event_name: "PreToolUse", }, directory); - applyDecision(r, { client, sessionID: input.sessionID }); + await applyDecision(r, { client, sessionID: input.sessionID }, "PreToolUse"); }, "tool.execute.after": async (input, output) => { + const canonicalTool = canonicalizeTool(input.tool); const r = runFailproofai("PostToolUse", { session_id: input.sessionID, cwd: directory, - tool_name: 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); - applyDecision(r, { client, sessionID: input.sessionID }); + await applyDecision(r, { client, sessionID: input.sessionID }, "PostToolUse"); }, "permission.ask": async (input, output) => { + const canonicalTool = canonicalizeTool(input.tool); const r = runFailproofai("PermissionRequest", { session_id: input.sessionID, cwd: directory, - tool_name: 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 { - applyDecision(r, { client, sessionID: input.sessionID }); + await applyDecision(r, { client, sessionID: input.sessionID }, "PermissionRequest"); } catch { output.status = "deny"; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c4389dd..d70a545c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.10-beta.11 — 2026-05-09 + +### Fixes +- Two-layer canonicalization for OpenCode `read` / `write` / `edit` tool calls so `block-read-outside-cwd` actually fires. PR #337 added `OPENCODE_TOOL_INPUT_MAP` to the production shim template at `src/hooks/integrations.ts:buildOpenCodePluginShim`, but the dev shim at `.opencode/plugins/failproofai.mjs` was hand-maintained and never regenerated, so contributors running `opencode` from inside this repo could still read `/home//...` outside the repo cwd — `getFilePath()` returned `""` (the camelCase `filePath` key didn't match the policy's snake_case `file_path` lookup) and the target-empty short-circuit at `src/hooks/builtin-policies.ts:799` returned allow. Two parallel fixes: (1) regenerate the dev shim with `TOOL_NAME_MAP` + `TOOL_INPUT_MAP` mirroring the production template, also picking up the `applyDecision` async-await change from #318 that the dev shim had been missing; (2) move canonicalization into the binary handler (`src/hooks/handler.ts`) for OpenCode + Pi as defense-in-depth, so user-scope shims that pre-date #337 automatically start enforcing the moment failproofai upgrades — without forcing a `failproofai policies --install --cli opencode` re-run. The shim canonicalization stays in place; both passes are idempotent because the per-CLI maps key on camelCase / Pi-shape and don't match already-canonical snake_case input. New `canonicalizeToolInput` helper in handler is gated by `cli === "opencode"` / `cli === "pi"` so Claude / Codex / Cursor / Copilot / Gemini sessions are unaffected. New unit tests in `__tests__/hooks/handler.test.ts` cover stale-shim shapes (`read` + `filePath`, `read` + `path`), per-tool map coverage parity (12 OpenCode + 6 Pi tool names, four Edit keys), the idempotency path (handler doesn't corrupt fresh-shim shapes), the unknown-tool passthrough (MCP `mcp_*` tool names + args), and the per-CLI gate (Claude payloads with literal `filePath` keys are not rewritten) (#340). + ## 0.0.10-beta.10 — 2026-05-10 ### Fixes diff --git a/__tests__/hooks/handler.test.ts b/__tests__/hooks/handler.test.ts index 905dea68..ded82a16 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -803,6 +803,228 @@ describe("hooks/handler", () => { ); }); + // -- OpenCode handler-side canonicalization (defense-in-depth) ---------- + // + // The OpenCode plugin shim already canonicalizes tool name + tool input + // before stdin reaches the binary. Handler-side canonicalization here is + // a second line of defense for stale user-scope shims that pre-date #337 + // (the shim is regenerated by `failproofai policies --install --cli + // opencode`, which existing users may not have re-run yet). Without + // these tests, a regression where the binary forgets to canonicalize + // would silently no-op `block-read-outside-cwd` / `block-env-files` / + // `block-secrets-write` for those installs. + + it("canonicalizes raw OpenCode tool name `read` → `Read` when shim is stale", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow", + }); + // Stale shim shape: lowercase `read` + camelCase `filePath`. + mockStdin(JSON.stringify({ + tool_name: "read", + tool_input: { filePath: "/etc/passwd", offset: 0, limit: 2000 }, + hook_event_name: "PreToolUse", + })); + + await handleHookEvent("PreToolUse", "opencode"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.objectContaining({ + tool_name: "Read", + tool_input: { file_path: "/etc/passwd", offset: 0, limit: 2000 }, + }), + expect.any(Object), + expect.any(Object), + ); + }); + + it("canonicalizes every OpenCode tool name in OPENCODE_TOOL_MAP", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + const cases: Array<[string, string]> = [ + ["bash", "Bash"], + ["read", "Read"], + ["write", "Write"], + ["edit", "Edit"], + ["apply_patch", "Edit"], + ["glob", "Glob"], + ["grep", "Grep"], + ["list", "LS"], + ["webfetch", "WebFetch"], + ["websearch", "WebSearch"], + ["todowrite", "TodoWrite"], + ["todoread", "TodoRead"], + ]; + for (const [raw, canonical] of cases) { + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow", + }); + mockStdin(JSON.stringify({ tool_name: raw, hook_event_name: "PreToolUse" })); + await handleHookEvent("PreToolUse", "opencode"); + expect(evaluatePolicies).toHaveBeenLastCalledWith( + "PreToolUse", + expect.objectContaining({ tool_name: canonical }), + expect.any(Object), + expect.any(Object), + ); + } + }); + + it("canonicalizes OpenCode Edit's four camelCase keys to snake_case", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow", + }); + mockStdin(JSON.stringify({ + tool_name: "edit", + tool_input: { filePath: "/repo/x", oldString: "a", newString: "b", replaceAll: true }, + hook_event_name: "PreToolUse", + })); + + await handleHookEvent("PreToolUse", "opencode"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.objectContaining({ + tool_name: "Edit", + tool_input: { file_path: "/repo/x", old_string: "a", new_string: "b", replace_all: true }, + }), + expect.any(Object), + expect.any(Object), + ); + }); + + it("OpenCode handler canonicalization is idempotent when shim already canonicalized", async () => { + // Fresh shim (post-#337) sends snake_case + PascalCase. Handler must + // not corrupt the shape on a second pass — the per-tool map's + // camelCase keys won't match snake_case input. + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow", + }); + mockStdin(JSON.stringify({ + tool_name: "Read", + tool_input: { file_path: "/etc/passwd", offset: 0, limit: 2000 }, + hook_event_name: "PreToolUse", + })); + + await handleHookEvent("PreToolUse", "opencode"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.objectContaining({ + tool_name: "Read", + tool_input: { file_path: "/etc/passwd", offset: 0, limit: 2000 }, + }), + expect.any(Object), + expect.any(Object), + ); + }); + + it("passes through unknown OpenCode tool names + args (MCP, extensions) unchanged", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow", + }); + mockStdin(JSON.stringify({ + tool_name: "mcp_github_create_issue", + tool_input: { title: "x", filePath: "/literal/key/name" }, + hook_event_name: "PreToolUse", + })); + + await handleHookEvent("PreToolUse", "opencode"); + + // Unknown tools NOT in OPENCODE_TOOL_INPUT_MAP — args pass through + // verbatim so MCP / third-party tool schemas aren't corrupted. + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.objectContaining({ + tool_name: "mcp_github_create_issue", + tool_input: { title: "x", filePath: "/literal/key/name" }, + }), + expect.any(Object), + expect.any(Object), + ); + }); + + // -- Pi handler-side canonicalization (defense-in-depth) --------------- + + it("canonicalizes raw Pi tool name `read` + Pi-shape `path` key when shim is stale", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow", + }); + // Stale Pi shim shape: lowercase `read` + Pi's `path` key (not file_path). + mockStdin(JSON.stringify({ + tool_name: "read", + tool_input: { path: "/etc/passwd" }, + hook_event_name: "tool_call", + })); + + await handleHookEvent("tool_call", "pi"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.objectContaining({ + tool_name: "Read", + tool_input: { file_path: "/etc/passwd" }, + }), + expect.any(Object), + expect.any(Object), + ); + }); + + it("canonicalizes every Pi tool name in PI_TOOL_MAP", async () => { + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + const cases: Array<[string, string]> = [ + ["bash", "Bash"], + ["read", "Read"], + ["write", "Write"], + ["edit", "Edit"], + ["glob", "Glob"], + ["grep", "Grep"], + ]; + for (const [raw, canonical] of cases) { + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow", + }); + mockStdin(JSON.stringify({ tool_name: raw, hook_event_name: "tool_call" })); + await handleHookEvent("tool_call", "pi"); + expect(evaluatePolicies).toHaveBeenLastCalledWith( + "PreToolUse", + expect.objectContaining({ tool_name: canonical }), + expect.any(Object), + expect.any(Object), + ); + } + }); + + it("does NOT canonicalize OpenCode/Pi tool inputs when cli=claude", async () => { + // A Claude session must not have its `filePath` literal rewritten, + // even though OPENCODE_TOOL_INPUT_MAP would match it. Per-CLI gate. + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow", + }); + mockStdin(JSON.stringify({ + tool_name: "Read", + tool_input: { filePath: "/literal" }, // Hypothetical Claude payload — must not be rewritten + hook_event_name: "PreToolUse", + })); + + await handleHookEvent("PreToolUse", "claude"); + + expect(evaluatePolicies).toHaveBeenCalledWith( + "PreToolUse", + expect.objectContaining({ + tool_name: "Read", + tool_input: { filePath: "/literal" }, + }), + expect.any(Object), + expect.any(Object), + ); + }); + it("fires telemetry with full payload for instruct decisions", async () => { const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); vi.mocked(evaluatePolicies).mockResolvedValueOnce({ diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index cf641153..43d43ba9 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -23,6 +23,10 @@ import { COPILOT_TOOL_MAP, CURSOR_TOOL_MAP, CODEX_TOOL_MAP, + OPENCODE_TOOL_MAP, + OPENCODE_TOOL_INPUT_MAP, + PI_TOOL_MAP, + PI_TOOL_INPUT_MAP, } from "./types"; import type { PolicyFunction, PolicyResult } from "./policy-types"; import { readMergedHooksConfig } from "./hooks-config"; @@ -87,9 +91,13 @@ function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType * • Cursor: PascalCase per Cursor docs but uses `Shell` for the bash- * equivalent — CURSOR_TOOL_MAP rewrites `Shell → Bash`; other * tool names already canonical and pass through - * • OpenCode: handled in the OpenCode plugin shim (in-process, - * self-contained) before the JSON crosses to this binary - * • Pi: handled in the Pi extension shim (same) + * • OpenCode: lowercase IDs (`bash`, `read`, …) — OPENCODE_TOOL_MAP. The + * OpenCode plugin shim ALSO canonicalizes inline as defense-in- + * depth; both passes are idempotent. Handler-side coverage + * here means a stale user-scope shim that pre-dates #337 still + * gets the canonicalization, without forcing a re-install. + * • Pi: lowercase IDs (`bash`, `read`, …) — PI_TOOL_MAP. Same dual- + * canonicalization story as OpenCode (shim + handler). * • Gemini: snake_case — GEMINI_TOOL_MAP * * Unknown tool names (MCP `mcp_*`, third-party extensions, Skills) pass @@ -101,9 +109,48 @@ function canonicalizeToolName(raw: string | undefined, cli: IntegrationType): st if (cli === "cursor") return CURSOR_TOOL_MAP[raw] ?? raw; if (cli === "codex") return CODEX_TOOL_MAP[raw] ?? raw; if (cli === "gemini") return GEMINI_TOOL_MAP[raw] ?? raw; + if (cli === "opencode") return OPENCODE_TOOL_MAP[raw] ?? raw; + if (cli === "pi") return PI_TOOL_MAP[raw] ?? raw; return raw; } +/** + * Canonicalize per-CLI tool-input keys to the snake_case shape that builtin + * policies read (e.g. `file_path`, `old_string`). OpenCode delivers args as + * camelCase (`filePath`, `oldString`, `newString`, `replaceAll`); Pi delivers + * `path` for Read/Write/Edit. Without translation, `getFilePath()` reads "" and + * the path-checking builtins (`block-read-outside-cwd`, `block-env-files`, + * `block-secrets-write`) silently no-op. + * + * Both CLIs' shims canonicalize inline before the JSON crosses to this binary. + * Handler-side coverage here is defense-in-depth: a user-scope shim that pre- + * dates #337 still passes the raw camelCase keys, and we want those installs + * to start enforcing the moment failproofai upgrades — without requiring a + * `failproofai policies --install --cli opencode` re-run. + * + * Idempotent: when the shim already canonicalized, the keys are snake_case + * and the per-tool map's camelCase keys don't match, so the loop is a no-op. + * + * Tools outside the per-CLI map (MCP `mcp_*`, third-party extensions) pass + * through unchanged so their schemas aren't corrupted. + */ +function canonicalizeToolInput( + toolName: string | undefined, + rawInput: unknown, + cli: IntegrationType, +): unknown { + if (!toolName || !rawInput || typeof rawInput !== "object") return rawInput; + let perToolMap: Record | undefined; + if (cli === "opencode") perToolMap = OPENCODE_TOOL_INPUT_MAP[toolName]; + else if (cli === "pi") perToolMap = PI_TOOL_INPUT_MAP[toolName]; + if (!perToolMap) return rawInput; + const out: Record = {}; + for (const [k, v] of Object.entries(rawInput as Record)) { + out[perToolMap[k] ?? k] = v; + } + return out; +} + export async function handleHookEvent( eventType: string, cli: IntegrationType = "claude", @@ -151,15 +198,25 @@ export async function handleHookEvent( // Canonicalize tool name in place so both the policy-registry tool-name // filter and policy bodies (`ctx.toolName === "Bash"`) see the canonical - // form. Today only Gemini's snake_case names need translation; other CLIs - // are no-ops here. Mutating `parsed.tool_name` keeps the activity store + - // telemetry tagging consistent (they read from `parsed.tool_name`). + // form. Mutating `parsed.tool_name` keeps the activity store + telemetry + // tagging consistent (they read from `parsed.tool_name`). const rawToolName = parsed.tool_name as string | undefined; const canonicalToolName = canonicalizeToolName(rawToolName, cli); if (canonicalToolName !== rawToolName) { parsed.tool_name = canonicalToolName; } + // Canonicalize tool-input keys for OpenCode + Pi (no-op for other CLIs). + // Defense-in-depth against stale shims that still pass camelCase / + // Pi-shape keys to the binary. The per-CLI shim ALSO canonicalizes; both + // passes are idempotent because the camelCase keys won't match a + // snake_case input. + const rawInput = parsed.tool_input; + const canonicalInput = canonicalizeToolInput(canonicalToolName, rawInput, cli); + if (canonicalInput !== rawInput) { + parsed.tool_input = canonicalInput; + } + // Extract session metadata from payload const sessionId = parsed.session_id as string | undefined; const session: SessionMetadata = { From 8e88d1f138cf988df5dd953ad8b7ca820def7acd Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 19:29:55 -0700 Subject: [PATCH 2/2] [luv-340] chore: align CHANGELOG date with UTC for 0.0.10-beta.11 section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged the freshly-added section heading dated 2026-05-09 while the PR was opened at 2026-05-10T02:26Z UTC. Bump the date so the heading is internally consistent with the prior `## 0.0.10-beta.10 — 2026-05-10` section and with the GH server clock that downstream release tooling reads. 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 d70a545c..c4ac8941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.0.10-beta.11 — 2026-05-09 +## 0.0.10-beta.11 — 2026-05-10 ### Fixes - Two-layer canonicalization for OpenCode `read` / `write` / `edit` tool calls so `block-read-outside-cwd` actually fires. PR #337 added `OPENCODE_TOOL_INPUT_MAP` to the production shim template at `src/hooks/integrations.ts:buildOpenCodePluginShim`, but the dev shim at `.opencode/plugins/failproofai.mjs` was hand-maintained and never regenerated, so contributors running `opencode` from inside this repo could still read `/home//...` outside the repo cwd — `getFilePath()` returned `""` (the camelCase `filePath` key didn't match the policy's snake_case `file_path` lookup) and the target-empty short-circuit at `src/hooks/builtin-policies.ts:799` returned allow. Two parallel fixes: (1) regenerate the dev shim with `TOOL_NAME_MAP` + `TOOL_INPUT_MAP` mirroring the production template, also picking up the `applyDecision` async-await change from #318 that the dev shim had been missing; (2) move canonicalization into the binary handler (`src/hooks/handler.ts`) for OpenCode + Pi as defense-in-depth, so user-scope shims that pre-date #337 automatically start enforcing the moment failproofai upgrades — without forcing a `failproofai policies --install --cli opencode` re-run. The shim canonicalization stays in place; both passes are idempotent because the per-CLI maps key on camelCase / Pi-shape and don't match already-canonical snake_case input. New `canonicalizeToolInput` helper in handler is gated by `cli === "opencode"` / `cli === "pi"` so Claude / Codex / Cursor / Copilot / Gemini sessions are unaffected. New unit tests in `__tests__/hooks/handler.test.ts` cover stale-shim shapes (`read` + `filePath`, `read` + `path`), per-tool map coverage parity (12 OpenCode + 6 Pi tool names, four Edit keys), the idempotency path (handler doesn't corrupt fresh-shim shapes), the unknown-tool passthrough (MCP `mcp_*` tool names + args), and the per-CLI gate (Claude payloads with literal `filePath` keys are not rewritten) (#340).