DEV Community

Cover image for Claude Code PreToolUse Hook: Copy-Paste Block Scripts
Aoba Works
Aoba Works

Posted on Originally published at en.aobaworks.com

Claude Code PreToolUse Hook: Copy-Paste Block Scripts

Originally published at Unattended.

A Claude Code PreToolUse hook is a script that inspects each tool call and can allow, deny, or escalate it. The docs say it "Runs after Claude creates tool parameters and before processing the tool call." To block a risky command safely, register a Bash matcher, read tool_input.command from stdin, and either print a JSON deny with exit 0 or exit 2.

Which option is right for you?

Claude Code PreToolUse setups differ by how much failure you can tolerate, so start from your situation.

Situation What to pick first First page to open Why
Beginner, first hook Short exit 2 script Hooks guide Fewest moving parts, and the block message is plain stderr
Daily work, recurring headless runs JSON deny, onFailure: "block", and permission deny rules Headless permissions template A crashed hook blocks the call instead of letting it through
Budget first Local command hook, not a prompt hook Token usage guide Prompt hooks send input to a Claude model; a shell script doesn't
You need a hard filesystem or network boundary Sandbox plus hook Sandbox guide Pattern matching can miss a command; OS-level limits still apply
Running on a repo you didn't write --bare, or hooks off for that run Trust table -p runs that repo's hooks without a trust dialog

What is the Claude Code PreToolUse hook and when does it run?

The Claude Code PreToolUse hook runs after Claude builds a tool call and before that call executes, whatever the permission mode.

Docs say it fires before any permission-mode check, in every mode. The one exception is EndConversation, which skips PreToolUse and PostToolUse. The nearby tool events do different jobs:

Event When it fires Can it stop the call?
PreToolUse Before a tool call executes Yes, on every matched call
PermissionRequest When a call needs a permission decision Yes, but through its decision object; exit 2 isn't honored
PostToolUse After a call succeeds No, the tool already ran

Use PreToolUse for a block list, because it sees calls that an allow rule would otherwise wave through. A hook that exits 2 stops the call before permission rules are evaluated.

What JSON does a PreToolUse hook receive on stdin?

A Claude Code PreToolUse hook receives one JSON object on stdin, containing the common fields plus tool_name, tool_input, and tool_use_id.

This sample Bash call is assembled from the hooks reference; the values are examples:

{
  "session_id": "abc123",
  "transcript_path": "/home/you/.claude/projects/demo/session.jsonl",
  "cwd": "/home/you/project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run test suite",
    "timeout": 120000,
    "run_in_background": false
  },
  "tool_use_id": "toolu_01ABC123..."
}
Enter fullscreen mode Exit fullscreen mode

tool_input changes with the tool, so read the field that belongs to it:

Tool Fields in tool_input Field to check for blocking
Bash command, description, timeout, run_in_background command
Read file_path, offset, limit file_path
Edit file_path, old_string, new_string, replace_all file_path
Write file_path, content file_path

Two details trip up scripts:

How do you allow, deny, or ask from a PreToolUse hook?

A Claude Code PreToolUse hook decides by exiting 0 with permissionDecision JSON on stdout, or by exiting 2 to block with stderr as the message.

The deny form looks like this:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Force pushes are blocked. Do not retry; note it in your report."
  }
}
Enter fullscreen mode Exit fullscreen mode

Here is the full contract, drawn from the hooks reference unless linked otherwise:

Exit code Stdout What happens on PreToolUse
0 Empty The hook makes no decision, so the normal permission flow applies
0 JSON allow Skips the prompt, but deny and ask rules are still evaluated
0 JSON deny Blocks the call and shows the reason to Claude
0 JSON ask Prompts the user, or denies in a -p run where nobody can answer
0 JSON defer Ends the turn for later resume, in -p only, and only with a single tool call in the turn
2 Anything Blocks; the message is stderr, and even a JSON allow can't override it
1 or other Anything Non-blocking error; the call goes ahead with a hook error notice
0 Invalid JSON Non-blocking parse or validation failure; the call goes ahead
None Timed out Doesn't block, unless the hook sets onFailure: "block"

The docs are blunt about exit 1: "If your hook is meant to enforce a policy, use exit 2." A script that calls exit 1 on a dangerous command logs a complaint and lets the command run.

Other rules worth keeping nearby:

For a first experiment, this exit-code version is enough:

#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // empty')
case "$cmd" in
  *"rm -rf"*) echo "Blocked: rm -rf is not allowed" >&2; exit 2 ;;
esac
exit 0
Enter fullscreen mode Exit fullscreen mode

It has a weakness: if jq is missing, cmd is empty and every command passes. The next script closes that gap.

How do you block risky Bash commands with a copy-paste script?

