Skip to content

fix(hooks): deduplicate hook handler invocations when installed in mu… - #621

Open
AVPthegreat wants to merge 1 commit into
FailproofAI:mainfrom
AVPthegreat:fix/deduplicate-hook-handler-invocations-multi-scope
Open

fix(hooks): deduplicate hook handler invocations when installed in mu…#621
AVPthegreat wants to merge 1 commit into
FailproofAI:mainfrom
AVPthegreat:fix/deduplicate-hook-handler-invocations-multi-scope

Conversation

@AVPthegreat

@AVPthegreat AVPthegreat commented Jul 29, 2026

Copy link
Copy Markdown

Summary

Fixes #58 by introducing an invocation fingerprint deduplication cache (src/hooks/dedup-invocation.ts) in handleHookEvent, preventing duplicate policy execution, duplicate activity store logs, and duplicate telemetry when hooks are installed across multiple settings scopes (e.g. global ~/.claude/settings.json and project .claude/settings.json).


❌ Root Cause Analysis

When failproofai is installed in multiple scopes (e.g., both user scope ~/.claude/settings.json and project scope .claude/settings.json):

  1. The agent CLI (such as Claude Code) merges settings files and fires failproofai --hook PreToolUse twice for the exact same event.
  2. Previously, handleHookEvent ran full policy evaluation, logged duplicate activity entries to hook-activity-store, and emitted duplicate telemetry for both processes.

✅ Fix Details

  1. Invocation Deduplication Cache (src/hooks/dedup-invocation.ts):
    Created isDuplicateInvocation and recordInvocation using a lightweight timestamped invocation fingerprint cache stored in ~/.failproofai/cache/dedup-invocations.json.
    Fingerprint key: ${cli}:${eventType}:${sessionId}:${toolName}:${JSON.stringify(toolInput)}

  2. Hook Handler Integration (src/hooks/handler.ts):

    • In handleHookEvent, computes invocation key after payload normalization.
    • If an identical invocation occurred within the 2000 ms deduplication window, handleHookEvent logs a deduplication warning and immediately returns the previous exit code without duplicate policy re-evaluation, duplicate activity logging, or duplicate telemetry.
    • On completion, recordInvocation updates the cache.

🧪 Unit Tests & Verification

Added unit test suite __tests__/hooks/dedup-invocation.test.ts:

  • returns isDuplicate: false on first invocation
  • returns isDuplicate: true when identical invocation occurs within deduplication window
  • returns isDuplicate: false when tool_input or session differs

Test Results:

  • Ran vitest run __tests__/hooks/dedup-invocation.test.ts
  • Result: ✓ 3 passed (3)

Summary by CodeRabbit

  • New Features

    • Added automatic deduplication for repeated CLI hook invocations.
    • Identical invocations received within a short window are skipped and return the original exit status.
    • Invocations with different tool inputs continue to be processed independently.
    • Deduplication state is persisted locally and safely recovers from cache read or write errors.
  • Tests

    • Added coverage for duplicate detection, exit-code preservation, and differing tool inputs.

Copilot AI review requested due to automatic review settings July 29, 2026 07:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Hook invocation deduplication

Layer / File(s) Summary
Persisted deduplication state
src/hooks/dedup-invocation.ts, __tests__/hooks/dedup-invocation.test.ts
Invocation keys use CLI, event, session, tool, and tool input data; recent records are read, pruned, written, and tested for duplicate and distinct payload behavior.
Handler enforcement and recording
src/hooks/handler.ts
Canonicalized invocations are checked before policy processing, while non-duplicates are recorded with their evaluated exit code.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HookHandler
  participant DeduplicationCache
  participant PolicyEvaluation
  HookHandler->>DeduplicationCache: Check canonical invocation
  DeduplicationCache-->>HookHandler: Return duplicate status and prior exit code
  alt New invocation
    HookHandler->>PolicyEvaluation: Evaluate policy
    PolicyEvaluation-->>HookHandler: Return exit code
    HookHandler->>DeduplicationCache: Record invocation and exit code
  end
Loading

Possibly related PRs

Suggested reviewers: copilot

Poem

I’m a rabbit guarding calls in a row,
Catching repeats before they grow.
Fresh tool inputs hop right through,
Old echoes keep their exit code too.
The cache trims stale tracks at night.
🐇✨ Times two becomes just right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: deduplicating hook handler invocations in hooks.
Description check ✅ Passed The description explains the bug, root cause, fix, and test results; it only omits the explicit Type of Change selection.
Linked Issues check ✅ Passed The changes implement the runtime deduplication guard requested by #58 and cover the duplicate-invocation scenarios.
Out of Scope Changes check ✅ Passed The PR stays focused on hook invocation deduplication and the related tests, with no obvious unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

__tests__/hooks/dedup-invocation.test.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

src/hooks/dedup-invocation.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

src/hooks/handler.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hermes-exosphere

Copy link
Copy Markdown
Contributor

Your PR is awaiting review by a reviewer. Till then you can join the Discord for conversation: https://discord.befailproof.ai

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.

Inline comments:
In `@__tests__/hooks/dedup-invocation.test.ts`:
- Around line 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.

In `@src/hooks/dedup-invocation.ts`:
- Around line 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.
- Around line 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 78c3a9fd-bc7b-4ec6-80ea-6a614a81dad0

📥 Commits

Reviewing files that changed from the base of the PR and between af74838 and 670d123.

📒 Files selected for processing (3)
  • __tests__/hooks/dedup-invocation.test.ts
  • src/hooks/dedup-invocation.ts
  • src/hooks/handler.ts

Comment on lines +53 to +70
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);
});

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

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

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

Comment on lines +21 to +24
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}`;

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.

Comment on lines +29 to +40
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 };

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[fix] deduplicate hook handler invocations when installed in multiple scopes

3 participants