Skip to content
Open
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
26 changes: 26 additions & 0 deletions __tests__/hooks/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ vi.mock("../../src/hooks/hook-logger", () => ({
hookLogError: vi.fn(),
}));

vi.mock("../../src/hooks/hook-invocation-dedup", () => ({
claimHookInvocation: vi.fn(() => Promise.resolve({ role: "independent" })),
}));

describe("hooks/handler", () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
Expand Down Expand Up @@ -1251,6 +1255,28 @@ describe("hooks/handler", () => {
);
});

it("replays a duplicate decision without evaluating or persisting again", async () => {
const { claimHookInvocation } = await import("../../src/hooks/hook-invocation-dedup");
vi.mocked(claimHookInvocation).mockResolvedValueOnce({
role: "duplicate",
response: { exitCode: 2, stdout: "", stderr: "blocked once" },
});
mockStdin(JSON.stringify({
session_id: "session-1",
tool_use_id: "tool-1",
tool_name: "Bash",
}));
const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator");
const { persistHookActivity } = await import("../../src/hooks/hook-activity-store");

const exitCode = await handleHookEvent("PreToolUse");

expect(exitCode).toBe(2);
expect(stderrSpy).toHaveBeenCalledWith("blocked once");
expect(evaluatePolicies).not.toHaveBeenCalled();
expect(persistHookActivity).not.toHaveBeenCalled();
});
Comment on lines +1258 to +1278

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Good duplicate-path coverage, but the owner-side complete()/release() wiring is untested here.

This test verifies the duplicate branch thoroughly, but no test overrides the mock to {role: "owner", complete: vi.fn(), release: vi.fn()} to assert handleHookEvent calls complete({exitCode, stdout, stderr}) with evaluatePolicies's actual result and calls release() in finally. That's the other, arguably more important, half of this handler's new control flow (owner publish + cleanup lifecycle).

Based on path instructions, "Every new or changed behavior must have a corresponding unit test under __tests__/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@__tests__/hooks/handler.test.ts` around lines 1258 - 1278, Add a unit test
alongside the duplicate-path test for the owner branch of handleHookEvent: mock
claimHookInvocation with role "owner" and vi.fn() complete/release callbacks,
mock evaluatePolicies to return a known {exitCode, stdout, stderr} result, then
assert complete receives that exact result and release is called after handling,
including when evaluation completes through the handler’s finally cleanup path.

Source: Path instructions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add test for owner claim path in handleHookEvent.

The duplicate path is well-tested here (replay without evaluatePolicies or persistHookActivity), but there is no test that asserts the owner path: that when claimHookInvocation returns { role: "owner", complete, release }, the handler calls complete() with the evaluation result and release() in finally.

This is important because the owner publishes the result for duplicates to consume — if the wiring breaks, duplicates never get a response and fall back to independent evaluation silently.

Test outline: mock claimHookInvocation to return role=owner with vi.fn() callbacks, mock evaluatePolicies to return a known result, then assert complete was called with { exitCode, stdout, stderr } and release was called after handling.


