Part of the AI coding agent security checklist

Claude Code hooks for security: block risky tool calls

A PreToolUse hook sees every tool call before it runs and can refuse it. Here is how to block rm -rf, force pushes and reads of .env, ~/.ssh and ~/.aws, and where the hook stops protecting you.

$ uvx ranwhat check
Install

Checked against Claude Code's hooks and permissions documentation under Sources, and ranwhat's source. Published .

01 / Layers

Three layers, and what each one is for

Claude Code can stop a risky call in three different places. They fail in different ways, so most setups use more than one.

Deny rules permissions.deny in settings, such as Read(./.env) or Bash(git push *). Rules are checked deny first, then ask, then allow, and the first match wins, so no allow rule can carve an exception out of a deny. Declarative, nothing to maintain.
Hooks Your own code, run before the tool does. A PreToolUse hook receives the full tool input as JSON and can deny the call, ask you, or say nothing. Use one when a pattern is too specific for a rule, or when you want a custom message back to the agent.
Sandbox OS-level limits on what a shell command and its child processes can touch. It does not depend on the command text, which is the property the other two lack. Claude Code's own documentation recommends it when a restriction has to hold.

Hooks and rules combine in a fixed way. A hook's "allow" skips the permission prompt but does not override a matching deny or ask rule, including one in managed settings. A hook that exits with code 2 stops the call before permission rules are evaluated, even if an allow rule would have approved it. So a hook can only make Claude Code stricter than your rules, never looser.

02 / Bash

Block rm -rf and git push --force

One script handles every case in this guide. Save it to ~/.claude/hooks/guard.py. It reads the hook input from stdin, checks the Bash command, and prints a deny decision when a pattern matches.

~/.claude/hooks/guard.py
#!/usr/bin/env python3
import json, os, re, sys

event = json.load(sys.stdin)
tool = event.get("tool_name", "")
args = event.get("tool_input", {})

BASH_RULES = [
    (r"\brm\s+(-\S*\s+)*-\S*[rR]", "recursive rm"),
    (r"\bgit\b.*\bpush\b.*(\s--force|\s-f\b)", "force push"),
    (r"\bgit\s+reset\s+--hard", "git reset --hard"),
]
SECRET = r"(^|[\s/'\"=])\.env(\.(?!example|sample)\w+)?($|[\s'\";|&])|\.ssh/|\.aws/"

def deny(reason):
    json.dump({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": reason}}, sys.stdout)
    sys.exit(0)

if tool == "Bash":
    cmd = args.get("command", "")
    for pattern, name in BASH_RULES:
        if re.search(pattern, cmd):
            deny("Blocked by guard.py: " + name)
    if re.search(SECRET, cmd):
        deny("Blocked by guard.py: command names a secrets path")
elif tool == "Read":
    path = os.path.realpath(args.get("file_path", ""))
    if re.search(SECRET, path):
        deny("Blocked by guard.py: secrets path")
sys.exit(0)

Register it in ~/.claude/settings.json. If the file already has a hooks key, merge into it.

~/.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Read",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$HOME/.claude/hooks/guard.py\""
          }
        ]
      }
    ]
  }
}
Input The hook gets tool_name, tool_input and tool_use_id on stdin, plus session_id, cwd and hook_event_name. For Bash, tool_input.command is the text. For Read, tool_input.file_path is always absolute, and Claude Code expands ~ and relative paths before the hook runs.
Matcher A matcher of letters, digits, _, | and commas is an exact tool name or a list of names. Anything else is a regular expression. Bash|Read is a list, so it matches those two tools and nothing else, such as Grep.
Patterns These regular expressions are a starting point. They also fire on git push -f inside a longer command and on any command that merely mentions .ssh/, so expect some false blocks. --force-with-lease matches too, because it starts with --force. Remove that if you want it allowed.
Why not a rule A deny rule is simpler for a fixed prefix, and the next section uses them. Claude Code's documentation lists what Bash(rm *) misses: /bin/rm -rf build/ and bash -c 'rm -rf build/'. Your own script can match those forms, and, as section 06 shows, can still miss others.
03 / Secrets

