TIPS 05 · GUARDIA

The guard

Two hooks for Claude Code: one asks you to confirm before a command deletes files and blocks the irreparable ones, the other runs the tests after every edit.

What it is and why it matters

These are the two hooks I use in my projects. A hook is a command that Claude Code runs on its own at a precise point in the session. The difference from an instruction written in the chat or in CLAUDE.md comes down to this: Claude can lose track of an instruction after two hours of work. Not a hook. Claude Code runs it every time, whether Claude remembers it or not.

The guard is there because some commands can't be undone: they delete files without going through the trash, rewrite the git history, empty a database table. A wrong path or a misread request is all it takes.

My guard does two things. If a command deletes files, it asks you to confirm, even in auto mode, the one where Claude works without asking you anything. If it's irreparable, like wiping the whole disk or forcing a push, it simply blocks it.

The tests after every edit are for the other classic problem: Claude changes a file, tells you “done”, and meanwhile it has broken something somewhere else. With the hook, the test results reach it right away, while it still has in mind what it touched.

1. The guard: before every command

The first hook uses the PreToolUse event, which fires before Claude uses a tool. With the “Bash” filter it only looks at terminal commands. Create the file .claude/hooks/guard.sh in the project folder and paste this into it:

The file
#!/bin/bash
# guard.sh: asks before deleting, blocks the irreparable stuff
command -v jq >/dev/null || { echo "Blocked: jq is missing (brew install jq)" >&2; exit 2; }
CMD=$(jq -er '.tool_input.command') || { echo "Blocked: unreadable input" >&2; exit 2; }
NO='rm[[:space:]]+-[a-z]*r[a-z]*[[:space:]]+(/|~/?|\$HOME/?)\*?([[:space:]]|$)|git[[:space:]]+push([[:space:]].*)?[[:space:]](-f|--force)|git[[:space:]]+reset[[:space:]]+--hard|drop[[:space:]]+(table|database)'
if grep -Eiq "$NO" <<<"$CMD"; then echo "Blocked: irreparable command ($CMD)" >&2; exit 2; fi
ASK='^[[:space:]]*((sudo|xargs)[[:space:]]+)*(rm|rmdir|unlink)([[:space:]]|$)|^[[:space:]]*find[[:space:]].*[[:space:]]-delete'
if tr ';&|()`' '\n\n\n\n\n\n' <<<"$CMD" | grep -Eq "$ASK"; then
  jq -n --arg r "The guard: this command deletes files. Confirm?" \
    '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $r}}'
fi
exit 0

How it works: before every command, Claude Code passes the command to the script as JSON, in the tool_input.command field. The script can answer in three ways:

  • It exits with code 2: the command doesn't run and Claude reads the reason. That happens with the commands in the NO list.
  • It prints a small JSON with permissionDecision “ask” and exits with 0: Claude Code shows you the permission prompt, with the reason the script wrote, and the command runs only if you say yes. That happens with the commands in the ASK list.
  • It exits with 0 without printing anything: the command goes on through the normal permissions.

The two lines after the comment block everything if jq, the program that reads the JSON, is missing, or if the input can't be read. On a Mac you install jq with brew install jq.

What it asks about, what it blocks, what it lets through

  • ASK, asks for confirmation: rm, rmdir, unlink and find … -delete, even with sudo or xargs in front. Before searching, the script splits the command on ; & | ( ) and backticks, so it also catches the rm at the end of cd folder && rm -r build.
  • NO, simply blocks: rm -r on /, ~ or $HOME, meaning the whole disk or the whole user folder; git push with -f or --force; git reset --hard; DROP TABLE and DROP DATABASE, upper or lower case.
  • Lets through: git rm --cached, docker rm and everything else, because there the command is git or docker, not rm.

Extend the two lists yourself. For example git clean, which deletes the new files git doesn't know about yet: it isn't in my version. If you use it, put it in ASK or NO, together with the commands that do damage in your project.

What it doesn't see

The guard only looks at Bash commands. If Claude empties a file by rewriting it with the Write tool, it doesn't stop it. For files that must never change, like .env, you need a deny rule in the permissions, for example Edit(./.env), which applies to every tool that edits files. Or a second hook on Write.

