PreToolUse hook
A PreToolUse hook is a program that runs before a tool call and can refuse it, stopping an edit the model would otherwise make.
Last updated: 28 Sep, 2026 · Claude Code
The CLAUDE.md written in the CLAUDE.md lesson 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.
Triggering the hook
A driver runs the hook exactly as Claude Code would: it sends one edit that should be blocked and one that should not.
Feeding the hook a tool call
The driver builds one function that gives hook.py a tool call on stdin and captures what it prints.
def ask(call):
sys.stdin = io.StringIO(json.dumps(call)) # the call, as JSON on stdin
said = io.StringIO()
with contextlib.redirect_stdout(said), contextlib.suppress(SystemExit):
runpy.run_path("hook.py", run_name="__main__") # run hook.py in place
return said.getvalue().strip() or "(nothing, so the call carries on)"Sending one blocked edit and one allowed edit
Then it sends the two cases: an edit to the protected file, and an edit to an ordinary one.
# a protected file: the hook should deny it
print("editing store.py:")
print(ask({"tool_name": "Edit", "tool_input": {"file_path": "/repo/store.py"}}))
# an ordinary file: the hook stays quiet
print("editing shorten.py:")
print(ask({"tool_name": "Edit", "tool_input": {"file_path": "/repo/shorten.py"}}))The hook deciding both edits
"""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"}}))editing store.py:
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "/repo/store.py is protected. Ask a human first."}}
editing shorten.py:
(nothing, so the call carries on)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.
Pick one to watch it run, step by step.
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.
Related
- Add
.envto the protected list in the panel and run it again. - Change the reason to something unhelpful, then predict what Claude would try next.
This is what real progress feels like.