Web and Code

Your First Claude Code Hook: Block a Command Before It Runs

A Claude Code hook that checks every shell command before it runs and blocks the risky ones, such as deleting files, with a message telling Claude why.

In the last post I mentioned that a git hook can be skipped with the --no-verify flag, which an AI assistant could add too. This post walks through a Claude Code hook that checks each shell command before Claude runs it, and blocks the ones you don’t want it running, such as skipping git hooks, force-pushing or deleting files.

The problem

Claude Code runs shell commands as part of its work, and you can write rules such as “never use –no-verify” in a CLAUDE.md file or a skill, but those are instructions Claude reads, so it might still run the command. A hook is a script that Claude Code runs before each shell command, so it catches the command even if Claude has missed or ignored those instructions.

The hook gets the command Claude is about to run, and when it finishes it returns a number called an exit code. If it returns 0 the command goes ahead, and if it returns 2 the command is blocked and the hook’s message goes to Claude as the reason, which Claude will usually pass on to you.

The fix

These steps are for a Mac, and the files go in a .claude folder inside your project folder. Finder hides folders whose names start with a full stop, though you can show them by pressing Cmd+Shift+. (Command, Shift and the full stop key) in a Finder window. You won’t need to, as the steps below use the terminal.

1. Check you have jq. The hook uses jq to read the command out of what Claude Code sends it. Open Terminal and run:

jq --version

If it prints a version number, you have it. If you get command not found: jq, install it with Homebrew:

brew install jq

If you get command not found: brew, install Homebrew first from its site.

2. Go to your project folder. Type cd followed by the path to your project folder and press Return, for example cd ~/Projects/my-site, and pwd prints the folder you’re in if you want to check. The checks at the end use git, so if your project doesn’t use it yet, run git init first.

3. Create the hook script. Paste this into Terminal and press Return. It makes a .claude/hooks folder in your project and writes the script into it:

mkdir -p .claude/hooks
cat > .claude/hooks/block-risky-commands.sh <<'EOF'
#!/bin/sh
if ! command -v jq >/dev/null 2>&1; then
  echo "Blocked: jq isn't installed, so this hook can't check commands. Install it with brew install jq." >&2
  exit 2
fi

command=$(jq -r '.tool_input.command')

if echo "$command" | grep -qE -- '--no-verify|git +commit( .*)? -n( |$)'; then
  echo "Blocked: --no-verify (or -n) skips the git hooks. Commit without it, and fix what the hook reports." >&2
  exit 2
fi

if echo "$command" | grep -qE -- 'git +push( .*)? (--force|-f( |$)|\+)'; then
  echo "Blocked: force-pushing can overwrite work on the remote. If it's needed, ask the user to run it." >&2
  exit 2
fi

if echo "$command" | grep -qE -- '(^|[;&|( /])(rm|rmdir|unlink)( |$)|( |^)-delete( |$)'; then
  echo "Blocked: deleting files needs the user's OK. Tell the user which files you'd like to delete and why, and let them do it." >&2
  exit 2
fi

exit 0
EOF

Then make it executable so it can run:

chmod +x .claude/hooks/block-risky-commands.sh

The messages after echo are what Claude sees when a command is blocked, so you can change them to say what you’d like Claude to do instead. Keep the >&2 at the end of each one, as that sends the message to where Claude Code reads it.

4. Add the hook to the settings. Claude Code reads project settings from .claude/settings.json, so check whether you have one:

ls .claude/settings.json

If it says No such file or directory, paste this to create it:

cat > .claude/settings.json <<'EOF'
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-commands.sh"
          }
        ]
      }
    ]
  }
}
EOF

If the file is already there, the command above would replace it, so open the file in your code editor instead, where the .claude folder may show in the file list even though Finder hides it. Copy the lines from "hooks": { down to its closing } in the block above, and paste them inside the file’s outer braces, with a comma between them and what’s already there. If the file already has a "hooks" section, add the part from { "matcher": "Bash" to its closing } into its "PreToolUse" list instead.

To check the file, run:

jq . .claude/settings.json

If the file is valid it’s printed back, and if something’s wrong it prints an error with the line number, such as for a missing comma, so fix that line and run it again.

