Skip to content

Commit 69ec18d

Browse files
committed
v3.0(item #8): pre-mortem mistake-guard (PreToolUse)
Opt-in warnings that fire BEFORE Claude Code runs an Edit/Write/Bash tool call against code previously flagged as a mistake. Fully gated via ENGRAM_MISTAKE_GUARD env var — zero overhead when unset. MODES unset / '0' → off (default — no database read, no overhead) '1' → permissive: tool proceeds, a warning is prepended to any additionalContext the primary handler emits '2' → strict: tool is denied with the warning as reason Hooks Edit/Write/Bash only. Read already surfaces mistakes via the engram:mistakes context provider — duplicating at tool-call time would be noise. MATCHING Edit/Write: - Normalize tool_input.file_path to relative POSIX vs projectRoot - Indexed lookup via store.getNodesByFile() (uses idx_nodes_source_file) - Dedupe by node id when both relative + raw shapes are stored Bash: - Substring match on mistake.metadata.commandPattern (length >2) - Fallback: substring match on mistake.sourceFile (length >3 to avoid accidentally matching single-char paths like 'a') - Full-table scan of mistakes (unavoidable — no file axis to index on). Bounded by project size; only runs when the guard is explicitly on. BI-TEMPORAL FILTER (item #7 interop) Mistakes with validUntil <= now are suppressed — they refer to code that has since been refactored away. Prevents stale-warning fatigue. INTEGRATION New file: src/intercept/handlers/mistake-guard.ts - currentGuardMode() — reads env var at call time, not module load, so tests can flip between cases cleanly - findMatchingMistakesAsync(target, projectRoot) — the matcher - formatWarning(matches) — human-readable warning block - applyMistakeGuard(rawResult, payload, kind) — wrapping fn that augments additionalContext (permissive) or overrides to deny (strict) src/intercept/dispatch.ts wiring: after runHandler() returns for Edit/ Write/Bash, pass result through applyMistakeGuard() before returning. Two-line diff. Doesn't touch the existing handlers. SAFETY Every code path in mistake-guard is wrapped in try/catch with a null return. A guard failure MUST NEVER break the primary handler. If the store open fails, the env var is wrong, the payload is malformed — guard silently returns the raw result unchanged. TESTS (+21 cases in tests/intercept/handlers/mistake-guard.test.ts) - currentGuardMode: off/permissive/strict recognition, bogus values coerced to off - formatWarning: empty-match string, single-match header, >5-match collapse with '… and N more' - findMatchingMistakesAsync (file): rel path, abs path normalization, no-match, validUntil filter - findMatchingMistakesAsync (bash): commandPattern substring match, sourceFile-in-command match, case-insensitive, too-short pattern guard, validUntil filter - applyMistakeGuard: mode=off no-op, permissive augments additional context, permissive no-match no-op, strict denies with reason, permissive from passthrough emits fresh allow-with-warning Full suite: 825 -> 846 tests (+21), all passing. TypeScript clean. V3.0 PROGRESS — 9 of 12 scope items ✅ #1 foundation ✅ #2#3#6#7#8#9#10#11 Remaining: - #1 completion (HTTP transport + real-server integration tests) - #4 Anthropic Auto-Memory bridge (blocked: needs MEMORY.md fixture) - #5 SSE streaming for rich packet assembly - #12 Official MCP Registry submission (post-ship)
1 parent 16d531a commit 69ec18d

3 files changed

Lines changed: 696 additions & 0 deletions

File tree

src/intercept/dispatch.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type EditWriteHookPayload,
3232
} from "./handlers/edit-write.js";
3333
import { handleBash, type BashHookPayload } from "./handlers/bash.js";
34+
import { applyMistakeGuard } from "./handlers/mistake-guard.js";
3435
import {
3536
handleSessionStart,
3637
type SessionStartHookPayload,
@@ -181,12 +182,16 @@ async function dispatchPreToolUse(
181182
result = await runHandler(() =>
182183
handleEditOrWrite(handlerPayload as unknown as EditWriteHookPayload)
183184
);
185+
// v3.0 item #8 — wrap with mistake-guard (opt-in via
186+
// ENGRAM_MISTAKE_GUARD). Zero overhead when the env var is unset.
187+
result = await applyMistakeGuard(result, handlerPayload, "edit-write");
184188
break;
185189

186190
case "Bash":
187191
result = await runHandler(() =>
188192
handleBash(handlerPayload as unknown as BashHookPayload)
189193
);
194+
result = await applyMistakeGuard(result, handlerPayload, "bash");
190195
break;
191196

192197
default:
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
/**
2+
* Mistake-guard — v3.0 pre-mortem warnings.
3+
*
4+
* Opt-in via `ENGRAM_MISTAKE_GUARD`:
5+
* - unset / `0` → no-op (default — zero production overhead)
6+
* - `1` → permissive: tool proceeds, a warning is prepended
7+
* to any additionalContext the primary handler emits
8+
* - `2` → strict: tool is denied with the warning as reason
9+
*
10+
* Only fires for PreToolUse events on Edit / Write / Bash. Read events
11+
* already surface mistakes via the engram:mistakes context provider —
12+
* duplicating the warning at tool-call time would be noise.
13+
*
14+
* Matching algorithm:
15+
* - Edit / Write: mistake.sourceFile equals the tool's file_path
16+
* (normalized via context.toRelativePath)
17+
* - Bash: mistake.metadata.commandPattern is a substring of the command,
18+
* or mistake.sourceFile is a substring of the command (catches
19+
* 'rm src/auth.ts' style recurrences for auth.ts mistakes)
20+
*
21+
* Bi-temporal filter (item #7): mistakes with validUntil in the past are
22+
* suppressed — they refer to code that has since been refactored away
23+
* and would be noise.
24+
*
25+
* Safety: every path is wrapped in try/catch and returns null on error.
26+
* A broken guard MUST NEVER break the primary PreToolUse handler.
27+
*/
28+
import { relative } from "node:path";
29+
import { getStore } from "../../core.js";
30+
import { findProjectRoot } from "../context.js";
31+
import { buildDenyResponse } from "../formatter.js";
32+
import type { HandlerResult } from "../safety.js";
33+
34+
/**
35+
* Guard modes. Read from the environment at call time (not module load)
36+
* so tests can set/unset between cases without re-importing.
37+
*/
38+
export type GuardMode = "off" | "permissive" | "strict";
39+
40+
export function currentGuardMode(): GuardMode {
41+
const raw = process.env.ENGRAM_MISTAKE_GUARD;
42+
if (raw === "1") return "permissive";
43+
if (raw === "2") return "strict";
44+
return "off";
45+
}
46+
47+
/**
48+
* Normalize a tool payload into its target "resource" — the file path
49+
* for Edit/Write or the raw command for Bash. Unsupported kinds return null.
50+
*/
51+
function extractTargetResource(
52+
kind: "edit-write" | "bash",
53+
toolInput: Record<string, unknown> | undefined
54+
): { kind: "file"; filePath: string } | { kind: "command"; command: string } | null {
55+
if (!toolInput) return null;
56+
if (kind === "edit-write") {
57+
const fp = toolInput.file_path;
58+
if (typeof fp !== "string" || fp.length === 0) return null;
59+
return { kind: "file", filePath: fp };
60+
}
61+
if (kind === "bash") {
62+
const cmd = toolInput.command;
63+
if (typeof cmd !== "string" || cmd.length === 0) return null;
64+
return { kind: "command", command: cmd };
65+
}
66+
return null;
67+
}
68+
69+
export interface MistakeMatch {
70+
readonly label: string;
71+
readonly sourceFile: string;
72+
readonly ageMs: number;
73+
}
74+
75+
/**
76+
* Look up mistakes that apply to this tool call. Runs the bi-temporal
77+
* filter from item #7 so stale mistakes never warn.
78+
*/
79+
export async function findMatchingMistakesAsync(
80+
target: ReturnType<typeof extractTargetResource>,
81+
projectRoot: string
82+
): Promise<MistakeMatch[]> {
83+
if (!target) return [];
84+
const now = Date.now();
85+
86+
try {
87+
const store = await getStore(projectRoot);
88+
try {
89+
const matches: MistakeMatch[] = [];
90+
91+
if (target.kind === "file") {
92+
// Normalize the tool's file_path to relative POSIX for matching.
93+
// If it's already relative, relative() is a no-op. If absolute,
94+
// it becomes relative to projectRoot.
95+
let normalized = target.filePath;
96+
try {
97+
const rel = relative(projectRoot, target.filePath);
98+
if (rel && !rel.startsWith("..")) {
99+
normalized = rel.split(/[\\/]/).join("/");
100+
}
101+
} catch {
102+
// Use raw path — better to over-match than miss
103+
}
104+
105+
// Indexed lookup: getNodesByFile uses idx_nodes_source_file.
106+
// Try BOTH the normalized relative path AND the raw path, because
107+
// the miner could have stored either shape depending on how the
108+
// miner was invoked. Dedupe by node id.
109+
const candidates = [
110+
...store.getNodesByFile(normalized),
111+
...(normalized === target.filePath
112+
? []
113+
: store.getNodesByFile(target.filePath)),
114+
];
115+
const seenIds = new Set<string>();
116+
for (const m of candidates) {
117+
if (seenIds.has(m.id)) continue;
118+
seenIds.add(m.id);
119+
if (m.kind !== "mistake") continue;
120+
if (m.validUntil !== undefined && m.validUntil <= now) continue;
121+
matches.push({
122+
label: m.label,
123+
sourceFile: m.sourceFile,
124+
ageMs: now - m.lastVerified,
125+
});
126+
}
127+
} else {
128+
// Bash — no file axis to index on, fall back to a full-table scan
129+
// filtered to mistake-kind nodes. Bounded by project size; this
130+
// only runs when ENGRAM_MISTAKE_GUARD is explicitly enabled.
131+
const allMistakes = store
132+
.getAllNodes()
133+
.filter((n) => n.kind === "mistake")
134+
.filter((n) => n.validUntil === undefined || n.validUntil > now);
135+
136+
if (allMistakes.length === 0) return [];
137+
138+
// Bash — substring match on commandPattern (metadata) or sourceFile.
139+
const command = target.command.toLowerCase();
140+
for (const m of allMistakes) {
141+
const pattern = m.metadata?.commandPattern;
142+
const patternStr = typeof pattern === "string" ? pattern.toLowerCase() : "";
143+
const fileStr = m.sourceFile.toLowerCase();
144+
145+
if (patternStr && patternStr.length > 2 && command.includes(patternStr)) {
146+
matches.push({
147+
label: m.label,
148+
sourceFile: m.sourceFile,
149+
ageMs: now - m.lastVerified,
150+
});
151+
} else if (fileStr && fileStr.length > 3 && command.includes(fileStr)) {
152+
matches.push({
153+
label: m.label,
154+
sourceFile: m.sourceFile,
155+
ageMs: now - m.lastVerified,
156+
});
157+
}
158+
}
159+
}
160+
161+
return matches;
162+
} finally {
163+
store.close();
164+
}
165+
} catch {
166+
return [];
167+
}
168+
}
169+
170+
/** Format a human-readable age string for a mistake. */
171+
function formatAge(ms: number): string {
172+
if (ms < 0) return "unknown";
173+
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
174+
if (days === 0) return "today";
175+
if (days === 1) return "yesterday";
176+
if (days < 30) return `${days}d ago`;
177+
return `${Math.floor(days / 30)}mo ago`;
178+
}
179+
180+
/** Format a warning block from a set of matched mistakes. */
181+
export function formatWarning(matches: readonly MistakeMatch[]): string {
182+
if (matches.length === 0) return "";
183+
const lines = matches
184+
.slice(0, 5)
185+
.map((m) => ` ⚠ ${m.label} (flagged ${formatAge(m.ageMs)}, file: ${m.sourceFile})`);
186+
const more = matches.length > 5 ? `\n … and ${matches.length - 5} more` : "";
187+
return [
188+
"⛔ engramx pre-mortem — this target has recurred as a mistake before:",
189+
...lines,
190+
more,
191+
]
192+
.filter((s) => s.length > 0)
193+
.join("\n");
194+
}
195+
196+
/**
197+
* Wrap a primary handler's result with mistake-guard output. Pure
198+
* function: takes the raw handler result + the payload + the project
199+
* root, and returns either the raw result (no matches / guard off),
200+
* an augmented allow-with-context result (permissive mode + matches),
201+
* or a deny response (strict mode + matches).
202+
*/
203+
export async function applyMistakeGuard(
204+
rawResult: HandlerResult,
205+
payload: { tool_name?: unknown; tool_input?: unknown; cwd?: unknown },
206+
kind: "edit-write" | "bash"
207+
): Promise<HandlerResult> {
208+
const mode = currentGuardMode();
209+
if (mode === "off") return rawResult;
210+
211+
try {
212+
const cwd = typeof payload.cwd === "string" ? payload.cwd : "";
213+
const projectRoot = findProjectRoot(cwd);
214+
if (!projectRoot) return rawResult;
215+
216+
const toolInput =
217+
payload.tool_input && typeof payload.tool_input === "object"
218+
? (payload.tool_input as Record<string, unknown>)
219+
: undefined;
220+
221+
const target = extractTargetResource(kind, toolInput);
222+
const matches = await findMatchingMistakesAsync(target, projectRoot);
223+
if (matches.length === 0) return rawResult;
224+
225+
const warning = formatWarning(matches);
226+
227+
if (mode === "strict") {
228+
return buildDenyResponse(warning);
229+
}
230+
231+
// Permissive — augment the existing allow response's additionalContext.
232+
if (rawResult && typeof rawResult === "object") {
233+
const res = rawResult as Record<string, unknown>;
234+
const hso =
235+
res.hookSpecificOutput && typeof res.hookSpecificOutput === "object"
236+
? (res.hookSpecificOutput as Record<string, unknown>)
237+
: undefined;
238+
const existingContext =
239+
typeof hso?.additionalContext === "string" ? hso.additionalContext : "";
240+
const merged = existingContext
241+
? `${warning}\n\n${existingContext}`
242+
: warning;
243+
return {
244+
...res,
245+
hookSpecificOutput: {
246+
...(hso ?? {}),
247+
hookEventName: "PreToolUse",
248+
permissionDecision:
249+
typeof hso?.permissionDecision === "string"
250+
? hso.permissionDecision
251+
: "allow",
252+
additionalContext: merged,
253+
},
254+
};
255+
}
256+
257+
// rawResult was PASSTHROUGH (null) — emit a fresh allow-with-warning.
258+
return {
259+
hookSpecificOutput: {
260+
hookEventName: "PreToolUse",
261+
permissionDecision: "allow",
262+
additionalContext: warning,
263+
},
264+
};
265+
} catch {
266+
// Any error → return raw result unchanged. Guard must never break
267+
// the primary handler.
268+
return rawResult;
269+
}
270+
}

0 commit comments

Comments
 (0)