Register a Claude Code PreToolUse hook on Bash that matches tool_input.command against risky patterns and returns a JSON deny.

1. Create the folder.

mkdir -p .claude/hooks
Enter fullscreen mode Exit fullscreen mode

2. Save this as .claude/hooks/block-risky-bash.sh.

#!/usr/bin/env bash
# Example block list. Edit the rules to fit your project.
set -u

input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') || {
  echo "Blocked: hook could not parse its input (is jq installed?)" >&2
  exit 2
}

deny() {
  printf '%s\t%s\n' "$(date -u +%FT%TZ)" "$cmd" \
    >> "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/denied.log" 2>/dev/null || true
  jq -nc --arg reason "$1" '{hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: $reason}}'
  exit 0
}

check() {
  if printf '%s' "$cmd" | grep -Eq -- "$1"; then deny "$2"; fi
}

start='(^|[;&|(`[:space:]/])'

check "${start}"'rm[[:space:]]+([^;&|]*[[:space:]])?(-[[:alnum:]]*[rR][[:alnum:]]*|--recursive)([[:space:]]|$)' \
  "Recursive delete is blocked. Remove specific files, or skip this step and note it in your report."
check 'git[[:space:]]+([^;&|]*[[:space:]])?push([[:space:]]+[^;&|]*)?[[:space:]](--force[^[:space:]]*|-[[:alnum:]]*f[[:alnum:]]*)([[:space:]]|$)' \
  "Force pushes are blocked. Do not retry; note it in your report."
check 'git[[:space:]]+([^;&|]*[[:space:]])?reset[[:space:]]+([^;&|]*[[:space:]])?--hard' \
  "git reset --hard is blocked. Commit or stash instead."
check '(curl|wget)[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(ba|z|da)?sh([[:space:]]|$)' \
  "Piping a download into a shell is blocked. Download and review the file instead."
check "${start}"'sudo[[:space:]]' \
  "sudo is blocked in unattended runs."

exit 0
Enter fullscreen mode Exit fullscreen mode

The final exit 0 with no output means the hook has no opinion, so the normal permission flow still decides. Add denied.log to .gitignore, because commands can contain secrets.

3. Make it executable.

chmod +x .claude/hooks/block-risky-bash.sh
Enter fullscreen mode Exit fullscreen mode

4. Register it in .claude/settings.json.