5. Start Claude Code in the project. In Terminal, still in the project folder, type claude and press Return, and if Claude Code was already running in the project, quit it and start it again so it reads the new settings. In a folder you haven’t used Claude Code in before, it first asks “Is this a project you created or one you trust?”, with “No, exit” selected, so press the down arrow to choose “Yes, I trust this folder” and press Return. Only say yes to a folder you trust, as a project’s hooks run with your own user permissions, so with a project you’ve cloned from someone else, look in its .claude folder first.

Why it works

"matcher": "Bash" means the hook only runs before shell commands, not before Claude reads or edits a file, and $CLAUDE_PROJECT_DIR is your project folder, so Claude Code can find the script. Claude Code sends the hook the details of the command as JSON, and jq -r '.tool_input.command' pulls out the command itself. Each grep -qE line checks the command against a pattern, where | means “or”, so the first one matches --no-verify, or git commit with -n on its own, which is the short way of writing --no-verify. The last one matches rm, rmdir or unlink as a word at the start of the command or after a space, ;, &, |, ( or /, which covers git rm and /bin/rm too, and find with -delete. When I first tested this I asked Claude to remove a file, and it used git rm -f, which my first pattern didn’t match as it only looked for rm -rf, so the pattern now checks for rm as a word wherever it appears.

On a match the script prints its message and returns 2, which blocks the command and gives Claude the message as the reason. Any other command returns 0, so it goes on to the usual permission prompt as before.

Check it worked

You can check the script on its own first, by sending it what Claude Code would:

echo '{"tool_input":{"command":"git commit --no-verify -m test"}}' | .claude/hooks/block-risky-commands.sh
echo $?

It prints the --no-verify message and then 2, as echo $? prints the exit code of the last command, which here is the script. Then in Claude Code in the project, ask it:

Run git commit --no-verify --allow-empty -m "test the hook" and if it is blocked, stop and tell me why

--allow-empty makes it a commit with no file changes in it, so if it does go through it only adds an empty commit to the history. Asking Claude to stop keeps the test to --no-verify, as otherwise Claude may follow the hook’s message and make the commit without it. Claude should say the command was blocked by a hook and give the reason from your message, and git log --oneline -1 shows your last commit, which shouldn’t be one called “test the hook”, or in a project with no commits yet it says so. If Claude made the commit with --no-verify, the hook might not be loaded, so check the settings file with jq again and restart Claude Code.

To check deleting, make a test file and ask Claude to remove it:

echo test > delete-me.txt
Can you remove delete-me.txt please

Claude should say it was blocked and suggest you delete it yourself, and ls delete-me.txt shows the file is still there. Then delete it with rm delete-me.txt, as the hook only checks Claude’s commands, not yours.

Then ask Claude to run git status, which only lists what’s changed in your project. It isn’t one of the commands the hook looks for, so it runs as it did before, with the usual permission prompt if you get one, which shows the hook lets everything else through.

Things to know

The patterns used in the code above catch some of the usual ways these commands are written. This means that it may catch unintended commands or miss other ways of writing, so you may need to refine and tweak as you use Claude Code. For example a commit message that has “ -n” in it, such as “fix the -n option”, is blocked, as is git rm --cached, which leaves the file where it is and only stops git tracking it. If that happens, Claude gets the message and can ask you to run the command yourself, or you can change the pattern in the script.

The hook checks the commands Claude runs, not every way a file can be removed. When it blocks a delete, Claude stops and asks you to do it, but there are other ways to remove a file, such as from a script, so if Claude offers to get around the hook, say no.

If the script fails in some other way, with an error or by taking too long, the command isn’t blocked and goes ahead as normal. The script checks for jq first, and if jq is missing it blocks every shell command, so Claude can’t run shell commands in the project until you’ve installed it. After changing the script, run the check above again.

The hook checks shell commands, not file edits, so it doesn’t stop Claude changing the hook itself. After a block, Claude may offer to edit the hook so the command is allowed, so if Claude asks to change anything in .claude/, say no unless you meant it to.

The hook lives in this project’s .claude folder, so it only runs in this project, and another project needs its own copy. If you commit the .claude folder, anyone who uses Claude Code on the project gets the hook too, and they’ll need jq installed as well. My own projects use hooks like this one, for example one that stops Claude running npm audit fix --force. Without --force, npm stays within the versions the project allows, but with it a package can move to a new major version, and in my build policy a major update is decided and reviewed on its own rather than as part of a security fix.

I hope this helps if you give it a go.