jared hebb ~ %

Claude Code hooks

The PreToolUse hook in Claude Code

PreToolUse fires before a tool call runs and can allow, deny, or rewrite it. Fields, exit codes, and a working example that blocks an unsafe deploy.

This is the hook people reach for first, and often the only one they need. If the goal is stopping something before it happens rather than reacting to it afterward, PreToolUse is the whole answer. PostToolUse cannot undo a command that already ran, so anything genuinely irreversible has to be caught here or not at all.

When it fires
Before a tool call runs, while it can still be stopped.
Can it block?
Yes. This is the only event that can stop a tool call before it has any effect.
Matcher
Yes, on tool_name
Matcher examples
Bash Edit|Write mcp__memory__.* ^Bash$

What it receives

The event arrives as JSON on standard input. These are the fields worth reading.

FieldTypeWhat it is
tool_namestringThe tool about to run, such as Bash or Edit.
tool_inputobjectThe tool's own arguments. For Bash that is command; for Edit it is file_path and the strings.
permission_modestringOne of default, plan, acceptEdits, auto, dontAsk, bypassPermissions.
effortobjectHas a level of low, medium, high, xhigh, or max.
session_idstringStable for the session. Useful as a key if the hook keeps state.
prompt_idstringIdentifies the turn the tool call belongs to.
cwdstringThe working directory at the time of the call.
transcript_pathstringPath to the session transcript on disk.

Exit codes

The exit code is the decision. Anything the script writes to standard error on a blocking exit is what Claude gets told.

ExitWhat happens
0Proceed. If the script printed JSON on stdout, Claude Code reads it for a decision.
2Block the tool call. Whatever the script wrote to stderr is shown to Claude, so write the reason there.
otherTreated as a broken hook, not a decision. The tool call proceeds and the first line of stderr lands in the transcript.

What it can return

permissionDecision takes allow to skip the permission prompt, deny to block the call, ask to force the prompt even when a rule would have allowed it, and defer to fall back to the normal permission flow. updatedInput is the interesting one: it rewrites the tool's arguments before the tool runs, so a hook can correct a call instead of refusing it.

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "why",
    "updatedInput": { },
    "additionalContext": "string"
  },
  "systemMessage": "shown to the user",
  "continue": true,
  "suppressOutput": false
}

Blocking a deploy that would publish files nobody looked at

On 2026-07-16 a session ran wrangler pages deploy on this very site using a listing of the folder it had read half an hour earlier. Another session had dropped untracked mock files in there since. All of it went live. The problem was not the deploy command, it was publishing an implicit set of files from a stale picture of the folder. A PreToolUse hook on Bash matches the publish commands, reads the folder right then, and returns a permission decision of "ask" with the current contents as the reason. The person who has to approve it sees the real file list at the moment of publishing, not a summary written earlier. The worst case is one extra keypress, never a permanent block.

In settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|PowerShell",
        "hooks": [
          {
            "type": "command",
            "command": "node ~/.claude/hooks/deploy-recheck.mjs",
            "timeout": 30,
            "statusMessage": "Re-checking what is about to be published"
          }
        ]
      }
    ]
  }
}

The script

const fs = require('fs');
const path = require('path');
const input = JSON.parse(fs.readFileSync(0, 'utf8')) || {};
const cmd = String((input.tool_input || {}).command || '');

// Strip a runner prefix so npx and sudo forms match too.
const words = cmd.trim().replace(/^(?:sudo|npx|bunx|pnpm dlx)\s+/, '').split(/\s+/);
if (words[0] !== 'wrangler' || !words.includes('deploy')) process.exit(0);

// The directory is the first argument after deploy that is not a flag.
// No argument at all means the current one, which is the risky form.
const args = words.slice(words.indexOf('deploy') + 1).filter(w => !w.startsWith('-'));
const dir = path.resolve(input.cwd || process.cwd(), (args[0] || '.').replace(/^["']|["']$/g, ''));

let listing;
try {
  listing = fs.readdirSync(dir, { recursive: true, withFileTypes: true })
    .filter(d => d.isFile())
    .map(d => path.relative(dir, path.join(d.parentPath || d.path, d.name)))
    .sort()
    .join('\n');
} catch {
  process.exit(0); // no such folder: the deploy will say so better than we can
}

// "ask" always prompts. The reason goes to the person, not to Claude.
console.log(JSON.stringify({
  hookSpecificOutput: {
    hookEventName: 'PreToolUse',
    permissionDecision: 'ask',
    permissionDecisionReason: 'About to publish everything in ' + dir + ':\n' + listing
  }
}));
process.exit(0);

Exit 0 whenever the hook does not care, and read the folder recursively. The 2026-07-16 incident published a vendor directory, and a listing one level deep would not have shown it. A hook that throws on unexpected input turns every tool call into a coin flip, so the readdir sits in a try block.

The catch

A matcher of Bash matches the tool, not the command. Every Bash call your session makes will run this script, so make the uninteresting path exit 0 immediately. Also worth knowing: matcher strings made only of letters, digits, underscores, hyphens, spaces, commas and pipes are treated as exact matches, and anything else is treated as an unanchored regular expression.