Skip to content
Agentic Control Plane

Denied by PreToolUse hook from repo settings (hook errored)

David Crowe David Crowe · · Updated · 14 min read
claude-code hooks reference
Share X HN LinkedIn

This message means a PreToolUse hook from the project’s settings failed, and Claude Code blocked the tool call because of the failure. The hook did not choose to deny anything. It crashed, could not start, timed out, or printed output Claude Code could not read.

Fix the hook and the call goes through. Below: how to find it, why it failed, how to test it by hand, and how to decide whether a broken hook should block or let calls through.

What the message says

Read it in three parts.

  • Denied by PreToolUse hook. The block came from a PreToolUse hook, which runs before every matching tool call. It is not a permission rule, and it is not a prompt you declined.
  • From “repo settings”. The hook is defined in the project’s settings: .claude/settings.json (committed with the repo) or .claude/settings.local.json (your local override). It is not in your user settings, a plugin or a skill. The hooks reference lists every place a hook can live.
  • Hook errored. The hook failed instead of returning a verdict.

That last part matters, because by default a failing hook does not block. The official reference says a hook that exits with a code other than 0 or 2, “or fails in some other way, such as not starting or printing invalid JSON”, is a non-blocking error and “the action goes ahead, except for hooks whose failure blocks the action”. On PreToolUse you normally see a <hook name> hook error notice in the transcript and the tool still runs.

So when the error turns into a deny, one of two things is true:

  1. The hook sets "onFailure": "block". This field (Claude Code v2.1.295 or later) tells Claude Code to block the action when a command or http hook fails or times out. The notice reads failed; blocking because onFailure is "block", or timed out after a timeout.
  2. You are on Windows without Git Bash. There, a failed shell-form PreToolUse hook with onFailure unset can block the call. The notice says blocking because Git Bash wasn't found, so only exit code 0 or 2 counts as this hook's verdict.

If neither applies and the call was still blocked, check whether the hook exited 2 on purpose. Exit 2 is a deliberate block, shown as PreToolUse:Bash hook error: [<command>]: <stderr>. That is a policy verdict, not a bug.

Find the hook

Type /hooks inside Claude Code. It opens a read-only list of every configured hook, labeled by source: user settings, project settings, local settings, plugin, or session. Select the hook to see the exact command and the file it came from.

Then open that file. A typical project hook:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.sh",
            "timeout": 30,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

The matcher says which tools trigger it. Bash is an exact match. Edit|Write is a list. Anything with other characters is an unanchored regular expression, so Edit.* also matches NotebookEdit. If every tool call is failing, look for a matcher of "*", "", or none.

The notice shows only the first line of stderr. For the full stderr and the hook’s stdout, start a session with claude --debug (or type /debug in a fresh session) and read the debug log.

The usual causes

Each of these comes from the failure list in the hooks reference and the troubleshooting section of the hooks guide.

The script path is wrong. The notice carries the shell’s own message:

Failed with non-blocking status code: /bin/sh: /path/to/hook.sh: No such file or directory

With onFailure: "block" the same missing file becomes a deny. Relative paths break as soon as the session’s working directory moves. Use ${CLAUDE_PROJECT_DIR}, which points at the project root where the session started, and quote it in shell form. In a worktree it still points at the main checkout.

The script is not executable. The shell exits with a code like 127 and the hook never runs:

chmod +x .claude/hooks/check-command.sh

This bites after a fresh clone, or a checkout on a filesystem that drops the execute bit.

The interpreter is missing. jq: command not found, node: command not found, or a shebang that points at a binary your machine does not have. The author had it; you do not. Install it, or switch the hook to an interpreter you know is there. The guide suggests Python or Node.js when jq is missing.

The JSON on stdout is broken. If the hook exits 0 and prints JSON that does not parse or fails schema validation, that counts as a failure, and with onFailure: "block" it blocks. Build the payload with an encoder such as jq -n instead of string concatenation, so quotes and backslashes are escaped. Print nothing but the JSON object.

One related trap raises no error at all. An unconditional echo in your shell profile lands on stdout before the JSON, Claude Code reads the output as plain text, and your decision is silently ignored. Search the debug log for Hook output does not start with {.

The hook timed out. Command hooks default to 600 seconds. A hook that reaches its timeout is cancelled and its output discarded. Without onFailure: "block" the call continues through the normal permission flow. With it, the call is blocked. A hook that calls a network service needs a short timeout and a plan for when the service is down.

It exited 1. This catches people who write policy hooks. Exit 1 is the Unix failure code, but in Claude Code it is not a deny. The reference says: “Exit code 1 without valid JSON output is a non-blocking error unless the hook is one whose failure blocks the action… If your hook is meant to enforce a policy, use exit 2.” Under onFailure: "block", any exit other than 0 or 2 counts as a failure even if the hook printed permissionDecision: "allow".

Exit codes in one table

Exit code Stdout What Claude Code does on PreToolUse
0 Nothing or plain text No decision; the normal permission flow applies
0 Valid JSON The JSON decides: allow, deny, ask, or defer
0 Invalid JSON Failure (non-blocking unless the hook blocks on failure)
2 Anything Blocks the call; stderr is the reason the model sees
Other Anything else Failure (non-blocking unless the hook blocks on failure)

The full matrix is in the reference. Our Claude Code hooks reference covers the input and output contract in more depth.

Test the hook by hand

Feed it a tool call on stdin, the way Claude Code would, and check the exit code:

echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./.claude/hooks/check-command.sh
echo $?

That is the minimal shape from the hooks guide. A real PreToolUse input carries more fields. If your script reads them, test with the fuller shape:

{
  "session_id": "abc123",
  "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
  "cwd": "/home/user/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run test suite"
  },
  "tool_use_id": "toolu_01ABC123"
}