it("persists instruct decision as 'instruct' in activity store", async () => {
const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator");
vi.mocked(evaluatePolicies).mockResolvedValueOnce({
Expand Down
91 changes: 91 additions & 0 deletions __tests__/hooks/hook-invocation-dedup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
claimHookInvocation,
setHookInvocationDedupDirForTests,
} from "../../src/hooks/hook-invocation-dedup";

describe("hook invocation deduplication", () => {
const tempDirs: string[] = [];

async function useTempDir(): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), "failproofai-hook-dedup-"));
tempDirs.push(dir);
setHookInvocationDedupDirForTests(dir);
}

afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

it("elects one owner and replays its response to a duplicate process", async () => {
await useTempDir();
const payload = { session_id: "session-1", tool_use_id: "tool-1" };
const owner = await claimHookInvocation("PreToolUse", "claude", payload);
expect(owner.role).toBe("owner");
if (owner.role !== "owner") throw new Error("expected owner claim");

const duplicatePromise = claimHookInvocation("PreToolUse", "claude", payload);
await owner.complete({ exitCode: 2, stdout: "", stderr: "blocked" });
const duplicate = await duplicatePromise;

expect(duplicate).toEqual({
role: "duplicate",
response: { exitCode: 2, stdout: "", stderr: "blocked" },
});
await expect(claimHookInvocation("PreToolUse", "claude", payload)).resolves.toEqual({
role: "duplicate",
response: { exitCode: 2, stdout: "", stderr: "blocked" },
});
});

it("does not combine different tool uses or hook events", async () => {
await useTempDir();
const first = await claimHookInvocation("PreToolUse", "claude", {
session_id: "session-1",
tool_use_id: "tool-1",
});
const differentTool = await claimHookInvocation("PreToolUse", "claude", {
session_id: "session-1",
tool_use_id: "tool-2",
});
const differentEvent = await claimHookInvocation("PostToolUse", "claude", {
session_id: "session-1",
tool_use_id: "tool-1",
});

expect(first.role).toBe("owner");
expect(differentTool.role).toBe("owner");
expect(differentEvent.role).toBe("owner");
if (first.role === "owner") await first.release();
if (differentTool.role === "owner") await differentTool.release();
if (differentEvent.role === "owner") await differentEvent.release();
});

it("lets another process take ownership when the first exits before publishing", async () => {
await useTempDir();
const payload = { session_id: "session-1", tool_use_id: "tool-1" };
const first = await claimHookInvocation("PreToolUse", "claude", payload);
expect(first.role).toBe("owner");
if (first.role !== "owner") throw new Error("expected owner claim");
await first.release();

const retry = await claimHookInvocation("PreToolUse", "claude", payload);
expect(retry.role).toBe("owner");
if (retry.role === "owner") await retry.release();
});

it("only deduplicates Claude payloads with stable invocation identifiers", async () => {
await useTempDir();
await expect(claimHookInvocation("PreToolUse", "codex", {
session_id: "session-1",
tool_use_id: "tool-1",
})).resolves.toEqual({ role: "independent" });
await expect(claimHookInvocation("PreToolUse", "claude", {
session_id: "session-1",
})).resolves.toEqual({ role: "independent" });
});
});
18 changes: 18 additions & 0 deletions src/hooks/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { resolvePermissionMode } from "./resolve-permission-mode";
import { resolveTranscriptPath } from "./resolve-transcript-path";
import { getInstanceId } from "../../lib/telemetry-id";
import { hookLogInfo, hookLogWarn } from "./hook-logger";
import { claimHookInvocation } from "./hook-invocation-dedup";

