# Claude Code hooks for security: block risky tool calls

> Block rm -rf, force pushes and .env reads before Claude Code runs them: PreToolUse hooks, deny rules, exit 2 versus JSON, and where hooks stop working.

Canonical: https://ranwhat.com/guides/claude-code-hooks-security
Last updated: 2026-10-09

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](https://ranwhat.com/install)

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

## 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.

## 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.

## 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](https://ranwhat.com/guides/claude-code-env-secrets) covers what these rules do not catch, including values that are already in a transcript.

## 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.

## Where hooks live and who wins

| Location | Scope | Shared |
| --- | --- | --- |
| `~/.claude/settings.json` | All your projects | No |
| `.claude/settings.json` | One project | Yes, committed to the repository |
| `.claude/settings.local.json` | One project | No, gitignored |
| Managed policy settings | Organisation | Yes, set by an administrator |
| Plugin `hooks/hooks.json`, skill and subagent frontmatter | While the plugin, skill or subagent is active | Yes |

- **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.

## 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.

## 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`.

## 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](https://ranwhat.com/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](https://ranwhat.com/install)

## Next

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

## Sources

- [Hooks referencePreToolUse input, exit codes, permissionDecision, matchers, workspace trust, disableAllHooks, security](https://code.claude.com/docs/en/hooks)
- [Hooks guideblocking example, hook locations, /hooks, debug log, matcher notes](https://code.claude.com/docs/en/hooks-guide)
- [Configure permissionsdeny, ask, allow order, Read rules, what a Bash rule does not match, hooks versus rules, sandbox](https://code.claude.com/docs/en/permissions)
- [Settings files and precedencethe order of managed, command line, local, project and user](https://code.claude.com/docs/en/settings)
- [Settings referencedisableAllHooks, allowManagedHooksOnly, permissions.deny](https://code.claude.com/docs/en/settings-reference)
- [ranwhat READMEwhat ranwhat hook install does and does not do](https://github.com/MatijaMiki/ranwhat/blob/main/README.md)
