Claude Code hooks: how to block a command before it runs

Convika Team17 min read

  • hooks
  • claude-code
  • subagents
On this page

Add a PreToolUse hook matching Bash|PowerShell and have its script exit with code 2: Anthropic's hooks reference says “Exit with code 2 to block the action,” and Claude reads your stderr as the reason (read October 23, 2026, for Claude Code 2.1.308). By default, exit 1 lets the command run.

Anthropic's hooks reference, hooks guide, and permissions page, and the Claude Code changelog, were read on October 23, 2026. The docs pages carry no date of their own, so the versions here come from the changelog, with publish dates from the npm registry (UTC). The script below was tested the way the hooks guide's troubleshooting section tests one, by piping the documented hook input into it: 31 of 31 cases passed on macOS 26.5 with /bin/bash 3.2.57 and jq 1.7.1. It wasn't run inside a Claude Code session, or on Windows, for this post.

What blocks a command and what doesn't

A PreToolUse hook runs “Before a tool call executes” and “Can block it.” Whether it does depends on the exit code and on what the script prints.

What the hook doesIs the command blocked?Anthropic's words
Exits 2Yes“Exit with code 2 to block the action”
Exits 0 and prints "permissionDecision": "deny"Yes“"deny" prevents the tool call”
Exits 0 and prints "permissionDecision": "ask"You decide in a prompt“"ask" prompts the user to confirm”
Exits 0 and prints nothingNo. The normal permission flow decides“The hook can deny the call, but staying silent doesn't approve it”
Exits 0 and prints "permissionDecision": "allow"No, and the prompt is skipped, but deny and ask rules still apply“Deny and ask rules are still evaluated regardless of what the hook returns”
Exits 1, or any code but 0 and 2No, unless onFailure is "block"“Exit code 1 without valid JSON output is a non-blocking error”
Reaches its timeout, 600 seconds by defaultNo, unless onFailure is "block"“don't count on a stalled hook to act as a gate”
Can't start: wrong path, or not executableNo, unless onFailure is "block"“a mistyped path in settings.json means the hook never runs”
Prints a line before its JSON, such as a shell profile's echoNo, and onFailure doesn't change it: the output reads as plain text“Print nothing but the JSON object to stdout”
Denies while you run in bypassPermissions modeYes“blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions”
Exits 2 for a command an allow rule permitsYes“the block applies even when an allow rule would otherwise let the call proceed”

From Anthropic's hooks reference, hooks guide, and permissions page, read October 23, 2026.

A hook that blocks git push, tested

A deny rule is the first thing most people try, and Anthropic's permissions page shows where it stops. Bash(git push *) in your deny rules stops git push origin main, but not git -C . push origin main, git -c push.default=current push origin main, or git 'push' origin main. The page's advice: “To inspect the full command text with your own logic before it runs, use a PreToolUse hook.”

1. Save the script to .claude/hooks/block-git-push.sh. It needs jq, as the examples in Anthropic's docs do:

#!/bin/bash
# .claude/hooks/block-git-push.sh
# PreToolUse hook for the Bash and PowerShell tools. Exit 2 blocks the call; stderr is the reason Claude reads.
command -v jq >/dev/null || { echo "Blocked: block-git-push.sh needs jq on PATH" >&2; exit 2; }