Block reads of .env, ~/.ssh and ~/.aws

The script above already refuses a Read of those paths, and a Bash command that names them. The first thing to add is plain deny rules, which need no code.

~/.claude/settings.json
{
  "permissions": {
    "deny": [
      "Read(**/.env)",
      "Read(**/.env.*)",
      "Read(~/.ssh/**)",
      "Read(~/.aws/**)",
      "Bash(git push --force *)",
      "Bash(git push -f *)"
    ]
  }
}
What Read covers Read and Edit deny rules apply to Claude's built-in file tools, and to file commands Claude Code recognises in Bash, such as cat, head, tail and sed, and to the targets of redirections. For Grep and Glob, Claude Code makes a best-effort attempt.
What it misses A command that reads files without naming them, such as grep -r pattern . run from the directory that holds the file, and a Python or Node script that opens the file itself. For OS-level enforcement that blocks every process from a path, the documentation points to the sandbox.
Path forms ~/path is your home directory, //path is an absolute path from the filesystem root, and a bare filename follows gitignore rules, so Read(.env) and Read(**/.env) are equivalent. A deny rule also applies to the file a symlink resolves to.
Why both The rule is exact and survives a bug in your script. The hook adds a message of your choosing, and a check on the text of commands the rule does not recognise.

The Claude Code and .env guide covers what these rules do not catch, including values that are already in a transcript.

04 / Output

Exit code 2 or a JSON decision

A hook can refuse a call in two ways. Pick one per hook, because Claude Code's guide says to choose one approach and the reference describes what happens when you mix them.

Exit code 2

~/.claude/hooks/no-force-push.sh
#!/bin/bash
cmd=$(jq -r '.tool_input.command // empty')
if [[ "$cmd" == *"git push --force"* ]]; then
  echo "Force pushes are blocked. Ask the user." >&2
  exit 2
fi
exit 0

Exit 2 is a blocking error. For PreToolUse it cancels the call, and the text on stderr is the reason Claude sees. It wins even over a JSON "allow".

Exit 0 with JSON

The script in section 02 prints this and exits 0, which gives you three outcomes instead of one:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Writes to src/generated/ are not allowed."
  }
}
deny Cancels the call and sends permissionDecisionReason to Claude.
ask Shows the normal permission prompt, with your reason in it. It also forces a prompt in auto mode, so the classifier cannot approve the call silently.
allow Skips the prompt, but deny and ask rules still apply. Print it rarely. Printing nothing is safer: exit 0 with no output means the hook has no opinion and the call goes through the normal permission flow.
Several hooks When hooks disagree, deny beats defer, which beats ask, which beats allow. A fourth value, defer, works only in non-interactive -p mode.

A crash lets the call through. Any other exit code, 1 included, is a non-blocking error: the action proceeds, and Claude Code shows only a notice with the first line of stderr. An uncaught Python exception exits 1. A timed-out PreToolUse command hook does not block either. For a security hook, wrap the check so that the failure path exits 2 if you would rather fail closed.

05 / Where

Where hooks live and who wins

LocationScopeShared
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes, committed to the repository
.claude/settings.local.jsonOne projectNo, gitignored
Managed policy settingsOrganisationYes, set by an administrator
Plugin hooks/hooks.json, skill and subagent frontmatterWhile the plugin, skill or subagent is activeYes
Merging Hook entries from different levels merge rather than replace each other, so your user hook and a project's hook both run, in parallel.
Settings keys For a single key set in several places, the order from highest is managed settings, --settings on the command line, .claude/settings.local.json, .claude/settings.json, then ~/.claude/settings.json.
Managed An administrator can set allowManagedHooksOnly in managed settings. User, project, local and plugin hooks are then blocked, apart from plugins force-enabled in managed enabledPlugins.
See them /hooks in Claude Code lists every configured hook, grouped by event.
06 / Limits

What a hook cannot do

A hook is a script that reads text. It is useful, and it is not a sandbox.