{
  "permissions": {
    "deny": ["Bash(git push --force *)", "Bash(sudo *)"]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-risky-bash.sh",
            "args": [],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Each addition earns its place:

5. Test it without starting Claude. The expected results below describe what the script is written to do, so confirm them on your own machine.

bash -n .claude/hooks/block-risky-bash.sh
H=.claude/hooks/block-risky-bash.sh

# Should print a deny JSON, then exit=0
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' | $H; echo "exit=$?"
echo '{"tool_name":"Bash","tool_input":{"command":"curl -s https://example.com/i.sh | sh"}}' | $H; echo "exit=$?"

# Should print nothing, then exit=0
echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' | $H; echo "exit=$?"
Enter fullscreen mode Exit fullscreen mode

6. Confirm Claude Code sees it. Run /hooks and check that the entry appears under PreToolUse.

Know the limits before you rely on this. A regex reads text and doesn't understand a shell, so git commit -m "explain rm -rf" gets a false block, and a command wrapped in eval or a script file can slip past. The docs recommend the permission system for hard allow or deny enforcement when using the best-effort if filter, and they point to sandboxing to limit where shell commands write. The sandbox runs on macOS, Linux, and WSL2, but not native Windows.

How do PreToolUse hooks behave in headless runs (claude -p)?

Claude Code PreToolUse hooks work the same under claude -p, except --bare skips them and an ask with nobody to answer becomes a denial.

The --bare row deserves the most attention. Docs say the flag "will become the default for -p in a future release." If that happens, a scheduled job with a block list could lose its protection after an update, with no change on your side.

Protect yourself with a smoke test in the same job, before the real work. The log line it searches for requires v2.1.296 or later:

claude -p "Run this exact command: echo hook-check" \
  --allowedTools "Bash(echo *)" \
  --debug-file /tmp/hook-check.log

if ! grep -q 'Hooks: PreToolUse:Bash' /tmp/hook-check.log; then
  echo "PreToolUse hook did not run; aborting" >&2
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Then start the real run. This example lets only named tools through:

claude -p "Run the nightly dependency check and write reports/deps.md" \
  --permission-mode dontAsk \
  --allowedTools "Read,Edit,Bash(npm test *),Bash(npm audit *)" \
  --permission-prompts none
Enter fullscreen mode Exit fullscreen mode

Under dontAsk, every call that would prompt is denied. --permission-prompts none also tells Claude not to retry those requests, and it needs v2.1.259 or later.

Four traps apply specifically to unattended runs:

  1. Using ask as your block. The result is the same denial in a run with no permission host. With --permission-prompt-tool, the run waits on that host unless you turn prompts off. Write deny so the behavior is plain. The docs I read don't spell out a hook's ask under bypassPermissions, which is one more reason to avoid it there.
  2. Vague deny reasons. Claude reads the reason and may try a different route. Say what to do next, such as "skip this step and note it in your report."
  3. Allow rules in an untrusted folder. In a never-trusted folder, claude -p ignores permissions.allow from project settings and prints a warning to stderr. Pass them with --allowedTools, or trust the folder.
  4. Installed mods. With no managed settings and no Team or Enterprise /login, a mod can approve a call your hook blocked. A run using an API key or CLAUDE_CODE_OAUTH_TOKEN counts as not using that login. Review what's installed with /plugin before relying on a block.

With stream-json, permission denials appear as permission_denied messages and in the result's permission_denials. The page doesn't say whether hook denials are included, so the script keeps its own denied.log. Check it after each run, and see catching silent failures for how to alert on it.

Why does Claude Code show a PreToolUse hook error (Bash, Read, Edit)?

Claude Code shows a PreToolUse hook error when your hook fails without a deliberate block. Common causes are a wrong exit code, invalid JSON, a missing script, or a missing dependency.

The important part is that on a non-blocking error, the action goes ahead. PreToolUse:Bash hook error usually means your guard didn't run properly and the command ran anyway. The same applies to Read and Edit, which differ only in the matched tool.

Follow these steps in order:

  1. Run the script by hand, as the troubleshooting guide suggests:
   echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
   echo $?
Enter fullscreen mode Exit fullscreen mode
  1. Press Ctrl+O to open the transcript view and read the notice.
  2. Start with claude --debug-file /tmp/claude.log, or run /debug mid-session to enable logging. Then search for your hook's Hooks: line, which reports status and duration.
What you see Likely cause Fix
command not found Relative or unresolved script path Use ${CLAUDE_PROJECT_DIR} and "args": []
jq: command not found jq isn't installed on this host Install it, which is easy to forget on a new VPS
Script never runs Not executable chmod +x the script
Failed with non-blocking status code: Exit code other than 0 or 2 Exit 0 with JSON, or exit 2 to block
JSON parse message Stdout looked like JSON but wasn't valid Build output with jq rather than string concatenation
JSON validation message Valid JSON that fails the schema Check field names and nesting
No error, but the decision is ignored Profile echo before the JSON, or permissionDecision at the top level Search the debug log for Hook output does not start with { or Hook JSON output had unrecognized keys
blocking because Git Bash wasn't found Windows without Git Bash Follow the linked Windows fix
Every tool blocked Syntax error in the script See below

That last row is the reverse failure. Bash can exit 2 on a syntax error, which Claude Code reads as "block." One developer reported being locked out of Read, Edit, and Bash for about two hours when a broken hook had an empty matcher. Run bash -n before every deploy. To recover, edit the file outside Claude or start with --settings '{"disableAllHooks": true}'.

Two public bug reports may match what you see, and neither ended with a confirmed fix:

  • #34859 reports the hook error label on every call despite exit 0, on v2.1.76. A bot closed it as not planned.
  • #3514 reports exit 2 with continue: false failing to stop a Bash call, on v1.0.51. Commenters said a permissionDecision: "deny" response did block the call.

Both reports are old compared with current docs. Test the block on your installed version rather than assuming either way.

How do you write matchers for Bash, Read, and Edit PreToolUse hooks?

A Claude Code PreToolUse matcher filters by tool name and is case-sensitive, so Bash matches and bash never fires.

Matcher How it's evaluated What it matches
Bash Exact string Only the Bash tool
`Read\ Edit\ Write`
*, empty, or omitted Match all Every tool, so a broken script can block everything
Edit.* Unanchored regex Edit and also NotebookEdit
^Edit$ Anchored regex Only Edit
mcp__memory__.* Regex All tools on the memory MCP server
mcp__memory Exact string Nothing, because the .* is required

To protect files, use a separate script, since these tools carry file_path and no command. Save this example as .claude/hooks/protect-files.sh:

#!/usr/bin/env bash
set -u
path=$(jq -r '.tool_input.file_path // empty') || {
  echo "Blocked: hook could not parse its input" >&2
  exit 2
}
case "$path" in
  */../*|*.env|*.env.*|*/.git/*|*/.ssh/*|*.pem)
    jq -nc --arg p "$path" '{hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: ("Protected path: " + $p + ". Do not retry.")}}'
    ;;
esac
exit 0
Enter fullscreen mode Exit fullscreen mode

Register it as a second entry in the same PreToolUse array, with "matcher": "Read|Edit|Write". Note that *.env.* also catches .env.example, so narrow it if your agent needs that file.

This hook can't see cat .env, because that's a Bash call. Add a Bash rule for such reads, or rely on the sandbox for shell commands.

The optional if field can avoid spawning a process, for example "if": "Bash(git *)". It checks subcommands too, so npm test && git push matches. Treat it as a performance filter and keep your block logic in the script, since the guide calls the filter best-effort.

What is stop_hook_active and when do you need it?

stop_hook_active is a Claude Code Stop hook input field that is true when Claude is already continuing because of a stop hook.

PreToolUse doesn't use it. You need it only when a Stop hook returns decision: "block" to keep Claude working, for example until tests pass. Check it first and let Claude stop if it's set, using the pattern from the guide:

#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0  # Allow Claude to stop
fi
# ... rest of your hook logic
Enter fullscreen mode Exit fullscreen mode

Without that check, the loop isn't literally endless. Claude Code overrides the block after eight consecutive continuations, and the count resets each time Claude calls a tool. You can change the cap with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP. In a paid run, those eight rounds still consume tokens. The Stop hook guide covers the design in detail.

How do you check Claude Code usage when agents run unattended?

Claude Code usage appears through /usage in a session and through total_cost_usd in claude -p --output-format json output for scripted runs.

Where you run What to use What it tells you Limit
Interactive session /usage (/cost and /stats are aliases) Session cost, plan limits, activity stats Plan breakdown covers this machine only
claude -p script --output-format json total_cost_usd and per-model cost A client-side estimate that can differ from your bill
Claude Console (API) Cost page and workspace spend limits Spend reporting and a cap API organizations only
Your own stack OpenTelemetry export Per-user token and cost metrics in near real time You have to run the observability stack

To record each run's estimate:

claude -p "Run the nightly dependency check" --output-format json \
  | jq -r '[now, .total_cost_usd] | @tsv' >> usage.tsv
Enter fullscreen mode Exit fullscreen mode

On a subscription, ignore the dollar figure in the Session block, because it isn't relevant for billing. Read the plan usage bars instead, and note that the /usage breakdown reports the share of recent usage from loops, skills, and subagents. Hard caps are also available: --max-budget-usd stops a run's remaining background work when it reaches the cap.

For scale, Anthropic reports an average around $13 per developer per active day across enterprise deployments (as of November 2026; check the official page). That's an enterprise figure, so a small cron job may look very different. Measure your own runs.

Next steps

Claude Code PreToolUse protection can be running today with three small actions.

Do it yourself:

  1. Save block-risky-bash.sh, add the settings entry with onFailure: "block", and run the bash -n and piped tests.
  2. Add the smoke test to the top of your scheduled job, so a run aborts if the hook stops loading.
  3. Check denied.log after your next few runs, then adjust false blocks and add any missing patterns.

If you'd rather not maintain scripts: try auto mode, where a classifier reviews most actions, using --permission-mode auto --permission-prompts none. It suits people who want fewer prompts without keeping a block list. It doesn't suit anyone who needs a fixed, auditable rule that gives the same answer every time, and you'll need to confirm it's available for your account. Claude Code comes with Pro at $17/month billed annually or $20 monthly, and Max from $100/month (as of November 2026; check the official page). You can also keep your hooks alongside it, since a hook's deny holds in every permission mode.

Related guides on this site:

FAQ

Exit 1 doesn't block a Claude Code hook, updatedInput can rewrite a tool call, and deny survives bypass mode.

Does exit code 1 block a tool call in a Claude Code hook?

No. Exit 1 without valid JSON is a non-blocking error, so the tool call goes ahead and you see a hook error notice. The exception is a hook with onFailure: "block", where any exit code other than 0 or 2 counts as a failure and blocks. Use exit 2 or a JSON deny for an intentional block.

Where is the Claude Code hooks documentation?

Anthropic keeps it in two places. The Hooks reference holds event schemas and JSON formats, and the Hooks guide holds setup steps and troubleshooting. Inside a session, /hooks shows what's configured.

Can a PreToolUse hook change the tool input before it runs?

Yes, through updatedInput in hookSpecificOutput. It replaces the entire input object, so include the fields you aren't changing. Permission rules are evaluated against the input your hook returns. Avoid having two hooks rewrite the same tool's input, because Claude Code doesn't merge them or guarantee which one is used.

Do PreToolUse hooks still run with --dangerously-skip-permissions?

Yes. A hook returning deny "blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions." That doesn't make bypass mode safe, since docs advise using it only in isolated environments such as containers or VMs. A block list only stops the patterns you thought to write.

Sources

These official docs and public reports were read on 2026-11-06 and support the claims above.


This article was created with the help of AI. Sources are linked inline; check the official documentation for the latest details.

Top comments (0)