cmd=$(jq -r '.tool_input.command // empty')
cmd=${cmd//$'\\\n'/ }   # join lines continued with a backslash
cmd=${cmd//$'\n'/ }     # read a multi-line command as one line

# git, git.exe, or /usr/bin/git, then any global options (-C dir, -c key=value, --git-dir dir, ...),
# then push or subtree push
if grep -Eq '(^|[^[:alnum:]_.-])git(\.exe)?([[:space:]]+(-[Cc][[:space:]]+("[^"]*"|[^[:space:]]+)|--(git-dir|work-tree|namespace)[[:space:]]+[^[:space:]]+|--?[[:alnum:]-]+(=[^[:space:]]+)?))*[[:space:]]+(subtree[[:space:]]+)?push([^[:alnum:]_.-]|$)' <<<"$cmd"; then
  echo "Blocked: git push isn't allowed from Claude Code in this repo. Commit, then tell the user the branch is ready to push." >&2
  exit 2
fi
exit 0

2. Make it executable:

chmod +x .claude/hooks/block-git-push.sh

3. Register it in .claude/settings.json, which you can commit so everyone working in the repository gets it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|PowerShell",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-git-push.sh",
            "args": [],
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}
  • "matcher": "Bash|PowerShell" sends the commands of both shell tools to the script, as Anthropic advises for “hooks that inspect shell commands.” On Windows, Claude Code's PowerShell tool is on automatically without Git Bash, and by default with Git Bash for claude.ai and Console accounts. There “Claude treats PowerShell as the primary shell and routes shell commands through it,” and “A hook that matches only Bash never fires there.” The PowerShell tool hands the hook the same tool_input.command. On macOS and Linux the tool is opt-in, so the extra name changes nothing.
  • "args": [] runs the script directly, with no shell in between. Anthropic says to “Set args whenever the hook references a path placeholder” such as ${CLAUDE_PROJECT_DIR}. It needs Claude Code 2.1.139 or later (published May 11, 2026).
  • "onFailure": "block" blocks the command when the script can't start, crashes, or times out, instead of letting it through. It needs 2.1.295 or later (published October 8, 2026).

On Windows, change one line. There, “exec form requires command to resolve to a real executable such as a .exe,” so a .sh file can't start with "args": []. With onFailure set to "block", every shell command is then blocked, push or not. Drop "args": [] and write the command in shell form, as Anthropic's hooks guide does: "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-git-push.sh". Shell form runs through Git Bash, so the script needs Git for Windows and jq. The Windows setup wasn't tested for this post.

4. Test it before you trust it. Pipe in the JSON Claude Code would send, as the hooks guide does:

echo '{"tool_name":"Bash","tool_input":{"command":"git -C ../app push origin main"}}' | .claude/hooks/block-git-push.sh
echo $?
Blocked: git push isn't allowed from Claude Code in this repo. Commit, then tell the user the branch is ready to push.
2

5. Check it in a session. Run /hooks and look for the script under PreToolUse. Claude Code picks up edits to settings files on its own; if the hook doesn't appear after a few seconds, restart the session.

In the 31-case test, the script blocked git push, git push --force-with-lease, npm test && git push, git -C ../app push, git -C "my app" push, git -c push.default=current push origin main, git --git-dir .git push, git --work-tree . push, git --no-pager push, /usr/bin/git push origin main, ./git push, git.exe push origin main, git subtree push --prefix dist origin gh-pages, FOO=bar git push, bash -c "git push", a push on the second line of a command, and every call when jq was missing. It let through git status, git stash push -m wip, git config push.default simple, git remote set-url --push origin x, git subtree pull --prefix dist origin gh-pages, git commit -m "fix the pushed-down footer", git log --oneline -5, gh pr view 12, echo pushed, and npm run push-docs. Each command was sent once as a Bash call and once as a PowerShell call.

It doesn't catch git 'push' origin main, a path with a space in single quotes after -C or in any quotes after --git-dir or --work-tree, a git path in quotes such as & "C:\Program Files\Git\cmd\git.exe" push, a push inside a script or a git alias, or a command built from variables. It also blocks text that only mentions a push inside quotes, such as echo "never git push".

The message on stderr is the reason Claude reads, so it says what to do next. Claude receives it “prefixed with the event name, the tool name, and the hook's command,” as in PreToolUse:Bash hook error: [...]: Blocked: ....

Exit code 2 or a JSON decision

“Exit codes only let you block or stay silent, but JSON output gives you finer-grained control,” in the reference's words. For JSON, the script exits 0 and prints one object, with nothing else on stdout:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Subagents commit but never push. Report the branch to the main session."
  }
}
  • permissionDecision is "allow", "deny", "ask", or "defer". "defer" works only in non-interactive runs with -p.
  • permissionDecisionReason goes to Claude for "deny" and to you in the prompt for "ask".
  • updatedInput rewrites the tool's input before it runs. It replaces the whole input, and since 2.1.300 (October 14, 2026) Claude Code validates it like Claude's own: unknown keys are dropped, and input the tool rejects denies the call.
  • additionalContext adds a note for Claude next to the tool result.

Keep permissionDecision inside hookSpecificOutput. At the top level, “the JSON still parses, and Claude Code ignores the misplaced fields without reporting an error.”

When several hooks answer the same call, “precedence is deny > defer > ask > allow.” Pick one style per hook: if a hook exits 2 and also prints JSON, the exit code wins, and “Even a permissionDecision of "allow" doesn't let the action through.” The old top-level decision and reason fields are deprecated for PreToolUse.

Matchers: which tool calls reach the hook

The matcher is tested against the tool's name, and how it's read depends on its characters:

  • "*", "", or no matcher runs the hook on every tool call.
  • Letters, digits, _, -, spaces, ,, and | only: exact names. Bash matches only the Bash tool. Edit|Write and Edit, Write match either.
  • Any other character makes it a JavaScript regular expression, unanchored. Edit.* matches NotebookEdit too, so write ^Edit$ for one tool.
  • Matchers are case-sensitive. For shell commands, Anthropic's advice is to “Match Bash|PowerShell in hooks that inspect shell commands,” because on Windows most of them go through the PowerShell tool.

