Two Claude Code PreToolUse hooks that enforce read-before-write: an Edit, Write, or bash-mediated write to a file that hasn't been Read in the current session is blocked until you read it.
It's a guardrail for the "Iron Law of Current State" — don't mutate a file based on what you remember it contains; read the live bytes first. Editing from a stale mental model (or a post-compaction summary) is how silent corruption and clobbered changes happen.
Two long-standing Claude Code behaviors motivate it. Both were filed upstream and closed as not planned, so the gaps are still present in current Claude Code:
- anthropics/claude-code#29709 — "Claude Code circumvents PreToolUse:Edit hook via Bash tool." A
PreToolUse(Edit|Write)hook does nothing aboutsed -i,tee, shell redirects, orpython -c "open(f,'w')". The model can route around an Edit guard entirely by writing through Bash. - anthropics/claude-code#16546 — "Model attempts file edits without reading file first." The built-in read-tracking also doesn't survive context compaction: once the conversation is summarized, the in-context record of "I read this file" is gone, so the model re-edits from a lossy summary.
fresh-read-guard addresses both by checking the on-disk session transcript (JSONL) for a prior Read of the exact file — a stateless check that survives compaction — and by covering the Bash write paths the Edit hook can't see.
| Hook | Event | Covers |
|---|---|---|
fresh-read-guard.sh |
PreToolUse(Edit|Write) |
Tool-mediated writes |
fresh-read-guard-bash.py |
PreToolUse(Bash) |
sed -i, awk -i inplace, tee, >/>> redirects, python open(...,'w'|'a'|'x') (incl. heredocs) |
Both:
- Grep the transcript, not in-context memory — so a
Readfrom 200 messages and three compactions ago still counts. - Check the issuing subagent's own transcript too — a subagent that read the file is honored; sibling subagents' reads are not.
- Allow new-file creation — writing a path that doesn't exist yet is never blocked (there's nothing to read).
- Safe-fail — any parse/IO error falls through to
exit 0. A broken guard must never turn every edit into a block. - Belt-and-suspenders deny — emit the
permissionDecisionJSON on stdout and a message on stderr andexit 2, because the deny channels have had version-specific bugs; emitting all three means at least one lands.
These are executable hooks — Claude Code runs them on every matching tool call with your shell's privileges. Read both scripts before installing, as you would any script you
curlinto your config. See SECURITY.md for the threat model (it fails open by design and is not a sandbox).
Copy the two hooks somewhere (e.g. ~/.claude/hooks/) and make them executable:
mkdir -p ~/.claude/hooks
curl -fsSL https://raw.githubusercontent.com/musharna/fresh-read-guard/main/fresh-read-guard.sh -o ~/.claude/hooks/fresh-read-guard.sh
curl -fsSL https://raw.githubusercontent.com/musharna/fresh-read-guard/main/fresh-read-guard-bash.py -o ~/.claude/hooks/fresh-read-guard-bash.py
chmod +x ~/.claude/hooks/fresh-read-guard.sh ~/.claude/hooks/fresh-read-guard-bash.pyThen wire them into ~/.claude/settings.json (see examples/settings.json and examples/README.md). Note: the example JSON can't carry comments — when you genuinely need to write without a prior read, use the override mechanism (IRON_LAW_OVERRIDE=1 env, or the inline # IRON_LAW_OK token for the Bash hook):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/fresh-read-guard.sh"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/fresh-read-guard-bash.py"
}
]
}
]
}
}The .sh hook requires jq. The .py hook is pure stdlib (Python 3.8+).
When you genuinely need to write without a prior read:
- Either set
IRON_LAW_OVERRIDE=1in the environment, or - (Bash hook only) put the literal token
# IRON_LAW_OKanywhere in the command.
Each hook reads the transcript_path from the hook payload and looks for the needle:
"name":"Read","input":{"file_path":"<resolved-abs-path>"
If present, the write is allowed. The Bash hook first parses the command to extract write targets (handling sed -i/-i.bak, awk -i inplace, multi-file tee, every redirect form, and open(path, 'w'|'a'|'x'...)), resolves each against the command's cwd, skips ephemeral paths (/tmp, /proc, /dev, …), and only blocks targets that already exist on disk without a prior read.
MIT — see LICENSE.