Writing a hook that blocks a bad edit
The CLAUDE.md in lesson 10 asked Claude not to edit store.py. This lesson stops it, which is a different thing.
The hook is an ordinary program. It reads the tool call from standard input, decides, and prints. Nothing about it is specific to Claude Code except the shape of the JSON.
"""A PreToolUse hook: refuse edits to a file the project protects.
Claude Code sends the tool call as JSON on stdin. Printing a
decision blocks it. Printing nothing lets the normal permission
flow carry on.
"""
import json
import sys
PROTECTED = ("store.py",)
call = json.loads(sys.stdin.read())
path = call.get("tool_input", {}).get("file_path", "")
if path.endswith(PROTECTED):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"{path} is protected. Ask a human first.",
}
}))
sys.exit(0)
sys.exit(0)Twenty four lines, and most of them are the decision object. If the path ends in a protected name it prints a denial with a reason. Otherwise it prints nothing and exits, which leaves the call to the normal permission flow.
Run it
The panel beside this drives the hook exactly as Claude Code would: it sends one edit that should be blocked and one that should not. Press Run.
"""Run the hook the way Claude Code would: JSON in, decision out.
Claude Code starts hook.py as a process and writes the tool call
to its stdin. Here the same file runs in place with the same
input, so it behaves the same.
"""
import contextlib
import io
import json
import runpy
import sys
def ask(call):
sys.stdin = io.StringIO(json.dumps(call))
said = io.StringIO()
with contextlib.redirect_stdout(said), contextlib.suppress(SystemExit):
runpy.run_path("hook.py", run_name="__main__")
return said.getvalue().strip() or "(nothing, so the call carries on)"
print("editing store.py:")
print(ask({"tool_name": "Edit", "tool_input": {"file_path": "/repo/store.py"}}))
print()
print("editing shorten.py:")
print(ask({"tool_name": "Edit", "tool_input": {"file_path": "/repo/shorten.py"}}))The first call comes back with a denial and a reason Claude will be shown. The second produces nothing at all, and that silence is what lets ordinary work continue.
The reason matters more than it looks. It is the sentence Claude reads after being blocked, so it should say what to do instead. Denied makes it try again a different way; store.py is protected, ask a human first makes it stop and tell you.
Wiring it up
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/hook.py" }
]
}
]
}
}
Matched on both editing tools, because a rule that only covers Edit leaves Write free to replace the file wholesale.
- Add
.envto the protected list in the panel and run it again. - Change the reason to something unhelpful, then imagine what Claude would try next.
This is what real progress feels like.