PreToolUse sees “any tool name except EndConversation”: built-in tools such as Bash, PowerShell, Edit, Write, Read, Agent, and WebFetch, and every MCP tool. MCP tools are named mcp__<server>__<tool>, and mcp__memory__.* matches every tool from a server called memory. Anthropic's warning: “The .* is required,” because mcp__memory alone is an exact name that matches no tool. Tools from an MCP server that a plugin bundles are named mcp__plugin_<plugin-name>_<server-name>__<tool>.

A handler can narrow further with if, written in permission rule syntax such as "Bash(git *)". When Claude Code can't tell which commands a Bash input runs, it runs the hook anyway, and the reference adds: “Because the if filter is best-effort, use the permission system rather than a hook to enforce a hard allow or deny.” The script above skips if and decides in its own code.

Ask before an MCP tool changes something live

"ask" puts the call in front of you with your reason in the prompt, labelled [settings] for a hook from a settings file. In auto mode, a hook's "ask" still forces the prompt: “the classifier can still deny the tool call, but it can't approve the call silently.”

Convika's MCP server is one example. It saves pages that Claude Code writes, and marks 4 tools as changing what anyone sees at a live URL: publish_site, rollback_site, unpublish_site, and disable_domain_route. Added with claude mcp add under the name convika, they reach hooks as mcp__convika__publish_site and so on. Convika's Claude Code plugin is named convika and bundles the server under the key convika, so by the reference's naming rule its tools are mcp__plugin_convika_convika__publish_site. This matcher covers both, and was checked with JavaScript's RegExp against both names:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^mcp__(plugin_convika_)?convika__(publish_site|rollback_site|unpublish_site|disable_domain_route)$",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/ask-before-live.sh",
            "args": []
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/ask-before-live.sh
# PreToolUse hook for MCP tools that change a live page: always ask the person first.
command -v jq >/dev/null || { echo "Blocked: ask-before-live.sh needs jq on PATH" >&2; exit 2; }
tool=$(jq -r '.tool_name')
jq -n --arg tool "$tool" '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "ask",
    permissionDecisionReason: "\($tool) changes what visitors see. Check the preview link first."
  }
}'

Without a script, the same 4 tool names in permissions.ask give the same prompt without the reason line. Setup for the server itself is in how to deploy a landing page from Claude Code.

Block it for subagents only

Settings-file hooks also run inside subagents: “When a subagent calls a tool, tool events such as PreToolUse and PostToolUse fire the same configured hooks as in the main conversation.” The input then carries agent_id, the “Unique identifier for the subagent or in-process teammate the hook fires in.” A call from the main session has no agent_id.

Test agent_id, not agent_type. agent_type is also “Present when the session uses --agent,” so it shows up in a main session too. This version answers with the JSON decision from above, for subagents only:

#!/bin/bash
# .claude/hooks/block-git-push-json.sh
# Same check, answered with a JSON decision instead of exit 2. Subagents only.
command -v jq >/dev/null || { echo "Blocked: block-git-push-json.sh needs jq on PATH" >&2; exit 2; }
input=$(cat)
[[ -z "$(jq -r '.agent_id // empty' <<<"$input")" ]] && exit 0   # main session: no decision

cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
cmd=${cmd//$'\\\n'/ }
cmd=${cmd//$'\n'/ }

if grep -Eq '(^|[^[:alnum:]_.-])git(\.exe)?([[:space:]]+(-[Cc][[:space:]]+("[^"]*"|[^[:space:]]+)|--(git-dir|work-tree|namespace)[[:space:]]+[^[:space:]]+|--?[[:alnum:]-]+(=[^[:space:]]+)?))*[[:space:]]+(subtree[[:space:]]+)?push([^[:alnum:]_.-]|$)' <<<"$cmd"; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Subagents commit but never push. Report the branch to the main session."
    }
  }'
fi
exit 0

Piped the same way, it printed the deny object for every push of the 31-case test sent as a subagent's call, and printed nothing for the main session's git -C ../app push origin main or for a subagent's git stash push -m wip. Without jq it exits 2 too: a check that exits 0 when it can't read its input would let every push through without a word. Subagent limits, nesting, and config for Claude Code and Codex are in Codex vs Claude Code subagents.

Hooks in a subagent's own front matter run “only while that subagent is running,” and a project subagent's front matter hooks wait until you accept the workspace trust dialog. A guard meant for every subagent belongs in a settings file.