Save it as sample.json and run:

CLAUDE_PROJECT_DIR="$PWD" ./.claude/hooks/check-command.sh < sample.json
echo "exit: $?"

Set CLAUDE_PROJECT_DIR if the script uses it. Claude Code exports it to the hook process; your terminal does not. Check three things: the exit code is 0 or 2, stdout is empty or one JSON object, and the run finishes well inside the timeout.

To check the JSON, pipe stdout through jq .. If jq complains, Claude Code will too.

Bypass it for one run

To get unblocked while you fix the hook, turn hooks off for one run:

claude --settings '{"disableAllHooks": true}'

The docs say this “takes precedence over project and local settings”. What it costs:

  • It disables every hook, not just the broken one. The reference: “There is no way to disable an individual hook while keeping it in the configuration.”
  • It does not disable managed hooks. Only disableAllHooks set in managed settings can do that.
  • Setting "disableAllHooks": true in your user settings does not help here, because a false in the project’s .claude/settings.json overrides it.

If the project hook guards something real, such as writes to production config, you now run without that guard. That includes any other hook-based checks, ACP’s among them. Fixing the path, permissions or interpreter usually takes a minute, so do that first.

Decide whether a broken hook blocks

A failing hook should do what you chose, not whatever the failure happens to do. Two levers.

onFailure on the hook entry. "continue" lets the action go ahead when the hook fails. "block" stops it. Use block for hooks that enforce policy, so a broken gate does not become an open door. The reference spells out the risk of the default: “A policy hook with a wrong path or a crashing script then lets everything through.”

{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/policy.sh", "onFailure": "block", "timeout": 10 }

The script’s own error handling. Decide inside the script what an internal failure means, and turn it into a verdict instead of a crash. To fail closed:

#!/usr/bin/env bash
trap 'echo "policy hook failed; blocking" >&2; exit 2' ERR
set -e
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
case "$cmd" in
  *"rm -rf /"*) echo "Blocked: recursive delete of /" >&2; exit 2 ;;
esac
exit 0

To fail open, use exit 0 in the trap. Exit 2 is a verdict, not a failure, so a fail-closed script blocks even on a Claude Code version too old for onFailure. Pick one per hook and write it down. A formatting hook should fail open. A secrets or destructive-command hook should fail closed.

Limits

  • Permission rules. A deny rule in permissions is a different mechanism with different failure modes. A hook returning allow does not override a matching deny rule. If a deny rule is not firing, see Claude Code deny rules not working.
  • Exact wording. “Repo settings” and “hook errored” are what Claude Code prints, not terms the official docs define. The behavior above follows the documented semantics, so check /hooks to confirm the source on your version.
  • Hooks you cannot edit. A hook from managed settings is set by your organization. You can read it in /hooks but not disable it locally.
  • Plugin and skill hooks. They fail the same way, but the source label differs and the fix lives in the plugin or skill, not in .claude/settings.json.

ACP’s Claude Code plugin is itself a hook, so it has this question too. If ACP cannot reach its gateway, it fails open for interactive sessions with a loud UNGOVERNED banner, and fails closed for subagent, background and API sessions. It does not use onFailure. To make interactive sessions fail closed too, run echo closed > ~/.acp/failmode (plugin 0.30.1 and later). The safety floor (force-pushes, destructive SQL through a database client, curl | sh and deletes outside the project are held for approval; rm -rf of home or root is always blocked) is checked on your machine, so it still applies during an outage.

Frequently asked questions

What does denied by pretooluse hook from "repo settings" (hook errored) mean?

A PreToolUse hook defined in the project’s settings failed to produce a verdict, and Claude Code blocked the tool call because of that failure. The hook did not decide to deny anything; it crashed, could not start, timed out, or printed invalid JSON. Fix or bypass the hook and the call goes through.

Why does a crashing hook block the call when hook errors are usually non-blocking?

By default a failed PreToolUse hook is a non-blocking error and the call continues. A failure blocks only when the hook sets onFailure to block, or on Windows without Git Bash, where a failed shell-form PreToolUse hook can block the call.

How do I test a Claude Code PreToolUse hook by hand?
Pipe a sample tool call into the script and read the exit code: echo ‘{“tool_name”:”Bash”,”tool_input”:{“command”:”ls”}}’ ./my-hook.sh; echo $?. Exit 0 or 2 is a verdict. Anything else, or invalid JSON on stdout, is a failure.
How do I bypass a broken PreToolUse hook for one session?

Start Claude Code with claude –settings ‘{“disableAllHooks”: true}’. That turns hooks off for that run and takes precedence over project and local settings. It disables every hook, not just the broken one, and it cannot disable managed hooks.

Where to read more

Running Claude Code? One command adds a hook. Each tool call is recorded, the session's model calls are priced, and a force-push, destructive SQL or curl | sh waits for your OK in the session.

macOS · Linux · WSL
curl -fsSL https://agenticcontrolplane.com/install.sh | bash
Windows · PowerShell
irm https://agenticcontrolplane.com/install.ps1 | iex

What the installer writes  ·  Claude Code guide  ·  free up to 5 agents

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog