ES · Leer en español Explore for free

Recipe · Intermediate · 10 min

Claude Code hooks: block dangerous commands and secrets

A Python PreToolUse hook that stops Claude Code from reading your .env, running dangerous rm -rf or doing git push --force to main. Tested.

Tested with Claude Code 2.1.316 · reviewed

The problem

Claude Code runs commands and opens files for you. One wrong step is enough for it to read your .env and drop the key into the conversation, run rm -rf on the wrong path, or rewrite main with git push --force. Asking for this in CLAUDE.md helps, but it is an instruction to the model, not a guarantee. If you use modes that approve on their own, such as acceptEdits or bypassPermissions, you need a rule that always holds.

The solution

A PreToolUse hook runs before every tool call. If the script exits with code 2, Claude Code cancels the action and passes the error message to Claude. We write it in Python with no dependencies, so you don't need jq.

1. Create the hook script

Save it as .claude/hooks/protect.py inside your project:

#!/usr/bin/env python3
"""PreToolUse hook: blocks dangerous rm -rf, access to secrets and git push --force to main."""
import json
import re
import subprocess
import sys

# Secret file names (matched against the file name, not the full path)
SECRETS = re.compile(r"^(\.env(\..+)?|.*\.pem|.*\.key|id_rsa.*|id_ed25519.*|credentials\.json|\.npmrc|\.pypirc)$")
TEMPLATES = re.compile(r"^\.env\.(example|sample|template)$")  # these can be read

# Same idea, searched inside a Bash command
SECRET_IN_COMMAND = re.compile(
    r"(?<![\w.-])(\.env(\.[\w-]+)?|[\w.-]+\.pem|[\w.-]+\.key|id_rsa\w*|id_ed25519\w*|credentials\.json)(?![\w.-])"
)


def block(reason):
    print(f"Blocked by the security hook: {reason}", file=sys.stderr)
    sys.exit(2)  # exit 2 = Claude Code cancels the tool call and passes this message to Claude


def is_secret(path):
    name = path.replace("\\", "/").rstrip("/").split("/")[-1]
    return bool(SECRETS.match(name)) and not TEMPLATES.match(name)


def check_rm(command):
    for part in re.split(r"&&|\|\||;|\||\n", command):
        words = part.split()
        if not words or words[0] not in ("rm", "sudo"):
            continue
        if words[0] == "sudo":
            words = words[1:]
            if not words or words[0] != "rm":
                continue
        flags = "".join(w.lstrip("-") for w in words[1:] if w.startswith("-") and not w.startswith("--"))
        long_flags = [w for w in words[1:] if w.startswith("--")]
        recursive = "r" in flags.lower() or "--recursive" in long_flags
        forced = "f" in flags or "--force" in long_flags
        if not (recursive and forced):
            continue
        for target in (w.strip("'\"") for w in words[1:] if not w.startswith("-")):
            # Root, home, the whole current directory or any absolute path
            if target in ("/", "/*", "~", "~/", "~/*", ".", "./", "..", "*", "./*") \
                    or target.startswith(("/", "~", "$HOME", "${HOME}", "..")):
                block(f"'rm -rf {target}' is too dangerous. Delete specific paths inside the project.")


def check_push(command, cwd):
    for part in re.split(r"&&|\|\||;|\n", command):
        if not re.search(r"\bgit\s+(-C\s+\S+\s+)?push\b", part):
            continue
        if not re.search(r"(\s--force(-with-lease)?\b|\s-[a-zA-Z]*f\b|\s\+\S+)", part):
            continue
        if re.search(r"\b(main|master)\b", part):
            block("'git push --force' to main/master is not allowed. Use a branch and a pull request.")
        # No explicit branch: check which branch the repository is on
        try:
            branch = subprocess.run(["git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
                                    capture_output=True, text=True, timeout=5).stdout.strip()
        except (OSError, subprocess.SubprocessError):
            branch = ""
        if branch in ("main", "master"):
            block(f"you are on '{branch}' and 'git push --force' would rewrite it. Use a branch.")


def main():
    data = json.load(sys.stdin)
    tool = data.get("tool_name", "")
    tool_input = data.get("tool_input", {})

    if tool in ("Read", "Edit", "Write"):
        path = tool_input.get("file_path", "")
        if is_secret(path):
            block(f"'{path}' contains secrets. Ask the user for the value you need.")
    elif tool == "Grep":
        path = tool_input.get("path", "") or ""
        if is_secret(path) or is_secret(tool_input.get("glob", "") or ""):
            block(f"searching inside secret files is not allowed ('{path or tool_input.get('glob')}').")
    elif tool == "Bash":
        command = tool_input.get("command", "")
        for m in SECRET_IN_COMMAND.finditer(command):
            if not TEMPLATES.match(m.group(1)):
                block(f"the command touches '{m.group(1)}', which contains secrets.")
        check_rm(command)
        check_push(command, data.get("cwd", "."))

    sys.exit(0)  # no objection: the normal permission flow continues


if __name__ == "__main__":
    main()

What it blocks:

ToolBlockedAllowed
Read, Edit, Write.env, .env.*, *.pem, *.key, id_rsa*, id_ed25519*, credentials.json, .npmrc, .pypirc.env.example, .env.sample, .env.template
Grepsearching inside those filesthe rest of the project
Bashcommands that name those files (cat .env, cp x .env…)process.env, .envrc
Bashrm -rf (or -fr, -Rf, with sudo) on /, ~, $HOME, ., .., * or any absolute pathrm -rf node_modules dist
Bashgit push --force, -f, --force-with-lease or +branch to main/master, or with no branch while you are on maingit push --force origin feature/x

2. Register it in .claude/settings.json

Create or extend .claude/settings.json at the project root:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Read|Edit|Write|Grep",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect.py"
          }
        ]
      }
    ]
  }
}

The matcher is a list of exact tool names separated by |, and it is case-sensitive. $CLAUDE_PROJECT_DIR is the project root, so the hook still works when Claude changes directory. Calling the script through python3 means you don't have to make it executable.

3. Share the protection with your team

Commit .claude/settings.json and .claude/hooks/protect.py to the repository, and add .env to .gitignore if it isn't there yet. Everyone who clones the project then gets the same rules.

Check it

First test the script on its own by simulating the JSON input Claude Code sends it:

echo '{"tool_name":"Read","tool_input":{"file_path":"/tmp/demo/.env"}}' | python3 .claude/hooks/protect.py; echo "exit $?"
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf node_modules"}}' | python3 .claude/hooks/protect.py; echo "exit $?"
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' | python3 .claude/hooks/protect.py; echo "exit $?"

Expected output:

Blocked by the security hook: '/tmp/demo/.env' contains secrets. Ask the user for the value you need.
exit 2
exit 0
Blocked by the security hook: 'git push --force' to main/master is not allowed. Use a branch and a pull request.
exit 2

Then test it with real Claude Code. We did it in bypassPermissions, the most permissive mode:

claude -p "Hook test: try to read .env with Read and then with 'cat .env'. Don't show any values; copy the exact error." --permission-mode bypassPermissions < /dev/null

This is what Claude received, copied from a real run with version 2.1.316 (with the Spanish version of the script, which prints the same messages in Spanish):

PreToolUse:Read hook error: [python3 "$CLAUDE_PROJECT_DIR"/.claude/hooks/proteger.py]: Bloqueado por el hook de seguridad: '/tmp/claude-1000/receta-hooks/.env' contiene secretos. Pídele al usuario el valor que necesites.
PreToolUse:Bash hook error: [python3 "$CLAUDE_PROJECT_DIR"/.claude/hooks/proteger.py]: Bloqueado por el hook de seguridad: el comando toca '.env', que contiene secretos.

With protect.py the message reads Blocked by the security hook: .... For rm -rf /tmp/claude-1000/receta-hooks-basura and git push --force origin main Claude also got the block error, and the folder stayed where it was. In an interactive session, type /hooks to confirm the hook is listed under PreToolUse.

Why it works

Claude Code runs PreToolUse hooks before it checks permissions, and it does so in every permission mode, including bypassPermissions and dontAsk. The hook gets a JSON object on stdin with tool_name and tool_input, which holds command for Bash, file_path for Read, Edit and Write, and path for Grep. Claude Code makes the file tools' paths absolute before it calls the hook. With exit 2 the action is blocked and the stderr text reaches Claude as the reason it was denied. Exit 0 approves nothing: the action goes through the normal permission flow. Any other exit code is a non-blocking error, and the action runs anyway. More in the hooks reference and the hooks guide.

Common mistakes

FAQ

Does it work with --dangerously-skip-permissions or in bypassPermissions mode?

Yes. The documentation says PreToolUse hooks run before the permission mode is checked and that a block holds in every mode. We tested it with --permission-mode bypassPermissions.

Is it bulletproof?

No. A command that reads files without naming them, such as python -c "open('.e'+'nv')" or grep -r key ., gets past it. Combine it with permission rules ("permissions": {"deny": ["Read(./.env)", "Read(./.env.*)"]}) and, if you need blocking at the operating-system level, with the sandbox.

Why Python and not jq?

The official examples use jq, but many machines don't have it installed, and then the hook fails with jq: command not found. python3 usually comes installed on Linux and macOS, and the script only uses the standard library.

Can I use it in all my projects?

Yes. Copy the script to ~/.claude/hooks/protect.py and put the same hooks block in ~/.claude/settings.json, with "command": "python3 \"$HOME\"/.claude/hooks/protect.py".

What if I really need Claude to use a value from .env?

Give it the value it needs yourself, or let the code read it at runtime without Claude seeing it. If it only needs to know which variables exist, keep a .env.example with no values: the hook lets Claude read it.

All recipes