-
Notifications
You must be signed in to change notification settings - Fork 28
fix(hooks): deduplicate multi-scope invocations #534
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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>; | ||
|
|
@@ -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(); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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({ | ||
|
|
||
| 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" }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
|
@@ -197,6 +199,14 @@ export async function handleHookEvent( | |
| } | ||
| } | ||
|
|
||
| invocationClaim = await claimHookInvocation(eventType, cli, parsed); | ||
| if (invocationClaim.role === "duplicate") { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
|
||
|
|
@@ -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`); | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 expandedRepository: 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.tsRepository: 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.tsRepository: FailproofAI/failproofai Length of output: 7332
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: |
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 -SRepository: 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())
PYRepository: FailproofAI/failproofai Length of output: 166 🌐 Web query:
💡 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 });
}
})();
JSRepository: FailproofAI/failproofai Length of output: 189 Handle existing 🤖 Prompt for AI Agents |
||
| }, | ||
| 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; | ||
| } | ||
There was a problem hiding this comment.
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
duplicatebranch thoroughly, but no test overrides the mock to{role: "owner", complete: vi.fn(), release: vi.fn()}to asserthandleHookEventcallscomplete({exitCode, stdout, stderr})withevaluatePolicies's actual result and callsrelease()infinally. 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
Source: Path instructions