Where hooks live, and when they don't run

WhereApplies toShared
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes, if you commit it
.claude/settings.local.jsonOne projectNo
Managed policy settingsThe organizationYes, set by an admin
A plugin's hooks/hooks.jsonWhile the plugin is enabledYes
A skill's front matterThe rest of the session after the skill is invokedYes
A subagent's front matterWhile that subagent runsYes

Hooks from every level add up rather than replace each other, and matching hooks run in parallel. Where skills live, and what their front matter can hold in Claude Code and Codex, is in Codex skills vs Claude Code skills. A few rules decide whether hooks run at all:

  • Workspace trust. An interactive session holds back hooks from every settings file, your own ~/.claude/settings.json included, until you accept the trust dialog for the folder. A -p or SDK session treats the folder as trusted, so “hooks committed in a repository's .claude/settings.json run in a folder you've never trusted.”
  • Turning hooks off. "disableAllHooks": true turns off every hook. “There is no way to disable an individual hook while keeping it in the configuration.”
  • Organization lock. With allowManagedHooksOnly in managed settings, hooks in your user, project, and local settings and plugin hooks don't run. Hooks from plugins force-enabled in managed settings still do.
  • Cloud sessions don't read your local ~/.claude/settings.json.

What a PreToolUse hook can't do

  • Read intent. The hook sees the text of a command. A push hidden in a script, an alias, or a variable passes; a quoted sentence that names a push gets blocked. While this post was being written, the push guard in Convika's own repository (.claude/hooks/guard-git.sh) blocked a shell command that only wrote the words into a test file. For enforcement “that doesn't depend on the command text,” Anthropic points to sandboxing. OpenAI says the same about Codex hooks: “Treat tool hooks as a useful guardrail, not a complete enforcement boundary.”
  • See files you @-mention. Files referenced with @ in your prompt “are added without any tool call,” so no PreToolUse hook fires, “including hooks matching Read.” Block those paths with a Read deny rule, or set CLAUDE_CODE_DISABLE_FILE_MENTIONS=1 (v2.1.299 or later).
  • Run a tool or a slash command. “Command hooks communicate through stdout, stderr, and exit codes only. They can't trigger / commands or tool calls.”
  • Undo. A PostToolUse hook runs after the tool and “can't undo actions.” PreToolUse is the last point where you can stop one.
  • Loosen your rules. A hook's "allow" “doesn't bypass deny rules from settings.”
  • Hold against every mod. A mod is a plugin that registers hooks as JavaScript functions inside Claude Code. “If your machine has no managed settings and your session doesn't use /login credentials for a Team or Enterprise plan, a mod can approve a call your PreToolUse hook blocked.”
  • Run with fewer rights than you. “Command hooks execute shell commands with your full user permissions.”

Hook changes in recent Claude Code releases

From Anthropic's changelog, with npm publish dates (UTC):

  • 2.1.304, October 19, 2026: “Fixed a PreToolUse hook's deny reason being replaced by a timeout error when an Agent SDK callback hook on the same tool call timed out”
  • 2.1.300, October 14, 2026: updatedInput from PreToolUse and PermissionRequest hooks “is now validated like Claude's own input”
  • 2.1.299, October 14, 2026: on Windows, “Fixed PreToolUse and PermissionRequest hooks that set no shell failing open when Git for Windows isn't installed; a guard that fails now blocks the call”; also “Fixed plan mode running a write (an MCP tool's, a file's, a shell command's) that a PreToolUse hook or a mod's tool.check hook allowed”
  • 2.1.296, October 9, 2026: “Fixed managed-settings PreToolUse hooks that deny a tool call with "continue": false [...] refusing the call but not ending the turn”
  • 2.1.295, October 8, 2026: “Added onFailure: "block" for command and HTTP hooks: a hook that can't start, times out, or exits with an unexpected code blocks the action instead of letting it through”
  • 2.1.294, October 8, 2026: “Fixed prompt and agent hooks written as instructions (such as "Block commands that...") allowing what they should block”
  • 2.1.292, October 6, 2026: “Security: Fixed PreToolUse hook approvals and auto mode bypassing the permission prompt for file reads from network (UNC) paths”

Hooks themselves shipped in 1.0.38, published June 30, 2025.

Pre-command hooks in other coding agents

Codex, Gemini CLI, and Cursor have a hook that runs before a shell command. Exit code 2 blocks in all 4 tools. What happens when the hook itself fails differs:

