# AI Agent Hooks That Fail Open: Detect and Fix Them

> A coding-agent hook that crashes, times out or exits 1 is skipped, and the agent carries on. Your guard or token filter stops working without a sound.

Source: https://tokenade.net/en/articles/agent-hooks-fail-open · Updated: 2026-10-21

When a coding-agent hook crashes, times out or exits with the wrong code, the agent treats it as a non-blocking error and carries on as if the hook did not exist. In Claude Code that is the documented default, and Codex and Cursor behave the same way out of the box. A security guard stops guarding, a token-saving filter stops filtering, and the session keeps running.

The only trace is a one-line notice in the transcript. This page is part of the [Claude Code hooks](https://tokenade.net/en/articles/claude-code-hooks) series; that article maps what each hook event costs in tokens, and this one covers what happens when the hook itself breaks.

Behaviour below was read in the [Claude Code hooks reference](https://code.claude.com/docs/en/hooks), the [Codex hooks docs](https://developers.openai.com/codex/hooks) and the [Cursor hooks docs](https://cursor.com/docs/hooks) on 2026-10-15.

## What does "fail open" mean for an agent hook?

A hook fails open when its failure lets the action through. The opposite, failing closed, blocks the action whenever the hook cannot give an answer.

Claude Code's reference puts it plainly: "On most events, when a hook fails or times out, Claude Code still carries out the action, so a policy hook with a wrong path or a crashing script lets everything through" ([reference, block the action when a hook fails](https://code.claude.com/docs/en/hooks#block-the-action-when-a-hook-fails)).

That default is reasonable for most hooks. A broken desktop notification should not freeze your agent. It hurts in exactly two kinds of hook:

- **Guards**: hooks that deny `rm -rf`, block writes outside `src/`, or stop secrets from leaving the machine.
- **Filters you rely on for budget**: hooks that rewrite or compact tool output so a long session stays affordable.

When a guard fails open, you lose the protection. When a filter fails open, you lose nothing visible, and the bill quietly goes up.

## How does each failure mode behave in Claude Code?

Every way a hook can break, apart from a clean exit 2 or a valid JSON decision, ends as a "non-blocking error": the action goes ahead, and on events such as `PreToolUse` you see a `<hook name> hook error` notice ([reference, exit code output](https://code.claude.com/docs/en/hooks#exit-code-output)).

| Failure | What Claude Code does by default | What you see |
| --- | --- | --- |
| Exit 1 (or any code but 0 and 2), no valid JSON | Non-blocking error, action proceeds | Notice with the first line of stderr |
| Exit 1 with valid JSON on stdout | Success: the JSON fields decide | Nothing unusual |
| JSON that does not parse or fails schema validation, exit 0 | Non-blocking error, action proceeds | Notice with the parse or validation message |
| Script path missing or not executable (shell exits 127) | Non-blocking error, action proceeds | `Failed with non-blocking status code: /bin/sh: ...: No such file or directory` |
| `command`, `http` or `mcp_tool` hook reaches its timeout on `PreToolUse` | Hook canceled, output discarded, call continues through the normal permission flow | `timed out` in the debug log |
| HTTP hook gets a non-2xx status or cannot connect | Non-blocking error, execution continues | Notice |

### Exit 1 is not a block

The reference carries a warning box for this: "Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use `exit 2`."

Every Unix habit says 1 means "no". In a Claude Code hook, 1 means "the hook had a problem, carry on". Exit 2 is the block; on `PreToolUse` it stops the tool call and your stderr becomes the reason.

### Anything on stdout before the JSON

Claude Code parses stdout as JSON only "when the whole output is one JSON object with nothing around it but whitespace". A shell profile that prints a greeting, a `set -x` left in, or a debug `echo` turns your decision into plain text or a parse failure.

A parse failure is a non-blocking error, so a `permissionDecision: "deny"` with a banner above it denies nothing. On the events that add plain stdout to context, Claude Code also drops stdout it failed to parse.

### A missing binary or a mistyped path

When the script path does not exist, the shell exits 127 and you get a non-blocking notice. The reference's own advice: "watch for this notice on its first run, because a mistyped path in `settings.json` means the hook never runs."

The sneakier version is a dependency inside the script. The reference's Bash examples use `jq` and tell you to make sure it is on your `PATH`. If `jq` is missing, a typical guard extracts an empty command, matches nothing and exits 0. That is not even a hook error: the script succeeded at doing nothing.

Hooks run "with Claude Code's environment", meaning whatever environment launched Claude Code. A binary that lives in a directory your interactive shell adds to `PATH` may not be there when the agent is started by an IDE, a cron job or a CI runner.

### Timeouts

The default timeout is 600 seconds for `command`, `http` and `mcp_tool` hooks, lowered to 30 on `UserPromptSubmit`, `PreModelSwitch` and `PostModelSwitch` ([reference, common fields](https://code.claude.com/docs/en/hooks#common-fields)). A hook that hangs on `PreToolUse` therefore stalls the call for up to ten minutes, then lets it through.

The reference is explicit: a timed-out `PreToolUse` command hook "doesn't block the tool call... so don't count on a stalled hook to act as a gate." `PreModelSwitch` is the exception: a hook canceled at its timeout blocks the switch. Agent SDK callback hooks that time out on `PreToolUse` also block.

> Tokenade installs its token-cutting hooks for you, and `tokenade health` tells you whether they are actually running.
>
> Install: `npm install -g @tokenade/cli && tokenade install` — or let your agent do it: https://tokenade.net/install.md

## Do Codex and Cursor fail open too?

Yes, both fail open by default, and Cursor gives you a switch to change it.

**Codex.** The Codex docs say an unsupported field in a `PreToolUse` result, such as `continue: false` or `permissionDecision: "ask"`, makes Codex mark the hook run as failed, report the error, "and continues the tool call."

For managed remote MCP hooks, the same page warns that "a `PreToolUse` callback error, timeout, or malformed response can fail the hook without blocking the tool." Exit 2 with a reason on stderr blocks; the default timeout is 600 seconds for most hooks ([Codex hooks](https://developers.openai.com/codex/hooks)).

The docs list no fail-closed option for local command hooks. An open request, [openai/codex#41979](https://github.com/openai/codex/issues/41979) (opened 2026-09-01), describes the current behaviour as: a timeout, spawn failure, crash or malformed output "is treated as a failed hook with `should_block=false`, so the tool is dispatched", and asks for an opt-in block mode.

Codex adds one silent-off mode of its own: non-managed hooks must be reviewed and trusted, and a new or changed hook is "skipped until trusted." Codex prints a startup warning pointing to `/hooks` when that happens, so read it after every edit to `hooks.json`.

**Cursor.** For `beforeShellExecution` and `beforeMCPExecution`, "crashes, timeouts, and non-zero exit codes other than `2` fail open by default: Cursor logs the failure and allows the action through."

Setting `failClosed: true` on a hook makes "hook failures (crash, timeout, non-zero exit code, no output) block the action instead." Cursor's permission hooks already block on invalid JSON even without it ([Cursor hooks](https://cursor.com/docs/hooks)).

## How do you find out a hook has failed?

You find out by looking for it on purpose, because the agent will not tell the model, and often not you. Four checks, cheapest first.

1. **Read the notice on the first run.** In Claude Code, a non-blocking error leaves a `<hook name> hook error` line in the transcript. It is short and easy to scroll past, but it names the failure.
2. **Open `/hooks`.** It lists every configured hook with its source (user, project, local settings, plugin or session) and the exact command it runs. A hook that is not in this list never runs, whatever your settings file says.
3. **Turn on the debug log.** Start with `claude --debug-file <path>`, or `claude --debug` and read `~/.claude/debug/<session-id>.txt` ([reference, debug hooks](https://code.claude.com/docs/en/hooks#debug-hooks)). From v2.1.296, each command hook on tool events leaves a `Hooks:` line ending in `finished with status 0 (31ms)`, `timed out after <N>ms` or `failed to start`. Grep for the last two.
4. **Run a canary.** Make your guard deny one harmless sentinel, for example any Bash command containing `HOOK_CANARY`. At the start of a long or [overnight session](https://tokenade.net/en/articles/overnight-agent-budget-safety), ask the agent to run `echo HOOK_CANARY`. If the word comes back, the guard is down.

For filters, the canary is the size of the output. Run one noisy command you know, such as your test suite, and check what the model received. If it got 4,000 lines instead of 40, the filter is not loading. [Measuring agent token usage](https://tokenade.net/en/articles/measure-agent-token-usage) shows where those numbers live in the transcript.

## How do you make a hook fail safely?

Decide, per hook, whether failure should block or pass, then make the configuration say so. Claude Code now has a field for it.

**Set `onFailure: "block"` on guards.** On a `command` or `http` hook, `"onFailure": "block"` turns any failure into what exit 2 does on that event. It needs Claude Code v2.1.295 or later. The reference counts as failures: a hook that cannot start, an exit code other than 0 or 2 (even with JSON that allows), an HTTP error, a timeout, and invalid JSON.

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}
```

The reference's own test is the right one: delete the script, ask for `ls`, and expect `failed; blocking because onFailure is "block"`. Note the limits. The field has no effect on `Stop`, `SubagentStop`, `TaskCompleted` and `TeammateIdle`, or on `async` hooks.

**Block with exit 2 or JSON, never exit 1.** Exit 0 with a JSON `permissionDecision`, or exit 2 with a reason on stderr. Anything else is a non-blocking error.

**Keep stdout clean.** Print only the JSON object. Send debug lines to stderr, which Claude never sees from a hook that exits 0.

**Use exec form for scripts.** With `args` set, Claude Code resolves `command` on `PATH` and spawns it with no shell, so quoting around `${CLAUDE_PROJECT_DIR}` cannot break the path. Use absolute paths, or check that every binary your script calls resolves in the agent's environment.

**Make the script fail loudly.** In Bash, `set -euo pipefail` turns a missing `jq` into a non-zero exit instead of an empty variable. Combined with `onFailure: "block"`, that missing dependency now blocks instead of approving.

**Set a timeout you can live with.** Ten minutes is a long time for a guard to think. If your check normally takes 200 ms, a `timeout` of 5 seconds plus `onFailure: "block"` means a hung check costs you one blocked call, not ten minutes and an unchecked one.

On Cursor, use `failClosed: true`. On Codex, with no such switch, the canary is your safety net.

## Should a token-saving filter fail open or closed?

A filter should fail open, and you should monitor it like a guard. Those two statements are not in conflict.

If a compaction hook breaks and fails closed, every Bash call is blocked and the agent cannot work. That is worse than paying for a noisy log. Failing open keeps the work going, with the cost going back to what it was before the filter.

The cost is real on long sessions. On the [THOL benchmark](https://tokenade.net/en/thol), Tokenade cuts session cost by 39% on long sessions ([methodology](https://tokenade.net/en/thol)). A filter that silently stops loading gives that back without a single error on screen.

So the rule I use:

- **Guards**: `onFailure: "block"` or `failClosed: true`, a short timeout, a canary at session start.
- **Filters**: fail open, plus a check that they are actually running. For Tokenade, `tokenade health` reports whether its hooks are installed and names the fix when they are not.
- **Everything else** (notifications, logging, formatting): leave the default.

For the filtering technique itself, see [output filtering for command logs](https://tokenade.net/en/articles/output-filtering-command-logs).

## What goes wrong (anti-patterns)

- **Testing only the happy path.** You checked that `ls` still runs. You never checked that `rm -rf /tmp/x` is blocked. A gate you have not seen fire is not a gate.
- **Treating a clean session as proof.** No error notice can mean the hook works, or that it is not registered at all. `/hooks` tells you which.
- **Editing the script, not the config.** Rename `check.sh` to `check-command.sh` and the old path in `settings.json` now exits 127 on every call.
- **Assuming another agent copies Claude Code.** Cursor blocks on bad JSON in permission hooks; Claude Code and Codex do not. Port a guard and you port its failure semantics too, or you do not.

## FAQ

**Why does my Claude Code hook not block anything?**

The usual causes are exit 1 instead of 2, text printed before the JSON, a script path that does not exist, a missing dependency like `jq`, or a `PreToolUse` timeout. All of them are non-blocking errors by default, so the action goes ahead. Open `/hooks` to confirm it is registered, then read the debug log for `failed to start` or `timed out`.

**What is the default timeout for a Claude Code hook?**

600 seconds for `command`, `http` and `mcp_tool` hooks, 30 for `prompt` hooks and 60 for `agent` hooks. The first three drop to 30 seconds on `UserPromptSubmit`, `PreModelSwitch` and `PostModelSwitch`. A timed-out `PreToolUse` command hook lets the tool call continue unless you set `onFailure: "block"`.

**How do I make a Claude Code hook fail closed?**

Add `"onFailure": "block"` to a `command` or `http` hook (Claude Code v2.1.295 or later). A failure then does what exit 2 does on that event: on `PreToolUse` it blocks the tool call. Test it by removing the script and running a command the hook matches; the error should include `blocking because onFailure is "block"`.

**Do Codex hooks fail open?**

Yes. The Codex docs say a `PreToolUse` result with an unsupported field is marked failed and the tool call continues, and that callback errors, timeouts or malformed responses from managed MCP hooks can fail without blocking. No fail-closed option is documented for local command hooks; GitHub issue #41979 asks for one.

**Should a token-saving hook block when it fails?**

No. A filter that fails closed stops the agent from running commands at all. Let it fail open, then check it is loaded: run one noisy command and see how much output the model received, or use the tool's own health check.

## See also

- [Claude Code hooks: which ones add tokens, which cut them](https://tokenade.net/en/articles/claude-code-hooks): the cost map of every hook event.
- [Output filtering for command logs](https://tokenade.net/en/articles/output-filtering-command-logs): what a token-saving hook actually does.
- [Overnight agent budget safety](https://tokenade.net/en/articles/overnight-agent-budget-safety): where a dead filter or guard hurts most.
- [How to measure agent token usage](https://tokenade.net/en/articles/measure-agent-token-usage): verify a filter from the transcript.
- [How to reduce Claude Code token usage](https://tokenade.net/en/articles/reduce-claude-code-token-usage): the wider list of levers.
- [Best Claude Code token optimizers](https://tokenade.net/en/articles/best-claude-code-token-optimizers): tools that ship these hooks for you.
