UnattendedClaude Code Guides

Claude Code PreToolUse Hook: Copy-Paste Block Scripts

By Aoba Works · Published · Sources checked · 17 min read

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.

Which option is right for you?
SituationWhat to pick firstFirst page to openWhy
Beginner, first hookShort exit 2 scriptHooks guideFewest moving parts, and the block message is plain stderr
Daily work, recurring headless runsJSON deny, onFailure: "block", and permission deny rulesHeadless permissions templateA crashed hook blocks the call instead of letting it through
Budget firstLocal command hook, not a prompt hookToken usage guidePrompt hooks send input to a Claude model; a shell script doesn't
You need a hard filesystem or network boundarySandbox plus hookSandbox guidePattern matching can miss a command; OS-level limits still apply
Running on a repo you didn't write--bare, or hooks off for that runTrust 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:

What is the Claude Code PreToolUse hook and when does it run?
EventWhen it firesCan it stop the call?
PreToolUseBefore a tool call executesYes, on every matched call
PermissionRequestWhen a call needs a permission decisionYes, but through its decision object; exit 2 isn't honored
PostToolUseAfter a call succeedsNo, 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..."
}

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

What JSON does a PreToolUse hook receive on stdin?
ToolFields in tool_inputField to check for blocking
Bashcommand, description, timeout, run_in_backgroundcommand
Readfile_path, offset, limitfile_path
Editfile_path, old_string, new_string, replace_allfile_path
Writefile_path, contentfile_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."
  }
}

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

How do you allow, deny, or ask from a PreToolUse hook?
Exit codeStdoutWhat happens on PreToolUse
0EmptyThe hook makes no decision, so the normal permission flow applies
0JSON allowSkips the prompt, but deny and ask rules are still evaluated
0JSON denyBlocks the call and shows the reason to Claude
0JSON askPrompts the user, or denies in a -p run where nobody can answer
0JSON deferEnds the turn for later resume, in -p only, and only with a single tool call in the turn
2AnythingBlocks; the message is stderr, and even a JSON allow can't override it
1 or otherAnythingNon-blocking error; the call goes ahead with a hook error notice
0Invalid JSONNon-blocking parse or validation failure; the call goes ahead
NoneTimed outDoesn'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

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

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

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

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"
          }
        ]
      }
    ]
  }
}

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=$?"

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.

How do PreToolUse hooks behave in headless runs (claude -p)?
Decision or settingIn a claude -p run
deny or exit 2Blocks the call, and deny holds in every permission mode
askDenied when no one can answer, and Claude reads your reason in the tool result
allowApproves calls that would otherwise be denied, but not past deny rules
No decisionManual mode denies calls needing approval unless something approves them (v2.1.302 or later)
--bareHooks don't run, even if you pass them with --settings

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

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

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:

``bash echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh echo $? ``

  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.
Why does Claude Code show a PreToolUse hook error (Bash, Read, Edit)?
What you seeLikely causeFix
command not foundRelative or unresolved script pathUse ${CLAUDE_PROJECT_DIR} and "args": []
jq: command not foundjq isn't installed on this hostInstall it, which is easy to forget on a new VPS
Script never runsNot executablechmod +x the script
Failed with non-blocking status code:Exit code other than 0 or 2Exit 0 with JSON, or exit 2 to block
JSON parse messageStdout looked like JSON but wasn't validBuild output with jq rather than string concatenation
JSON validation messageValid JSON that fails the schemaCheck field names and nesting
No error, but the decision is ignoredProfile echo before the JSON, or permissionDecision at the top levelSearch the debug log for Hook output does not start with { or Hook JSON output had unrecognized keys
blocking because Git Bash wasn't foundWindows without Git BashFollow the linked Windows fix
Every tool blockedSyntax error in the scriptSee 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:

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.

How do you write matchers for Bash, Read, and Edit PreToolUse hooks?
MatcherHow it's evaluatedWhat it matches
BashExact stringOnly the Bash tool
`Read\Edit\Write`List of exact stringsThose three tools
*, empty, or omittedMatch allEvery tool, so a broken script can block everything
Edit.*Unanchored regexEdit and also NotebookEdit
^Edit$Anchored regexOnly Edit
mcp__memory__.*RegexAll tools on the memory MCP server
mcp__memoryExact stringNothing, 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

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

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.

How do you check Claude Code usage when agents run unattended?
Where you runWhat to useWhat it tells youLimit
Interactive session/usage (/cost and /stats are aliases)Session cost, plan limits, activity statsPlan breakdown covers this machine only
claude -p script--output-format jsontotal_cost_usd and per-model costA client-side estimate that can differ from your bill
Claude Console (API)Cost page and workspace spend limitsSpend reporting and a capAPI organizations only
Your own stackOpenTelemetry exportPer-user token and cost metrics in near real timeYou 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

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 and support the claims above.

Spotted something out of date or wrong? Tell us at shigoto.ai.note@gmail.com. Guides are re-checked against official sources on a rolling schedule.

How this guide was made: drafted with AI assistance from the official sources linked below, then passed an automated editorial check before publishing. Our process