ToolHook before a shell commandHow it blocksWhen the hook failsConfig file
Claude CodePreToolUse, matcher Bash|PowerShellExit 2, or permissionDecision: "deny"The command runs, unless "onFailure": "block".claude/settings.json
CodexPreToolUse, matcher BashExit 2, or permissionDecision: "deny"For unsupported output, Codex “reports the error, and continues the tool call”.codex/hooks.json, or [hooks] in config.toml; each hook must be trusted in /hooks
Gemini CLIBeforeToolExit 2, or {"decision": "deny"} on exit 0Other exit codes: “A warning is shown, but the interaction proceeds”.gemini/settings.json
CursorbeforeShellExecutionExit 2, or permission: "deny"“Hook failed, action proceeds (fail-open by default)”; failClosed: true blocks.cursor/hooks.json

From each vendor's docs, read October 23, 2026: OpenAI's Codex hooks page, Gemini CLI's hooks page, and Cursor's hooks page. Cursor's page also says it “supports loading hooks from third-party tools like Claude Code.”

Frequently asked questions

How do I block a command in Claude Code before it runs?

Register a PreToolUse hook with "matcher": "Bash|PowerShell" in .claude/settings.json, and have its script exit 2 with the reason on stderr when the command matches. Claude Code blocks the call and shows Claude the reason. Add "onFailure": "block" (v2.1.295 or later) so a script that can't start or times out blocks too, and test the script by piping JSON into it before you rely on it.

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

No. Exit 1 is a non-blocking error: the command runs and the transcript shows a hook error notice. Anthropic's reference says “If your hook is meant to enforce a policy, use exit 2.” The exception is a hook with "onFailure": "block", where any exit code other than 0 or 2 counts as a failure and blocks the call.

Can a permissions deny rule block git push instead of a hook?

Yes, for the form Claude usually writes. Bash(git push *) in your deny rules stops git push origin main, and deny rules also apply inside && chains and $(). Anthropic's permissions page shows it doesn't stop git -C . push origin main, git -c push.default=current push origin main, or git 'push' origin main. A hook reads the whole command with your own logic, and the two can be used together.

Do Claude Code hooks run inside subagents?

Yes. Hooks from settings files, managed settings, and plugins fire for a subagent's tool calls the same way as in the main conversation. The input carries agent_id for a subagent or in-process teammate, so a hook can block a command for subagents and allow it in the main session. Hooks in a subagent's own front matter run only while that subagent runs.

Does a hook still block in bypassPermissions mode?

Yes. Anthropic's hooks guide says a hook that returns "deny" “blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions,” because PreToolUse hooks fire before any permission-mode check. A hook that exits 2 also blocks a command an allow rule would have let through.

Does a crashed or timed-out hook block the command?

No, not by default. A hook that can't start, exits with an unexpected code, or reaches its timeout (600 seconds for command hooks) is a non-blocking error, and the command runs. Set "onFailure": "block" on the hook (v2.1.295 or later) and those failures block the call instead. Plain-text output is not a failure, even with that setting.

Can a PreToolUse hook rewrite a command instead of blocking it?

Yes. Return permissionDecision: "allow" or "ask" with updatedInput, which replaces the tool's whole input before it runs. Claude Code checks permission rules against the input your hook returns, and since 2.1.300 it validates that input like Claude's own. Anthropic advises against having more than one hook rewrite the same tool's input, because the last one to finish wins.

Can a hook stop Claude from reading a file?

Yes, when Claude reads it with a tool: a PreToolUse hook with the matcher Read sees tool_input.file_path, always as an absolute path. Files you @-mention in your prompt are inserted without a tool call, so no hook fires for them. Block those paths with a Read deny rule, or set CLAUDE_CODE_DISABLE_FILE_MENTIONS=1.

Why isn't my Claude Code hook running?

Run /hooks first: if the hook isn't listed under PreToolUse, check that the settings file is valid JSON with no comments or trailing commas, that it sits in .claude/settings.json or ~/.claude/settings.json, and that you accepted the folder's trust dialog. If it's listed but never fires, check the matcher's spelling and case, and make the script executable with chmod +x. A No such file or directory notice means the script path is wrong.

Do Claude Code hooks work on Windows?

Yes, with two changes to this recipe: drop "args": [] and write the command in shell form, and install Git for Windows so that shell form runs the .sh script through Git Bash. On Windows “exec form requires command to resolve to a real executable such as a .exe,” so a .sh file can't start the other way. Keep the Bash|PowerShell matcher, because on Windows Claude usually runs shell commands through the PowerShell tool, and “A hook that matches only Bash never fires there.” Without Git for Windows, Claude Code has no Bash tool and runs hooks in PowerShell, so rewrite the guard as a PowerShell script and set "shell": "powershell" and "onFailure": "block" on it. This post's script wasn't tested on Windows.

Sources