hooks

PreToolUse Hook: Validate, Block, or Transform Tool Calls in Claude Code

Quick Answer

PreToolUse hooks run a shell command before each tool call. Return exit code 0 to allow the tool call. Return non-zero to block it — Claude is informed the tool was denied and will adapt. Environment variables CLAUDE_TOOL_NAME and CLAUDE_TOOL_INPUT contain the tool name and JSON-encoded arguments.

PreToolUse is the most security-relevant hook type. It gives you a synchronous checkpoint before Claude executes any tool — file write, bash command, API call. Your hook script runs, inspects the tool call, and decides: allow (exit 0) or block (exit non-zero).

The hook receives `CLAUDE_TOOL_NAME` (e.g., 'Bash', 'Write', 'Read') and `CLAUDE_TOOL_INPUT` (a JSON string of the tool's arguments). For Bash calls, the input is `{"command": "rm -rf /tmp/cache"}`. For Write calls, it's `{"path": "src/file.ts", "content": "..."}`. Your hook can parse this JSON and make policy decisions.

A common use case: prevent writing outside the project directory. Parse the Write tool's path argument, check that it starts with your project root, and block if not. Another: prevent dangerous bash commands. Parse the Bash tool's command argument and block if it contains `rm -rf /`, `sudo`, or `curl` to external domains.

Blocking a tool call doesn't end the session — Claude is told the tool was denied and asked to adapt. Claude will typically try an alternative approach or ask you how to proceed. The error message your hook prints to stderr is passed to Claude as context for why the block occurred.

PreToolUse hooks that run validation logic must be fast — they add latency to every tool call. A bash hook that does a regex match on the command argument is fine (milliseconds). A hook that makes HTTP requests to a policy server will noticeably slow down the session.

Hooks are not a replacement for OS-level security controls. A determined agent could bypass a PreToolUse hook by calling tools in ways your regex doesn't anticipate. For production security, use OS-level controls (read-only mounts, network namespaces, seccomp profiles) in addition to PreToolUse hooks.

Examples

PreToolUse hook: block writes outside projectbash
#!/bin/bash
# .claude/hooks/validate-write.sh
# Blocks Write tool calls to paths outside the project directory

set -euo pipefail

# Only run for Write tool
[ "$CLAUDE_TOOL_NAME" = "Write" ] || exit 0

# Parse path from tool input JSON
FILE_PATH=$(echo "$CLAUDE_TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('path',''))")

PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"

# Resolve to absolute path
ABS_PATH=$(realpath -m "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")

# Block if not inside project root
if [[ "$ABS_PATH" != "$PROJECT_ROOT"* ]]; then
  echo "BLOCKED: Write to '$FILE_PATH' is outside project root '$PROJECT_ROOT'" >&2
  exit 1
fi

exit 0
PreToolUse hook: block dangerous bash commandsbash
#!/bin/bash
# .claude/hooks/validate-bash.sh
# Blocks dangerous bash commands

[ "$CLAUDE_TOOL_NAME" = "Bash" ] || exit 0

COMMAND=$(echo "$CLAUDE_TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('command',''))")

# Blocklist patterns
BLOCKED_PATTERNS=(
  "rm -rf /"
  "rm -rf ~"
  "sudo rm"
  "dd if=/dev/zero"
  "> /dev/sda"
  "mkfs"
  "curl.*|.*sh"  # pipe curl to shell
  "wget.*|.*sh"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qE "$pattern"; then
    echo "BLOCKED: Command matches dangerous pattern: $pattern" >&2
    exit 1
  fi
done

exit 0
settings.json: register PreToolUse hooksjson
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",
        "command": "bash .claude/hooks/validate-write.sh"
      },
      {
        "matcher": "Bash",
        "command": "bash .claude/hooks/validate-bash.sh"
      },
      {
        "matcher": ".*",
        "command": "echo \"[AUDIT] Tool: $CLAUDE_TOOL_NAME at $(date -u +%Y-%m-%dT%H:%M:%SZ)\" >> ~/.claude/audit.log"
      }
    ]
  }
}

Tips

  • Always start hook scripts with `[ "$CLAUDE_TOOL_NAME" = "ToolName" ] || exit 0` to short-circuit quickly for tools you don't care about.
  • Print meaningful error messages to stderr when blocking — Claude receives this message as context and will relay it to you or use it to adapt.
  • Use `jq` or Python's json module to parse CLAUDE_TOOL_INPUT — don't try to regex-parse JSON strings, it's fragile.
  • Add an audit log hook (exit 0 always, just logs) separately from your blocking hooks — it gives you a record of what tools Claude called even when you're not watching.
  • Test hooks with edge cases: paths with spaces, commands with special characters, empty inputs. Shell parsing issues are the most common hook bug.
  • Keep PreToolUse hooks under 100ms — measure with `time bash hook.sh` with sample inputs. If slower, optimize or move logic to a compiled binary.

FAQ

What format is CLAUDE_TOOL_INPUT?+

CLAUDE_TOOL_INPUT is a JSON string. For Bash: `{"command": "npm test"}`. For Write: `{"path": "src/file.ts", "content": "..."}`. For Read: `{"file_path": "src/file.ts"}`. Parse with `jq` or Python's json module — the exact fields depend on the tool.

Can I modify the tool call arguments in a PreToolUse hook?+

Not directly in the current version — the hook is allow/block only. You cannot transform the arguments (e.g., rewrite a file path) and pass modified arguments back. If you need transformation, consider a PostToolUse hook that reprocesses the output.

Does blocking a tool call interrupt the whole task?+

No — Claude is informed that the tool was blocked and typically adapts its approach. It may try a different tool, ask you for guidance, or report that it cannot complete the task with the current permissions. The block is informative, not terminal.

How do I test a PreToolUse hook without running a full Claude session?+

Set the environment variables manually and run the script: `CLAUDE_TOOL_NAME=Write CLAUDE_TOOL_INPUT='{"path":"/tmp/outside-project","content":"test"}' bash .claude/hooks/validate-write.sh; echo "Exit: $?"`

Related Guides