/**
* Canonicalize an event name to PascalCase. Codex sends snake_case event names
Expand Down Expand Up @@ -92,6 +93,7 @@ export async function handleHookEvent(
cli: IntegrationType = "claude",
): Promise<number> {
const startTime = performance.now();
let invocationClaim: Awaited<ReturnType<typeof claimHookInvocation>> | undefined;
try {

// Read stdin payload (Claude passes JSON)
Expand Down Expand Up @@ -197,6 +199,14 @@ export async function handleHookEvent(
}
}

invocationClaim = await claimHookInvocation(eventType, cli, parsed);
if (invocationClaim.role === "duplicate") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical — Duplicate replay skips per-CLI output shaping

When the duplicate path returns, it calls process.stdout.write and process.stderr.write directly on the raw stored strings (lines 204-205). However, the normal evaluation path's output is shaped per-CLI by evaluatePolicies (which returns CLI-specific JSON shapes for deny/instruct decisions — cursor, pi, hermes, goose, etc.).

The replayed response is the RAW exitCode/stdout/stderr triple that the owner stored, which is correct for Claude (exit 2 + stderr), but if the handler ever adds post-evaluation output reshaping outside of evaluatePolicies, this path would miss it.

Current status: Safe today because all output shaping happens inside evaluatePolicies and the owner stores the already-shaped result. But this is a fragile coupling — the two output paths (owner vs duplicate) have diverged in responsibility.

Fix: Consider routing the replayed response through a common output function or at least document that all output shaping must be complete before invocationClaim.complete() is called in the owner path.

if (invocationClaim.response.stdout) process.stdout.write(invocationClaim.response.stdout);
if (invocationClaim.response.stderr) process.stderr.write(invocationClaim.response.stderr);
hookLogInfo(`duplicate invocation replayed event=${eventType} cli=${cli}`);
return invocationClaim.response.exitCode;
}

// Canonicalize event name (Codex sends snake_case; internals expect PascalCase)
const canonicalEventType = canonicalizeEventType(eventType, cli);

Expand Down Expand Up @@ -311,6 +321,13 @@ export async function handleHookEvent(

// Evaluate policies (use canonical PascalCase event type)
const result = await evaluatePolicies(canonicalEventType, parsed, session, config);
if (invocationClaim.role === "owner") {
await invocationClaim.complete({
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
});
}
const durationMs = Math.round(performance.now() - startTime);
hookLogInfo(`result=${result.decision} policy=${result.policyName ?? "none"} duration=${durationMs}ms`);

Expand Down Expand Up @@ -376,6 +393,7 @@ export async function handleHookEvent(
}
return result.exitCode;
} finally {
if (invocationClaim?.role === "owner") await invocationClaim.release();
// Await any un-awaited (`void trackHookEvent(...)`) events fired during
// this invocation. bin/failproofai.mjs calls process.exit() the moment we
// return OR throw, which would otherwise drop in-flight POSTs — notably on
Expand Down
149 changes: 149 additions & 0 deletions src/hooks/hook-invocation-dedup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Cross-process deduplication for Claude hooks installed in multiple scopes.
*
* Claude starts one hook process per matching user/project/local registration.
* Those processes receive the same session + tool-use identifiers. An exclusive
* lock elects one process to evaluate policies; the others wait briefly and
* replay its response without duplicating activity or telemetry.
*/
import { createHash } from "node:crypto";
import { mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { IntegrationType } from "./types";

const RESULT_TTL_MS = 5 * 60_000;
const WAIT_TIMEOUT_MS = 12_000;
Comment on lines +15 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

ast-grep outline src/hooks/hook-invocation-dedup.ts --view expanded

Repository: FailproofAI/failproofai

Length of output: 909


🏁 Script executed:

sed -n '1,220p' src/hooks/hook-invocation-dedup.ts && printf '\n---\n' && sed -n '1,240p' src/hooks/policy-evaluator.ts

Repository: FailproofAI/failproofai

Length of output: 15640


🏁 Script executed:

rg -n "claimHookInvocation|WAIT_TIMEOUT_MS|timeout|10s|10000|custom hook|evaluatePolicies" src/hooks src | sed -n '1,220p'

Repository: FailproofAI/failproofai

Length of output: 14633


🏁 Script executed:

sed -n '180,340p' src/hooks/handler.ts

Repository: FailproofAI/failproofai

Length of output: 7332


WAIT_TIMEOUT_MS should track the owner’s real worst-case runtime. claimHookInvocation() gives up after 12s, but the owner path can run longer than that once multiple custom hooks are loaded: each hook gets a 10s Promise.race budget, and evaluatePolicies() runs them sequentially. Duplicate processes can therefore abandon dedup, return role: "independent", and re-run policy evaluation/telemetry while the owner is still working.

Derive the wait from the custom-hook budget, or add a heartbeat/touch mechanism so waiters can keep waiting while the owner is active.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/hook-invocation-dedup.ts` around lines 15 - 16, Update
WAIT_TIMEOUT_MS and the claimHookInvocation flow so waiting covers the owner’s
worst-case runtime when multiple custom hooks are evaluated sequentially, rather
than expiring at 12 seconds. Derive the timeout from the existing per-hook
custom-hook budget and evaluation limits, or add owner heartbeat/touch support
that extends active waits; preserve deduplication until the owner finishes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: WAIT_TIMEOUT_MS (12s) may be shorter than owner worst-case runtime.

Duplicates poll for 12s before falling through to independent evaluation. But the owner evaluates custom hooks sequentially, each with a 10s Promise.race budget (handler.ts line 270). With 2+ custom hooks, owner runtime exceeds 12s and duplicates abandon dedup, producing duplicate telemetry and activity entries.

Fix options:
(1) Derive WAIT_TIMEOUT_MS from custom hook count x 10s + buffer.
(2) Owner heartbeat: periodically touch the lock file mtime so waiters can extend their deadline.
(3) Cap WAIT_TIMEOUT_MS at max possible hook budget, e.g. max hooks x 10s + 5s.

const POLL_INTERVAL_MS = 10;

let dedupDir = join(homedir(), ".failproofai", "cache", "hook-invocations");

export interface HookInvocationResponse {
exitCode: number;
stdout: string;
stderr: string;
}

export type HookInvocationClaim =
| { role: "owner"; complete: (response: HookInvocationResponse) => Promise<void>; release: () => Promise<void> }
| { role: "duplicate"; response: HookInvocationResponse }
| { role: "independent" };

function invocationKey(
eventType: string,
cli: IntegrationType,
payload: Record<string, unknown>,
): string | null {
if (cli !== "claude") return null;
const sessionId = payload.session_id;
const toolUseId = payload.tool_use_id;
if (typeof sessionId !== "string" || typeof toolUseId !== "string") return null;
return createHash("sha256").update(`${cli}\0${eventType}\0${sessionId}\0${toolUseId}`).digest("hex");
}

async function removeOldEntries(): Promise<void> {
try {
const names = await readdir(dedupDir);
const now = Date.now();
await Promise.all(names.map(async (name) => {
const path = join(dedupDir, name);
try {
if (now - (await stat(path)).mtimeMs > RESULT_TTL_MS) await unlink(path);
} catch {
// Another hook process may have removed it first.
}
}));
} catch {
// Cleanup is best-effort and must never delay or block policy evaluation.
}
}

async function readResponse(path: string): Promise<HookInvocationResponse | null> {
try {
const parsed = JSON.parse(await readFile(path, "utf8")) as Partial<HookInvocationResponse>;
if (
typeof parsed.exitCode === "number"
&& typeof parsed.stdout === "string"
&& typeof parsed.stderr === "string"
) {
return parsed as HookInvocationResponse;
}
} catch {
// The owner has not published its response yet.
}
return null;
}

async function readFreshResponse(path: string): Promise<HookInvocationResponse | null> {
try {
if (Date.now() - (await stat(path)).mtimeMs > RESULT_TTL_MS) return null;
} catch {
return null;
}
return readResponse(path);
}

export async function claimHookInvocation(
eventType: string,
cli: IntegrationType,
payload: Record<string, unknown>,
): Promise<HookInvocationClaim> {
const key = invocationKey(eventType, cli, payload);
if (!key) return { role: "independent" };

try {
await mkdir(dedupDir, { recursive: true });
void removeOldEntries();
const lockPath = join(dedupDir, `${key}.lock`);
const resultPath = join(dedupDir, `${key}.json`);
const existingResponse = await readFreshResponse(resultPath);
if (existingResponse) return { role: "duplicate", response: existingResponse };

try {
const handle = await open(lockPath, "wx");
await handle.close();
const racedResponse = await readFreshResponse(resultPath);
if (racedResponse) {
try { await unlink(lockPath); } catch { /* best-effort */ }
return { role: "duplicate", response: racedResponse };
}
let completed = false;
return {
role: "owner",
complete: async (response) => {
try {
const tmpPath = `${resultPath}.${process.pid}.tmp`;
await writeFile(tmpPath, JSON.stringify(response), "utf8");
await rename(tmpPath, resultPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: rename() may fail on Windows when resultPath already exists.

On line 117, rename(tmpPath, resultPath) can fail with EPERM/EEXIST on Windows if a resultPath already exists (e.g. from a previous owner that crashed after writing but before unlinking). The catch block swallows the error silently and leaves the response unpublished. This makes duplicates wait the full WAIT_TIMEOUT_MS and fall back to independent evaluation.

The race is narrow: the only scenario is a crashed owner that left a stale resultPath AND a new owner that claims the same invocation key before RESULT_TTL_MS expires (5 min). Still worth hardening.

Fix: Before rename(), try unlink(resultPath) to remove any stale destination, or catch rename errors and retry with a direct writeFile(resultPath, ...). On non-Windows platforms, rename already atomically replaces the destination.

completed = true;
} catch {
// Publishing is best-effort; the owner must still return its policy result.
} finally {
try { await unlink(lockPath); } catch { /* best-effort */ }
}
Comment on lines +113 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and inspect the relevant ranges.
ast-grep outline src/hooks/hook-invocation-dedup.ts --view expanded || true
wc -l src/hooks/hook-invocation-dedup.ts
sed -n '1,220p' src/hooks/hook-invocation-dedup.ts

# Find cleanup / expiry logic for resultPath and lockPath.
rg -n "resultPath|lockPath|removeOldEntries|rename\\(|unlink\\(" src/hooks -S

Repository: FailproofAI/failproofai

Length of output: 7890


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import os, pathlib, json
path = pathlib.Path('src/hooks/hook-invocation-dedup.ts')
print(path.exists())
PY

Repository: FailproofAI/failproofai

Length of output: 166


🌐 Web query:

Node.js fs.rename overwrite semantics Windows destination exists docs

💡 Result:

In Node.js, the behavior of fs.rename and fs.renameSync when the destination path (newPath) already exists is platform-dependent [1]. On POSIX-compliant systems (such as Linux and macOS), fs.rename typically overwrites the existing file at the destination [2][1]. On Windows, however, the behavior differs significantly. The operation is not guaranteed to overwrite an existing destination file [1]. Attempting to rename a file to a destination that already exists on Windows often results in an error (commonly EPERM or EEXIST) [1][3]. This is due to underlying Windows file system behaviors and restrictions—such as file locking—that prevent the seamless replacement of an existing file via a simple rename operation [3]. Because of these cross-platform inconsistencies, it is considered a best practice to check if the destination file exists before performing a rename operation if you require portable behavior [1]. You can verify the existence of the destination file using fs.access or fs.stat and then use fs.unlink to remove it if necessary before calling fs.rename [1]. Top results: [1][3]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node - <<'JS'
const fs = require('fs/promises');
const path = require('path');
const os = require('os');

(async () => {
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rename-probe-'));
  const src = path.join(dir, 'src.txt');
  const dst = path.join(dir, 'dst.txt');
  await fs.writeFile(src, 'src');
  await fs.writeFile(dst, 'dst');
  try {
    await fs.rename(src, dst);
    const data = await fs.readFile(dst, 'utf8');
    console.log(JSON.stringify({rename: 'ok', dst: data}));
  } catch (e) {
    console.log(JSON.stringify({rename: 'error', code: e.code, message: e.message}));
  } finally {
    await fs.rm(dir, { recursive: true, force: true });
  }
})();
JS

Repository: FailproofAI/failproofai

Length of output: 189


Handle existing resultPath before rename() on Windows.
rename(tmpPath, resultPath) can fail when the destination already exists, and the best-effort catch leaves the response unpublished. That makes duplicates wait out the full timeout and fall back to independent evaluation; unlink the destination first or retry with a direct write on rename() failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/hook-invocation-dedup.ts` around lines 113 - 123, Update the
complete callback’s result publication flow to handle an existing resultPath
before or when calling rename, such as removing the destination first or
retrying with a direct write after rename fails. Preserve the best-effort
cleanup of lockPath and ensure completed is set only after the response is
successfully published.

},
release: async () => {
if (!completed) {
try { await unlink(lockPath); } catch { /* best-effort */ }
}
},
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return { role: "independent" };
}

const deadline = Date.now() + WAIT_TIMEOUT_MS;
while (Date.now() < deadline) {
const response = await readResponse(resultPath);
if (response) return { role: "duplicate", response };
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
} catch {
// Deduplication is an optimization. Fail open into normal policy evaluation.
}
return { role: "independent" };
}

export function setHookInvocationDedupDirForTests(path: string): void {
dedupDir = path;
}