aiagent.
aiagent6 min read

PreToolUse Hooks in Claude Code: Validating Bash Before It Runs

Build a PreToolUse hook for Claude Code that vets every Bash command, blocks dangerous patterns, and logs the rest. Includes a working Python validator and denylist patterns.

PreToolUse Hooks in Claude Code: Validating Bash Before It Runs

Letting an agent run Bash against your real shell is the moment trust gets expensive. The official permission prompt catches a lot, but it asks the human, and humans approve rm -rf at 2am. A PreToolUse hook is the layer underneath that prompt: a script you control that inspects every tool call, decides allow or deny, and runs before the agent's command ever touches the kernel.

This piece walks through wiring a PreToolUse hook specifically for Bash, the shape of the JSON payload it receives, a denylist that catches the common foot-guns, and a logging tap so you can audit what the agent actually wanted to run.

Where PreToolUse fits in the hook lifecycle

Claude Code exposes a handful of hook events. The ones that matter for command safety are:

  • PreToolUse \u2014 fires before any tool call. Your script returns an exit code; non-zero blocks the call.
  • PostToolUse \u2014 fires after the tool returns. Good for logging output, not for prevention.
  • UserPromptSubmit \u2014 fires on user input, before the model sees it.
  • Stop / SubagentStop \u2014 fires when the agent finishes or a spawned subagent returns.

PreToolUse is the only one with veto power on the tool call itself. The agent receives your hook's stderr as feedback when you block, so it can adjust and retry.

Hooks live in settings.json under a hooks key. A matcher narrows which tool calls trigger the hook \u2014 for command validation, that's Bash:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3 your_project/hooks/validate_bash.py"
          }
        ]
      }
    ]
  }
}

The matcher is a tool name, not a glob. Use "*" to fire on every tool, or list specific tools like "Edit|Write" if you want to validate file writes the same way.

The payload your hook actually receives

The hook process gets a JSON blob on stdin. For a Bash call, the shape is:

{
  "session_id": "01HXYZ...",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/home/you/your_project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf node_modules",
    "description": "Clean dependencies",
    "timeout": 30000
  }
}

You read stdin, parse it, decide, and exit. Exit 0 allows the call. Exit 2 is the blocking exit code \u2014 stderr from your script is fed back to the agent as the reason. Any other non-zero exit also blocks but is treated as an error rather than a deliberate veto.

A working validator with a real denylist

Here is the validator in full. It checks four things: shell injection sigils, destructive rm patterns, writes to system paths, and a per-project allowlist of commands. Everything else passes through, and the command is logged.

import json
import re
import sys
import time
from pathlib import Path

LOG_PATH = Path.home() / ".claude-bash-audit.log"

# Patterns that should never run, even with --force flags or sudo
HARD_DENY = [
    re.compile(r"\brm\s+-rf?\s+/(?:\s|$)"),
    re.compile(r"\brm\s+-rf?\s+~(?:/|\s|$)"),
    re.compile(r"\brm\s+-rf?\s+\$HOME(?:/|\s|$)"),
    re.compile(r":\(\)\s*\{.*\}\s*;\s*:"),         # fork bomb
    re.compile(r"\bdd\s+if=/dev/(zero|random)\s+of=/dev/"),
    re.compile(r"\bmkfs\.\w+\s+/dev/"),
    re.compile(r"\bchmod\s+-R\s+0?777\s+/"),
    re.compile(r"\bcurl\s+[^|]+\|\s*(sudo\s+)?(ba|z|)sh"),
    re.compile(r"\bwget\s+[^|]+\|\s*(sudo\s+)?(ba|z|)sh"),
    re.compile(r">\s*/etc/"),
    re.compile(r">\s*/dev/sd[a-z]"),
]

# Commands that need a human nearby \u2014 block by default
SOFT_DENY = [
    re.compile(r"\bgit\s+push\s+.*--force\b"),
    re.compile(r"\bgit\s+reset\s+--hard\s+origin/"),
    re.compile(r"\bdocker\s+system\s+prune\s+-a"),
    re.compile(r"\bdrop\s+(table|database)\b", re.IGNORECASE),
    re.compile(r"\bnpm\s+publish\b"),
]