Text, not behaviour The hook sees the command string, not what it does. Try the secrets check against python3 -c "print(open('.en'+'v').read())": the guard script above lets it through, and a Read deny rule does not apply to a script that opens files itself. Aliases, encoded payloads and a script the agent wrote a moment earlier all look the same way. Pattern lists shrink the risk. They do not close it.
Your privileges Claude Code's documentation says command hooks run with your full user permissions and can modify, delete or access any file your account can. A hook that is wrong, or edited by something else, has that reach too.
Fails open A crash, a missing interpreter, a non-zero exit other than 2 and a timeout all let the call proceed. Test the hook so you know it runs at all (next section).
Off switch "disableAllHooks": true in any settings file turns hooks off, and Claude Code reads the value left after precedence applies. A false in a project's .claude/settings.json overrides a true in your user settings. Managed hooks keep running unless disableAllHooks is set in managed settings too.
A project's hooks Hooks in a repository's .claude/settings.json are code that runs on your machine when you open it. In an interactive session Claude Code holds back hooks from every settings file, yours included, until you accept the workspace trust dialog for that folder. With -p or the SDK there is no dialog and the folder is treated as trusted, so a hook committed to a repository you never opened runs. Before scripting claude -p over code you did not write, read its .claude/ files, or run with --settings '{"disableAllHooks": true}'.
Config changes A ConfigChange hook can block a change to a settings file taking effect, but not a change to managed policy settings, and a blocked change shows no message to you or Claude. Only the debug log records it.
Coverage A matcher of Bash does not see a PowerShell call, or an MCP tool, which is named mcp__<server>__<tool>. List every tool you mean to guard.

When the restriction has to hold, add the sandbox, and keep the hook for the message and for the calls the sandbox does not cover.

07 / Test

Test a hook before you trust it

A hook is a program that reads JSON and writes JSON. Test it as one, outside Claude Code first.

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' \
  | python3 ~/.claude/hooks/guard.py
# expect the deny JSON

echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' \
  | python3 ~/.claude/hooks/guard.py; echo "exit $?"
# expect no output, exit 0

echo '{"tool_name":"Read","tool_input":{"file_path":"/Users/you/app/.env"}}' \
  | python3 ~/.claude/hooks/guard.py
# expect the deny JSON
Registered? Run /hooks and look for the entry under PreToolUse.
Real call Ask Claude to read your .env, or to run rm -rf on a scratch directory. The call should be refused and the agent should quote your reason.
Debug log claude --debug-file <path> writes hook execution details to a known file. claude --debug writes them to ~/.claude/debug/<session-id>.txt and prints nothing to the terminal.
Negative tests Run the bypasses on purpose: /bin/rm -rf x, bash -c 'rm -rf x', a Python one-liner. Write down which ones get through, so you know what the hook does not cover.
Executable Claude Code's guide says hook scripts must be executable on macOS and Linux. If you call the script through python3 as above, it is not needed; for a bare script path, run chmod +x.
08 / ranwhat

If you would rather not write the patterns

ranwhat hook install is an opt-in PreToolUse hook. From the next session, a tool call that ranwhat's watch rules rate high or critical waits for your yes. It judges calls on your machine and sends nothing anywhere. A call no rule rates that high gets no answer from it, so Claude Code decides as it would have without the hook. It fails open, as any hook does, and RANWHAT_HOOK=0 in Claude Code's environment lets every call through. --mode deny refuses critical calls outright. It needs a real install, not uvx. To see what it would have asked about, run the check below, which reads what the agent already did and changes nothing.

$ uvx ranwhat check
Install
09 / Next

Next

A hook logs and blocks from now on. To see what already ran, start with the Claude Code audit log. If a command did delete something, read files Claude Code deleted. For secrets the agent has already read, see Claude Code and .env, and for the full list of controls, the AI coding agent security checklist. A hook is one gate among several: the secure-by-default setup shows where it fits, and checking AI-written code covers the gates that run in CI. The rest of the guides are listed here.