Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (#337).
- Route OpenCode project pages by encoded cwd (`encodeFolderName(worktree)`) instead of opencode's project name / basename, fixing the dashboard `/project/<slug>` 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 `## <version> — <YYYY-MM-DD>` 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).

Expand Down
22 changes: 22 additions & 0 deletions __tests__/e2e/hooks/opencode-integration.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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 {
Expand Down
90 changes: 90 additions & 0 deletions __tests__/hooks/opencode-plugin-shim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<string, unknown> = {};
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" });
Expand Down
65 changes: 65 additions & 0 deletions __tests__/hooks/pi-extension-shim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> };
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<string, unknown> };
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<string, unknown> };
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<string, unknown> };
// 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<string, unknown> };
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<string, string> = { 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<string, unknown> = {};
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<string, unknown> };
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;
Expand Down
Loading