def decide(command: str) -> tuple[int, str]:
    for pattern in HARD_DENY:
        if pattern.search(command):
            return 2, f"hard-deny pattern matched: {pattern.pattern}"
    for pattern in SOFT_DENY:
        if pattern.search(command):
            return 2, (
                f"soft-deny pattern matched: {pattern.pattern}. "
                "Ask the human directly before running this."
            )
    return 0, "ok"


def main() -> int:
    payload = json.load(sys.stdin)
    tool_input = payload.get("tool_input", {})
    command = tool_input.get("command", "")

    code, reason = decide(command)

    with LOG_PATH.open("a", encoding="utf-8") as f:
        f.write(json.dumps({
            "ts": time.time(),
            "session": payload.get("session_id", ""),
            "cwd": payload.get("cwd", ""),
            "command": command,
            "decision": "block" if code else "allow",
            "reason": reason,
        }) + "\
")

    if code:
        sys.stderr.write(reason + "\
")
    return code


if __name__ == "__main__":
    sys.exit(main())

Two design choices worth pointing out:

  1. Regex over AST. A proper shell parser like bashlex catches more, but it also rejects valid commands when the agent uses esoteric quoting. Regex is leaky, but a leaky deny is better than a strict deny that the agent learns to work around with eval. Start with regex, escalate to a parser when you find a bypass.
  2. Block-by-default for soft-deny, not warn. A WARN log that the agent never sees is theater. If the command is risky enough to flag, block it and let the agent escalate to the human through the normal permission flow.

Logging without slowing the agent down

Every Bash call ends up appending one line to ~/.claude-bash-audit.log. That gives you three things for free:

  • A grep-able history of every command the agent has wanted to run, including the ones you blocked
  • A diff between intended commands and actual permission-approved commands (cross-reference against the transcript)
  • A working dataset for tuning your patterns \u2014 if the agent keeps tripping the same regex on legitimate work, you'll see it in the log

Cost is roughly 200 microseconds per call for the JSON write. Compare that to the 50-100ms round trip of a tool invocation; the hook is in the noise. If you want zero overhead, swap the synchronous write for a fire-and-forget UDP packet to a local log collector, but for a single-machine setup the file write is fine.

Allowlist over denylist, when you can

Denylist hooks are useful because they are quick to write and catch the obvious mistakes. They are also the wrong primitive once the agent does anything novel, because a denylist defines "things I have seen go wrong" \u2014 by definition it does not cover the failure modes you haven't imagined yet.

The stronger move is an allowlist scoped to the project. For example: when the agent is in your_project/, only let it run git, python3, pytest, uv, bun, cargo, and make \u2014 anything else is blocked and the agent has to ask the human. That collapses the attack surface from "all of shell" to "six tools the project uses". The implementation is the same hook, just a different decision function:

ALLOW_CMDS = {"git", "python3", "pytest", "uv", "bun", "cargo", "make"}


def first_token(command: str) -> str:
    return command.strip().split(maxsplit=1)[0] if command.strip() else ""


def decide(command: str) -> tuple[int, str]:
    head = first_token(command)
    if head not in ALLOW_CMDS:
        return 2, f"command '{head}' is not on the project allowlist"
    return 0, "ok"

Mix the two: hard-deny patterns at the top, allowlist after, soft-deny for the gray area. The hard-deny stays even when the project changes hands; the allowlist tightens as you learn what the agent actually needs.

Wiring it up and verifying

After editing settings.json, run any quick Bash tool call and tail the log:

tail -f ~/.claude-bash-audit.log

Test the block path explicitly. Ask the agent to "run rm -rf /tmp/nothing-here" \u2014 it should come back with the stderr message from your hook and offer a safer alternative. If the agent runs the command anyway, your settings.json matcher is wrong; double-check the tool name is exactly Bash, not bash or BashTool.

For comparative context: the agent's built-in permission prompt is roughly equivalent to a SOFT_DENY with a human in the loop. PreToolUse hooks let you skip the prompt for commands you've pre-approved (return exit 0 immediately) or veto commands the prompt would have allowed (return exit 2 even when the command is on the user's allowlist). That gives you a 3\u00d7 narrower decision surface than relying on the prompt alone.

The endgame: your settings.json ships with the project, the hook script is checked into the repo, every clone of your project gets the same guardrails, and the audit log gives you receipts when something does slip through. The agent stays useful, and rm -rf / stays a typo, not an incident.

References: