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
71 changes: 71 additions & 0 deletions __tests__/hooks/dedup-invocation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, vi } from "vitest";

vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
let mockStore: Record<string, string> = {};
return {
...actual,
existsSync: vi.fn((path: string) => path in mockStore),
readFileSync: vi.fn((path: string) => mockStore[path] ?? "{}"),
writeFileSync: vi.fn((path: string, content: string) => {
mockStore[path] = content;
}),
mkdirSync: vi.fn(),
__resetMockStore: () => {
mockStore = {};
},
};
});

describe("hooks/dedup-invocation", () => {
beforeEach(async () => {
vi.resetAllMocks();
const fs = await import("node:fs");
(fs as unknown as { __resetMockStore: () => void }).__resetMockStore();
});

it("returns isDuplicate: false on first invocation", async () => {
const { isDuplicateInvocation } = await import("../../src/hooks/dedup-invocation");
const result = isDuplicateInvocation("claude", "PreToolUse", {
session_id: "s1",
tool_name: "Bash",
tool_input: { command: "ls" },
});
expect(result.isDuplicate).toBe(false);
});

it("returns isDuplicate: true when identical invocation occurs within deduplication window", async () => {
const { isDuplicateInvocation, recordInvocation } = await import("../../src/hooks/dedup-invocation");
const payload = {
session_id: "s1",
tool_name: "Bash",
tool_input: { command: "ls" },
};

recordInvocation("claude", "PreToolUse", payload, 0);

const check = isDuplicateInvocation("claude", "PreToolUse", payload);
expect(check.isDuplicate).toBe(true);
expect(check.exitCode).toBe(0);
});

it("returns isDuplicate: false when tool_input or session differs", async () => {
const { isDuplicateInvocation, recordInvocation } = await import("../../src/hooks/dedup-invocation");
const payload1 = {
session_id: "s1",
tool_name: "Bash",
tool_input: { command: "ls" },
};
const payload2 = {
session_id: "s1",
tool_name: "Bash",
tool_input: { command: "pwd" },
};

recordInvocation("claude", "PreToolUse", payload1, 0);

const check = isDuplicateInvocation("claude", "PreToolUse", payload2);
expect(check.isDuplicate).toBe(false);
});
Comment on lines +53 to +70

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 | 🟡 Minor | ⚡ Quick win

Cover the session-ID key branch.

This case only changes tool_input; add a same-input/different-session_id assertion expecting isDuplicate: false.

As per coding guidelines, “Add corresponding unit tests for every new or changed behavior.”

🤖 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/dedup-invocation.test.ts` around lines 53 - 70, Add a
unit-test assertion in the existing “returns isDuplicate: false when tool_input
or session differs” test using identical tool_input but a different session_id,
then verify isDuplicate is false through isDuplicateInvocation after recording
the original payload.

Source: Coding guidelines

});
87 changes: 87 additions & 0 deletions src/hooks/dedup-invocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

const DEDUP_CACHE_DIR = join(homedir(), ".failproofai", "cache");
const DEDUP_CACHE_FILE = join(DEDUP_CACHE_DIR, "dedup-invocations.json");
const DEDUP_WINDOW_MS = 2000;

interface DedupRecord {
key: string;
timestamp: number;
exitCode: number;
}
Comment on lines +9 to +13

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 | 🏗️ Heavy lift

Replay the full hook response, not only the exit code.

Normal handling writes result.stdout and result.stderr, but duplicates return only exitCode. A duplicate deny/instruct invocation can therefore receive a different CLI response. Persist and replay the required response payload alongside the result.

  • src/hooks/dedup-invocation.ts#L9-L13: include the response fields needed to reproduce the result.
  • src/hooks/dedup-invocation.ts#L48-L53: record those fields with the completed invocation.
  • src/hooks/handler.ts#L225-L229: emit the cached response before returning.

As per coding guidelines, “For stop events, preserve each CLI's retry semantics.”

📍 Affects 2 files
  • src/hooks/dedup-invocation.ts#L9-L13 (this comment)
  • src/hooks/dedup-invocation.ts#L48-L53
  • src/hooks/handler.ts#L225-L229
🤖 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/dedup-invocation.ts` around lines 9 - 13, Extend DedupRecord in
src/hooks/dedup-invocation.ts (lines 9-13) with the response fields required to
reproduce stdout, stderr, and stop-event retry semantics; persist those fields
when recording the completed invocation (lines 48-53). In src/hooks/handler.ts
(lines 225-229), emit the cached response through the normal output path before
returning, rather than replaying only exitCode.

Source: Coding guidelines


export function isDuplicateInvocation(
cli: string,
eventType: string,
parsed: Record<string, unknown>,
): { isDuplicate: boolean; exitCode: number } {
try {
const session = (parsed.session_id as string) ?? "";
const tool = (parsed.tool_name as string) ?? "";
const toolInput = parsed.tool_input ? JSON.stringify(parsed.tool_input) : "";
const key = `${cli}:${eventType}:${session}:${tool}:${toolInput}`;
Comment on lines +21 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not persist raw tool input in cache keys.

The JSON cache stores the full serialized tool_input as an object key. Tool inputs can contain commands, credentials, or file contents; stale values also remain until a later write triggers pruning. Derive the key from a one-way digest of a structured tuple instead.

  • src/hooks/dedup-invocation.ts#L21-L24: derive a non-reversible structured key.
  • src/hooks/dedup-invocation.ts#L55-L58: use the identical derivation when recording.
  • src/hooks/dedup-invocation.ts#L82-L83: persist only the digest-based key.
📍 Affects 1 file
  • src/hooks/dedup-invocation.ts#L21-L24 (this comment)
  • src/hooks/dedup-invocation.ts#L55-L58
  • src/hooks/dedup-invocation.ts#L82-L83
🤖 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/dedup-invocation.ts` around lines 21 - 24, Replace the raw
toolInput portion of the key derived near parsed.session_id, parsed.tool_name,
and tool_input with a one-way digest of the structured tuple (cli, eventType,
session, tool, and tool input). Reuse the identical derivation at the recording
logic around lines 55-58 and persist only the digest-based key around lines
82-83 in src/hooks/dedup-invocation.ts.


const now = Date.now();
let records: Record<string, DedupRecord> = {};

if (existsSync(DEDUP_CACHE_FILE)) {
try {
const raw = readFileSync(DEDUP_CACHE_FILE, "utf8");
records = JSON.parse(raw) as Record<string, DedupRecord>;
} catch {
records = {};
}
}

const existing = records[key];
if (existing && now - existing.timestamp < DEDUP_WINDOW_MS) {
return { isDuplicate: true, exitCode: existing.exitCode };
Comment on lines +29 to +40

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 | 🏗️ Heavy lift

Make duplicate claiming atomic.

Concurrent hook processes can all read an empty cache before any reaches recordInvocation, so each evaluates policies and emits duplicate activity/telemetry—the exact case this PR targets. Use an atomic claim/lock with a pending state; later processes should wait briefly for the completed result rather than independently evaluating.

  • src/hooks/dedup-invocation.ts#L29-L40: atomically claim a previously unseen invocation.
  • src/hooks/dedup-invocation.ts#L63-L83: finalize the claimed record without lost concurrent updates.
📍 Affects 1 file
  • src/hooks/dedup-invocation.ts#L29-L40 (this comment)
  • src/hooks/dedup-invocation.ts#L63-L83
🤖 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/dedup-invocation.ts` around lines 29 - 40, Make invocation claiming
atomic in src/hooks/dedup-invocation.ts at lines 29-40 by adding a lock or
pending record state so only one process claims a previously unseen key;
competing processes must briefly wait for the completed record and reuse its
result instead of evaluating independently. Update recordInvocation at lines
63-83 to finalize the pending claim without overwriting concurrent records or
losing updates.

}
} catch {
// Fail-open: if dedup check fails, treat as not duplicate
}
return { isDuplicate: false, exitCode: 0 };
}

export function recordInvocation(
cli: string,
eventType: string,
parsed: Record<string, unknown>,
exitCode: number,
): void {
try {
const session = (parsed.session_id as string) ?? "";
const tool = (parsed.tool_name as string) ?? "";
const toolInput = parsed.tool_input ? JSON.stringify(parsed.tool_input) : "";
const key = `${cli}:${eventType}:${session}:${tool}:${toolInput}`;

const now = Date.now();
let records: Record<string, DedupRecord> = {};

mkdirSync(DEDUP_CACHE_DIR, { recursive: true });

if (existsSync(DEDUP_CACHE_FILE)) {
try {
const raw = readFileSync(DEDUP_CACHE_FILE, "utf8");
records = JSON.parse(raw) as Record<string, DedupRecord>;
} catch {
records = {};
}
}

// Clean up stale records older than 10 seconds to keep cache small
const cleaned: Record<string, DedupRecord> = {};
for (const [k, v] of Object.entries(records)) {
if (now - v.timestamp < 10_000) {
cleaned[k] = v;
}
}

cleaned[key] = { key, timestamp: now, exitCode };
writeFileSync(DEDUP_CACHE_FILE, JSON.stringify(cleaned), "utf8");
} catch {
// Non-blocking write
}
}
9 changes: 9 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 { isDuplicateInvocation, recordInvocation } from "./dedup-invocation";

/**
* Canonicalize an event name to PascalCase. Codex sends snake_case event names
Expand Down Expand Up @@ -221,6 +222,13 @@ export async function handleHookEvent(
parsed.tool_input = canonicalInput;
}

// Deduplicate duplicate hook handler invocations from multiple scopes
const { isDuplicate, exitCode: prevExitCode } = isDuplicateInvocation(cli, canonicalEventType, parsed);
if (isDuplicate) {
hookLogInfo(`deduplicated duplicate hook invocation from multiple scopes: event=${canonicalEventType} cli=${cli}`);
return prevExitCode;
}

// Extract session metadata from payload
const sessionId = parsed.session_id as string | undefined;
const session: SessionMetadata = {
Expand Down Expand Up @@ -377,6 +385,7 @@ export async function handleHookEvent(
// Telemetry is best-effort — never block the hook
}
}
recordInvocation(cli, canonicalEventType, parsed, result.exitCode);
return result.exitCode;
} finally {
// Await any un-awaited (`void trackHookEvent(...)`) events fired during
Expand Down