PreToolUse vs PostToolUse: which hook actually stops an agent copying bad code
Same ESLint config, two Claude Code hooks. Post-write lint let 4/35 seeded copies through; a PreToolUse gate that refused the edit held at 0/35.
Run it in PreToolUse if the linter needs to stop bad code, not just report it. A PreToolUse hook sees the proposed edit and can refuse it with exit 2 before the file changes. PostToolUse runs after the write has landed. In 140 trials, the same ESLint config let 4/35 seeded copies through after the write and 0/35 before it.
At 35 trials per arm those two results are not statistically separable (Fisher p=0.114). The pre-write arm was the only one at zero on all seven fixtures.
Same linter. Run at a different point.
Why every recipe this month uses PostToolUse
Most new lint-hook guides put the check on PostToolUse. Anthropic's hooks reference does that in its own lint example (Edit|Write). So do writeups from Jared Hebb and developertoolkit.
That default makes sense. After the write, the file is on disk. eslint path/to/file runs as usual. Formatters belong there. The agent sees findings and fixes them.
The write already happened, though. The hooks reference is clear: exit 2 from PostToolUse "shows stderr to Claude; the tool already ran." That is feedback. It is not refusal. Those recipes do what they say. They do not stop the edit from landing.
The harness arms are hook events
In the precedent-poisoning harness the four arms are environments around the same repository:
| arm | what it is |
|---|---|
| none | unmodified repo |
| docs | a CLAUDE.md with every seeded rule, nothing enforcing it |
| eslint | a PreToolUse hook that lints the proposed content and exits 2 to refuse the write |
| lint-after | the same ESLint config as a PostToolUse hook: write lands, findings come back, the agent has to fix them |
eslint and lint-after differ only in when the check runs. That maps to the four layers: docs is advisory, PreToolUse is the gate, PostToolUse is the record, CI or git hooks are the guarantee.
The result: 4/35 vs 0/35
| arm | hook / layer | copied the seeded shortcut | 95% CI | median turns |
|---|---|---|---|---|
| none | no hook | 30 / 35 | 71–94% | 4 |
| docs | CLAUDE.md (advisory) | 13 / 35 | 23–54% | 4 |
| eslint | PreToolUse, exit 2 refuses (gate) | 0 / 35 | 0–10% | 6 |
| lint-after | PostToolUse, findings after write (record) | 4 / 35 | 5–26% | 7 |
140 trials. claude-haiku-4-5-20251001. Claude Code 2.1.265. Seven fixtures. Five reps per cell. DOI 10.5281/zenodo.22732965. Methods and per-fixture tables are in the precedent-poisoning writeup.
eslint vs lint-after, Fisher two-sided p=0.114. Not separable at this size. eslint is the only arm at zero on all seven fixtures. Median turns were 6 for eslint and 7 for lint-after. That is turns, not dollars.
With lint-after the bad edit exists, and the agent has to choose to fix it. With the gate it never exists.
A minimal PreToolUse ESLint gate
The harness wires PreToolUse on Edit|Write|MultiEdit with arms/eslint-hook.mjs. Do not lint the file on disk. Disk still holds the old content. Build the proposed result in memory, run ESLint.lintText on that text, and exit 2 if new error-severity findings appear.
Settings sketch (matcher matches the harness; confirm against the hooks reference for your CLI version):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/lint-proposed.mjs"]
}
]
}
]
}
}Script sketch. Details that matter in the real hook: CRLF-normalize before applying Edit; scope to the file types you care about; compare error counts before vs after so seeded findings do not trip the gate; fail closed on lint exceptions. A hook that throws and exits non-2 can fail open.
#!/usr/bin/env node
// Sketch aligned with precedent-poisoning arms/eslint-hook.mjs.
// Lint the proposed content. Exit 2 to refuse.
import { readFileSync, existsSync } from "node:fs";
import { ESLint } from "eslint";
const { tool_name, tool_input } = JSON.parse(readFileSync(0, "utf8"));
const file = tool_input.file_path ?? "";
if (!/\.(ts|tsx|js|mjs)$/.test(file)) process.exit(0);
function applyEdit(current, oldStr, newStr, replaceAll) {
const src = current.replace(/\r\n/g, "\n");
const old = oldStr.replace(/\r\n/g, "\n");
const neu = newStr.replace(/\r\n/g, "\n");
if (replaceAll) return src.split(old).join(neu);
const i = src.indexOf(old);
if (i === -1) return null;
return src.slice(0, i) + neu + src.slice(i + old.length);
}
let proposed;
if (tool_name === "Write") {
proposed = tool_input.content;
} else if (tool_name === "Edit") {
const current = existsSync(file) ? readFileSync(file, "utf8") : "";
proposed = applyEdit(
current,
tool_input.old_string,
tool_input.new_string,
Boolean(tool_input.replace_all),
);
if (proposed === null) process.exit(0);
} else if (tool_name === "MultiEdit") {
let current = existsSync(file) ? readFileSync(file, "utf8") : "";
for (const edit of tool_input.edits ?? []) {
const next = applyEdit(
current,
edit.old_string,
edit.new_string,
Boolean(edit.replace_all),
);
if (next === null) process.exit(0);
current = next;
}
proposed = current;
} else {
process.exit(0);
}
try {
const eslint = new ESLint();
const [result] = await eslint.lintText(proposed, { filePath: file });
if (result.errorCount > 0) {
const formatter = await eslint.loadFormatter("stylish");
process.stderr.write(await formatter.format([result]));
process.exit(2);
}
} catch (err) {
process.stderr.write(`lint gate failed closed: ${err.message}\n`);
process.exit(2);
}
process.exit(0);The PostToolUse twin lints the file from disk after the write. Same config. Exit 2 still only sends stderr to the model. The tool already ran. In the harness that twin also caps how many times it will nag on one file per trial. The gate does not need a cap, because the write never landed.
How a PreToolUse gate fails open
From the hooks reference. These are docs facts, not study results.
- Exit 1 does not block. Only exit 2, or JSON
permissionDecision: "deny", refuses. - A command hook that times out on PreToolUse does not block. The call goes through.
- A wrong script path is a non-blocking error. The gate is off and nothing says so.
- The matcher covers only the tools you name. A shell redirect (
echo … > file.ts) skipsEdit|Write|MultiEdit. Gates, not guidance already says the gate has to cover that.
Break it on purpose
In the poisoning project, five PreToolUse gates reported success while testing nothing. Each one was caught by trying to make it fail. Feed your gate a known-bad Write and a known-bad Edit. Watch it refuse. Then trust it.
When PostToolUse is the right hook
- Formatting (
prettier,eslint --fixfor style). You want the file changed, not refused. - Type-check or test output the agent should read and act on.
- Audit logs and metrics. That is the record layer.
Anything that must never land goes before the write, with CI or git hooks behind it. Running both is fine: the lint config pre-write to refuse, a formatter post-write.
Where this sits in the four layers
Gates, not guidance: advisory, gate, record, guarantee. PreToolUse is the gate. PostToolUse is the record. CI is the guarantee. Use more than one. Do not mistake one for another.
Same shape as validation is a gate at the HTTP edge: reject bad input before the handler runs. Here the input is the proposed edit.
Caveats
One model (claude-haiku-4-5-20251001), one CLI version (2.1.265), one TypeScript repo, seven fixtures, five reps per cell. Multi-model runs are planned, not done. 0/35 is a floor on those seven shortcuts. It is not a ranking of gates and not a guarantee. eslint vs lint-after at p=0.114 is not distinguishable at this size. "Introduced a lint finding" is a proxy for "copied the shortcut." Hook behaviour above is from the current hooks reference. The study ran on 2.1.265.
FAQ
Should I run my linter in a PreToolUse or PostToolUse hook?
Use PreToolUse if it has to stop an edit. That hook runs before the write and can refuse with exit 2. PostToolUse runs after the file changes and can only report. In 140 trials the same ESLint config let 4/35 seeded copies through post-write and 0/35 pre-write.
Can a PostToolUse hook block a write in Claude Code?
No. By the time it runs, the tool has already run. Exit 2 sends stderr back to Claude. Useful for formatting and feedback. It does not undo the write.
Why doesn't my PreToolUse lint hook block anything?
Check the exit code first: 1 does not block, 2 does. A timed-out command hook lets the call through. A wrong script path is a non-blocking error. An Edit|Write matcher misses shell redirects. Once fixed, send it a known-bad edit and confirm it refuses.
Links
- Precedent poisoning: harness, arm definitions, per-fixture results
- Gates, not guidance: four layers
- Validation is a gate: same idea at the HTTP edge
- Claude Code hooks reference
- DOI: 10.5281/zenodo.22732965