2. The tests: after every edit

The second hook uses PostToolUse, which fires after a tool has finished its job. With the “Edit|Write” filter it runs every time Claude edits or creates a file. Create .claude/hooks/test-after-edit.sh:

The file
#!/bin/bash
cd "$CLAUDE_PROJECT_DIR" || exit 0
OUT=$(npm test 2>&1) && exit 0
echo "Tests failed after the edit:" >&2
echo "$OUT" | tail -n 30 >&2
exit 2

Put your own test command in place of npm test. If the tests pass, you see nothing. If they fail, the script sends Claude the last 30 lines of the error and exits with code 2.

Here code 2 doesn't undo the edit: it's already done. It makes Claude read the error, so it fixes it right away. That's the difference between the two hooks: the guard can say no beforehand, this one can only warn afterwards. And Claude waits for the tests to finish after every edit: if yours are slow, put only the fast ones in the script.

3. Turn them on

Make both scripts executable. From the terminal, in the project folder:

The line
chmod +x .claude/hooks/*.sh

Then in .claude/settings.json, again in the project folder, you register the two hooks:

The file
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.sh" }] }
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/test-after-edit.sh" }] }
    ]
  }
}

If the file already exists, don't replace it: add the “hooks” part next to what's already in it, and check the commas. $CLAUDE_PROJECT_DIR is the folder you started the session from, so Claude Code finds the scripts even when Claude is working in a subfolder.

If you'd rather not do it all by hand, ask Claude:

The prompt
Create the two hooks from this guide in this project: a PreToolUse hook on Bash that asks me for confirmation before deleting files and blocks irreparable commands with exit code 2, and a PostToolUse hook on Edit and Write that runs [your test command] after every edit. Register them in .claude/settings.json without removing anything that's already there. Then show me the two scripts and explain what each line does.

4. The test

First I test the guard on its own, without Claude. From the terminal, in the project folder, I pass it a fake command the way Claude Code would:

The line
echo '{"tool_input":{"command":"rm -rf test"}}' | .claude/hooks/guard.sh; echo $?

You should see a small JSON with “ask”, and then 0. With git reset --hard instead of rm -rf test, you should see “Blocked” and then 2. With ls, only 0. The script only reads the text: nothing gets executed here. This test matters because if the path in settings.json is wrong or chmod +x is missing, Claude Code only shows a “hook error” notice and the command goes through with no questions.

Then the real test. Open a new session: Claude Code usually notices changes to settings.json on its own, but this way you're sure. Type /hooks and check that both are there, one under PreToolUse and one under PostToolUse. Then ask Claude:

The prompt
Create an empty folder called guard-test and then delete it.

The permission prompt should appear, with the reason “The guard: this command deletes files” and the [settings] label, which tells you where the question comes from. The first time, answer no and check that the folder is still there.

For the test hook:

The prompt
Change a line in a project file so that a test fails, then tell me what you received after the edit. At the end, put everything back the way it was and check that the tests pass.

Common mistakes

  • Saying yes out of habit: the guard's question is there for a reason. Read the command before you answer.
  • Trusting the guard without having seen it work: with a wrong path, the command goes through with just a notice. Always run the manual test with echo.
  • Starting with tests that are already broken: every edit gets an error that has nothing to do with it. Before turning on the hook, get the tests passing, or run only the ones that pass.

Checklist

  • guard.sh in .claude/hooks, with your ASK and NO lists
  • test-after-edit.sh with your test command
  • jq installed
  • chmod +x on both scripts
  • Hooks registered in .claude/settings.json
  • Manual test with echo: ask for rm, 2 for git reset --hard
  • Test in Claude Code: permission prompt for the folder, failed test reported

Frequently asked questions

What if nobody answers at night?

The command stays on hold at the prompt until you're back, and nothing gets deleted. That holds in auto mode too. If instead you run Claude Code with nobody in front of it, for example with claude -p, the prompt can't appear: Claude Code denies the command and Claude reads the reason.

Does the guard protect me from everything?

No. It looks at the text of Bash commands, and the same damage can be done in other ways, for example with a small Python script or by rewriting a file with Write. It lowers the risk. The real safety net is still git: a commit before long jobs and a copy of the project online.

← All guides