You wrote a hook. It's in settings.json. It does nothing.

So you did what every blog post tells you to do: restarted your session. Still nothing. And now you're wondering whether hooks are just broken.

They aren't. But the advice is stale, and almost every failure mode here is silent by design.

The hooks I run are gates: Playwright smoke tests, code review gates, deterministic pattern checks. All of them share a property that makes silent failure expensive — when a gate doesn't fire, nothing happens, which is exactly what a passing gate looks like.

This is the tree I use to find them. It branches on symptom, because symptom is what you arrive with.

Checked against the Claude Code docs and issue tracker on 6 August 2026, v2.1.223. Where the docs and the field disagree, I say so.

The restart advice is out of date

For most of Claude Code's life, hooks were read once at session start. Edit settings.json mid-session and nothing happened until you started a new one. That was real — issue #22679 documented it, and a run of feature requests asked for a reload command.

That's no longer how it works:

Direct edits to hooks in settings files are normally picked up automatically by the file watcher.

Claude Code watches user, project, local, and managed settings and reloads on change. Restart is now the fallback: if a change hasn't appeared after a few seconds, the watcher may have missed it.

This matters more than it sounds. Someone hits a hook problem, finds a 2025 answer telling them to restart, restarts, and it still doesn't fire — so they conclude hooks are broken. The restart wasn't wrong advice. It's just no longer the answer, and it burns the one diagnostic step most people are willing to take.

Also changed: /hooks is now a read-only browser. It shows every event, its configured hooks, and which settings file each came from. It does not apply changes.

The five symptoms

What you're seeingMost likely causeFirst check
Nothing at allConfig rejected whole, or matcher never matched/statusSetting sources
Ran but didn't blockExit 1, or an event that can't blockExit code table below
Fired on the wrong thingUnanchored regex matcherAnchor with ^ and $
Worked before, doesn't nowVersion change, or someone broke the fileclaude --version
IntermittentParallel race, timeout, subagent pathDebug log timestamps

Symptom 1: nothing happened at all

One bad entry kills every hook in the file

A single schema-invalid matcher anywhere in settings.json silently disables every hook in that file. Every event type. No error, no warning, nothing in /doctor.

Issue #75071 — open, labelled bug and regression — documents it. The reporter lost roughly 100 hooks for 30 hours, and from inside the product it looked identical to nothing happening.

  • The offending entry was "matcher": {"type": "always"} — an object where a string belongs
  • A completely separate, entirely valid UserPromptSubmit hook never fired
  • An invalid regex string like "matcher": "*" is tolerated. Only the wrong type kills the file
  • Tolerance changed across an auto-update: the same config worked on 2.1.19x and failed on 2.1.202. Sessions started before the update kept firing until they ended, so it looked like a time-based outage rather than a config problem

The mechanism is documented:

User, project, and local settings files remain strict: a file that fails validation is rejected as a whole and reported.

Managed settings parse tolerantly — an invalid entry is stripped and everything else still enforced. So your org's policy survives a typo and your personal config does not.

The check: run /status and read the Setting sources line. A source only appears once it loads with at least one setting, so a file that failed validation doesn't appear at all.

Absence from that list is the signal. Much better than eyeballing the JSON, because the JSON usually looks fine — that's the whole problem. (Trailing commas and comments aren't allowed. JSON, not JSON5.)

Configured is not the same as effective

Issue #82323, open since 29 July 2026, makes the point the docs don't: /hooks shows what is configured. It says nothing about whether that config is effective.

A missing hook script fails open. Deleted, renamed, or moved, the command exits non-zero-but-not-2 — a non-blocking error — so the tool call proceeds. No warning, nothing in /doctor, and /hooks still lists it. For a gate this is the worst available default: the control disappears and the only evidence is its absence. If your hook lives inside the working tree, a git checkout can delete it out from under you and every session after that is ungated.

A rule with no matching matcher is dead code. Register a script for Bash|PowerShell, implement a branch for EnterWorktree, and that branch never runs. Valid config, no error, no diagnostic. The reporter had exactly this: a rule implemented, declared by their installer, covered by 85 passing tests, absent from every live matcher set for five days.

Your tests test your script, not your registration. If a gate matters, it needs a canary — something you can trigger on demand that proves the hook actually ran.

The boring causes, in the order worth checking

  1. Wrong settings file. .claude/settings.json for project, ~/.claude/settings.json for global. /hooks shows the source of everything it found.
  2. Matcher case. Case-sensitive. bash is not Bash.
  3. Event has no matcher support. UserPromptSubmit, PostToolBatch, Stop, TeammateIdle, TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, MessageDisplay, and CwdChanged don't take matchers. Add one and it's silently ignored — the hook still fires event-wide. If you narrowed one of these and it's firing everywhere, that's why.
  4. Wrong event. PreToolUse before, PostToolUse after. Obvious until it's 11pm.
  5. chmod +x. If the script isn't running at all, this is often it.

Symptom 2: it ran but didn't block

The exit code trap catches people who know Unix, because the convention is wrong here.

Claude Code treats exit code 1 as a non-blocking error and proceeds with the action.

If your hook is meant to enforce a policy, use exit 2.

Exit 1 is what a shell script does by default when something fails. Here it means log it and carry on.

Exit codeWhat happensWhere output goes
0Success. JSON in stdout is parsedStderr to debug log only — Claude never sees it
2Blocking error. Effect depends on the eventStdout ignored; stderr fed back to Claude
Any otherNon-blocking error. Action proceedsTranscript notice plus first line of stderr

A non-blocking error does show something — a <hook name> hook error notice prefixed Failed with non-blocking status code: — but only in transcript view (Ctrl+O). In the default view, a failing exit-1 hook and a clean exit-0 hook look identical. That's why it reads as silent.

Exit 2 doesn't block everywhere

Can block: PreToolUse, PermissionRequest, UserPromptSubmit (blocks and erases the prompt), UserPromptExpansion, Stop, SubagentStop, TeammateIdle, PostToolBatch, TaskCreated, TaskCompleted, ConfigChange, PreCompact, Elicitation, ElicitationResult, WorktreeCreate (where any non-zero code fails creation).

Cannot block: PostToolUse, PostToolUseFailure, PermissionDenied, Notification, SessionStart, Setup, SubagentStart, SessionEnd, StopFailure, CwdChanged, DirectoryAdded, FileChanged, PostCompact, WorktreeRemove, InstructionsLoaded, MessageDisplay.

Trying to prevent a bad write with PostToolUse? Wrong event. The file is already written. Move it to PreToolUse.

One quirk: since exit-0 stderr never reaches Claude, a PostToolUse hook that wants to tell Claude something — "you just broke the types" — has to exit 2, even though it can't block. Exit 2 does double duty as "block" and "make Claude read this".

Exit 0 is not approval

A PreToolUse hook exiting 0 has approved nothing; the normal permission flow still runs. And approval doesn't beat denial:

If a deny rule matches the tool call, the call is blocked even when your hook returns "allow".

The asymmetry is deliberate: hooks tighten restrictions, never loosen them. The useful flip side is that a hook returning permissionDecision: "deny" blocks the tool in every permission mode, including bypassPermissions and --dangerously-skip-permissions. That's how you build a gate nobody can switch off by changing modes.

Symptom 3: it fired on the wrong thing

Matcher evaluation depends on which characters your matcher contains.

Matcher containsEvaluated as
*, empty, or omittedMatch everything
Only letters, digits, _, -, spaces, ,, |Exact string, or a |/,-separated list of exact strings
Anything elseJavaScript regular expression, unanchored

Unanchored is the word. The regex path uses RegExp.prototype.test, which succeeds on a match anywhere. So Edit.* matches Edit and NotebookEdit. Want whole-string? ^Edit$.

The MCP rule that trips everyone

MCP tools are named mcp__<server>__<tool>. To match every tool from a server you must append .*:

{
  "matcher": "mcp__memory__.*",
  "hooks": [{ "type": "command", "command": "/abs/path/log-memory.sh" }]
}

The .* is required: a matcher like mcp__memory or mcp__brave-search contains only exact-match characters, so it is compared as an exact string and matches no tool.

There's no tool literally named mcp__memory, so a bare prefix matches nothing.

And if the server comes from a plugin, the name is scoped differently again: mcp__plugin_<plugin-name>_<server-name>__<tool>. Plugin my-plugin bundling a server under key db exposes its query tool as mcp__plugin_my-plugin_db__query, so you need mcp__plugin_my-plugin_db__.*. A matcher on the bare server key never fires. I'd bet this accounts for a chunk of "my MCP hook doesn't work" reports.

The inversion at v2.1.195

The one that makes stale blog posts actively dangerous. On 26 June 2026:

Fixed hook matchers with hyphenated identifiers (e.g. code-reviewer, mcp__brave-search) accidentally substring-matching — they now exact-match.

Same config, opposite outcome, one minor version apart:

  • Before 2.1.195: mcp__brave-search is an unanchored regex and matches every tool from that server
  • From 2.1.195: it exact-matches and matches nothing

It hits agent-type matchers the same way — code-reviewer used to fire for senior-code-reviewer too. If a hook worked in June, stopped in July, and has a hyphen in the matcher, this is your bug. mcp__brave-search__.* works on every version, which is reason enough to always write the .* form.

The if field you're probably not using

Matchers filter on tool name. The if field on an individual handler filters on name and arguments together, using permission rule syntax:

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "if": "Bash(git push*)",
      "command": "/abs/path/pre-push-check.sh"
    }
  ]
}

"Edit(*.ts)" runs only for TypeScript files. One rule matches one tool, so Bash and PowerShell each need their own handler. Note v2.1.147 fixed if conditions like PowerShell(git push*) never matching — before that, only PowerShell(*) worked.

This is also the cheapest performance win in the post, which I'll come back to.

Tool names have moved

  • It's Agent, not Task.
  • TodoWrite still exists but is disabled by default since v2.1.142, in favour of TaskCreate/TaskGet/TaskList/TaskUpdate. A hook matching TodoWrite on a default install fires for nothing.
  • No MultiEdit in the current reference.

Symptom 4: it worked before and doesn't now

Three questions, in order.

Did your version change? Claude Code auto-updates, and these all landed within a few months:

VersionWhat changed
2.1.147Fixed if conditions like PowerShell(git push*) never matching
2.1.169Added --safe-mode to disable all customisations including hooks
2.1.195Hyphenated matchers: substring → exact match
2.1.196Timeouts now show a transcript notice; earlier versions cancelled silently
2.1.199SessionStart/Setup/SubagentStart show exit-2 stderr in transcript
2.1.214Exit 2 with schema-invalid JSON now blocks; previously non-blocking
2.1.218Subagent frontmatter hooks require workspace trust
2.1.222Fixed PreToolUse auto-allow hooks bypassing tool restrictions in background agent tasks

Did someone else edit the file? Back to #75071. One bad entry added anywhere — by a teammate, a plugin installer, a config generator — takes down every hook in it. Your hook didn't break. The file did.

Did a plugin update? ${CLAUDE_PLUGIN_ROOT} changes on every plugin update. Issue #42564 reports it intermittently unset, expanding to /scripts/hooks/run-with-flags.js and producing MODULE_NOT_FOUND.

The relative-path landmine

Not version-related, but it belongs here because the symptom is "worked for twenty minutes, then every tool call broke."

Issue #32361 reports that hook command strings resolve relative to the working directory at hook invocation time, not the project root. Since working directory persists between Bash tool calls, one cd into a subdirectory permanently shifts resolution for every subsequent hook invocation in that session:

PreToolUse:Bash hook error: [python .claude/hooks/batch-progress-logger.py]:
can't open file 'C:\Repos\my-project\analyst-ui\.claude\hooks\batch-progress-logger.py':
[Errno 2] No such file or directory

Once a PreToolUse hook errors on every call, the session is unusable and recovery means hand-editing settings.local.json.

Use exec form and stop fighting quoting

Add "args" and Claude Code resolves command on PATH and spawns it directly. No shell, no tokenisation, on any platform:

{
  "type": "command",
  "command": "python3",
  "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check.py"]
}

Without args it's shell form — the string goes to sh -c (or Git Bash on Windows), and you have to double-quote every placeholder yourself. Exec form kills the relative-path landmine, the quoting bugs, and the shell-profile-pollutes-stdout failure in one move. It's a good default even when nothing is broken.

Symptom 5: it's intermittent

Hooks run in parallel. All matching hooks run concurrently and identical handlers are deduplicated. No ordering guarantee is documented.

When several PreToolUse hooks return updatedInput to rewrite the same tool's arguments, the last to finish wins — non-deterministic, by definition. Never have two hooks modify the same tool's input.

Issue #24327 has a reporter attributing intermittent PreToolUse exit-2 misbehaviour to a parallel-hook race, fixed by collapsing six hooks into one Python dispatcher that runs them sequentially and returns merged output. A follow-up in the same thread couldn't reproduce it and blamed timing during a 20.5-second hook run instead. No maintainer root cause — but one dispatcher per matcher is a sound pattern either way.

Timeouts vary by event, which catches people moving hooks around:

Handler type or eventDefault timeout
command, http, mcp_tool600s
prompt / agent30s / 60s
UserPromptSubmit (command/http/mcp_tool)30s
MessageDisplay10s
SessionEnd1.5s shared budget, raisable to 60s

A hook that's fine on PostToolUse with 600 seconds can time out on UserPromptSubmit at 30. A timed-out UserPromptSubmit hook is cancelled with its output — including any additionalContextdiscarded. The prompt still reaches Claude, just without your context.

Stop hooks have a loop guard. Claude Code overrides a Stop hook after it blocks eight times in a row without progress, which reads as "my Stop hook stopped working" rather than "never worked". Parse stop_hook_active and exit early:

#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
# ... rest of your logic

The cost nobody mentions: hook bloat

Every guide tells you what hooks can do. None of them tell you what they cost.

This is the problem I actually spend time on now, and it's not correctness — it's latency. A PreToolUse hook runs before every matching tool call. Give it a broad matcher and it runs before every Bash command, every Edit, every Read. Each one is a process spawn. Each one is time the agent isn't working.

You don't notice with one agent. You notice hard when you're running a lot of them, because the cost multiplies by agent count and by tool calls per agent. A hook that takes 300ms and fires on every Bash call is invisible in a chat session and brutal across thirty parallel agents grinding through a refactor.

What actually helps:

  • Narrow the matcher. Edit instead of .*. Every event you don't match is a process you don't spawn.
  • Use the if field. "Bash(git push*)" runs your pre-push check on pushes, not on every ls. This is the highest-leverage change available and most people never touch it.
  • Watch what runs on UserPromptSubmit and Stop. These fire on rhythm rather than on tool volume, but they sit directly in the turn's critical path — a slow one stalls the session rather than a single call.
  • Audit periodically. Hooks accumulate. Plugins add them, teammates add them, you add one for a debugging session and never remove it. /hooks lists everything currently loaded and where it came from — read it occasionally and delete what's dead.

The trap is that hook bloat has no error message. Nothing fails. Everything just gets slower, gradually, and you attribute it to the model or the codebase.

The JSON traps

If your hook exits 0 and returns JSON, three ways to lose silently.

1. Wrong nesting level. additionalContext must live inside hookSpecificOutput:

if you place it at the top level of the JSON, Claude Code silently ignores it.

{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "Current branch: feature/auth"
  }
}

hookEventName is required and must match the event.

2. Something else printed first. Your stdout must contain only the JSON object. A shell profile that prints on startup produces Shell ready on arm64 {"decision": "block"...}, which fails to parse. In the debug log: Hook output does not start with {, treating as plain text. Exec form dodges this entirely.

3. Mixing approaches. Pick one per hook: exit codes, or exit 0 with JSON. JSON is only processed on exit 0; exit 2 ignores it.

Two smaller ones. PreToolUse no longer uses top-level decision/reason — they're deprecated for that event in favour of hookSpecificOutput.permissionDecision (allow, deny, ask, defer). Other events like PostToolUse and Stop still use the top-level form, which is exactly the inconsistency that makes copy-pasted examples fail. And PostToolUse input carries tool_response, not tool_output — though Anthropic's own plugin-development skill says tool_result. Trust the hooks reference.

Where the docs and the field disagree

Subagents — the one that cost me the most. The docs say hooks from settings files, managed settings, and plugins all run inside subagents. Issue #34692, closed as not_planned, reports that Bash, Edit, Write, Read, and Grep calls from a subagent spawned via the Agent tool don't trigger parent-session PreToolUse or PostToolUse hooks — with no indication that hooks are being skipped.

I've hit this, and worse: I've seen the effect cross worktrees, hitting both other worktrees and the one I was working in. It is extremely hard to debug, because there's no signal anywhere. You only pick it up by watching individual agents and noticing a gate that should have fired didn't.

That cross-worktree behaviour I can't explain from the docs, so I won't try — but it's consistent with the resolution problem in #32361, where hook paths resolve against a working directory that moves. If you run agents across worktrees and rely on hooks for enforcement, assume nothing and verify per worktree.

MCP tools and PostToolUse. Separately from its main finding, #75071 reports PostToolUse never firing for MCP tools, verified over three weeks in which hook dispatch was otherwise healthy: zero invocations across thousands of MCP calls while Stop and UserPromptSubmit hooks logged thousands. Contradicts the documented behaviour. Still open.

If you're building MCP servers and hooking them, my MCP server authentication walkthrough covers the server side.

The triage sequence

  1. claude --version. Behaviour changed at 2.1.195 and 2.1.214. Know which rules you're playing by.
  2. /statusSetting sources. If your file isn't listed, it failed validation and every hook in it is dead. Highest-yield single check here.
  3. /hooks. Confirm it appears under the right event, from the file you expect. Configured, not effective.
  4. Simplify to one handler, one event, an exact tool name, and a command that just writes a file. Prove dispatch before you debug logic.
  5. Log hook_event_name, tool_name, and tool_use_id from stdin to that file.
  6. claude --debug-file /tmp/claude.log, then tail -f it. Mid-session, run /debug to enable logging and get the path. The log carries which hooks matched, exit codes, stdout, and stderr:
[DEBUG] Hook output does not start with {, treating as plain text
[DEBUG] Hook PostToolUse:Write (PostToolUse) success:
hook-ran
  1. CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose for matcher counts and query matching. This answers the question you usually have: did my matcher match?
  2. Test main-thread and subagent paths separately, given #34692.
  3. --safe-mode starts Claude Code with all customisations disabled — use it to confirm a hook is the cause.
  4. Restore JSON decision output last.

Not worth bothering with: /doctor. It isn't a hook diagnostic — #75071 specifically notes there was no doctor warning for a failure that took down 100 hooks.

Five habits that prevent most of this

Test one agent slowly first. This is the big one. Debugging a hook across ten parallel agents is impossible — the output interleaves, the timing shifts, and you can't tell a hook that didn't fire from a hook that fired on a different agent. Get the gate provably working on one slow session, then fan out. Every hour I've lost to hooks started with skipping this.

Exec form by default. "args" plus ${CLAUDE_PROJECT_DIR} eliminates three failure classes at once.

Exit 2 or exit 0, nothing else. Exit 1 is the default failure code of every script you'll write, and here it means "carry on".

One dispatcher per matcher. Register one script per matcher group and branch inside it: deterministic ordering, one place to log, no updatedInput race, one process spawn instead of six.

Give every gate a canary. #82323 is the argument. Your tests prove the script works; they prove nothing about whether it's registered, reachable, or still on disk. A gate you can't demonstrate is a gate you don't have.

If you're setting up Claude Code from scratch, my guide to using Claude Code in 2026 covers where hooks fit alongside skills, MCP, and permissions.

Frequently asked questions

Why isn't my Claude Code hook firing at all?
In order of likelihood: your settings file failed validation and every hook in it was silently dropped (run /status and check whether your file appears under Setting sources), the matcher never matched (matchers are case-sensitive and MCP prefixes need a trailing .*), the event doesn't support matchers so yours was silently ignored, or the script isn't executable. Restarting the session is no longer the fix — hooks hot-reload via a file watcher on current versions.
Do I need to restart Claude Code after editing hooks in settings.json?
No. Claude Code watches user, project, local, and managed settings files and picks up hook changes automatically while the session runs. This changed from the older behaviour where hooks were read once at session start. Restart is now a fallback: if a change hasn't appeared after a few seconds, the file watcher may have missed it, and restarting forces a reload.
Why does my hook run but not block the tool call?
You're probably exiting 1. Claude Code treats exit 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. Only exit 2 blocks. The exception is WorktreeCreate, where any non-zero code aborts. Also check that your event can block at all: PostToolUse, PostToolUseFailure, PermissionDenied, Notification, SessionStart, SessionEnd and several others can't block anything, because the thing already happened.
Why doesn't my MCP tool hook matcher work?
MCP tools are named mcp__<server>__<tool>, and a bare prefix like mcp__memory contains only exact-match characters, so it's compared as an exact string and matches no tool. You need mcp__memory__.* with the trailing .* — and on Claude Code before v2.1.195 the bare hyphenated form behaved the opposite way, matching everything from that server. If the server comes from a plugin the name is scoped differently again: mcp__plugin_<plugin-name>_<server-name>__<tool>, so a matcher written against the bare server key never fires.
Can one bad hook break all my other hooks?
Yes, and this is the failure mode almost nobody knows about. A single schema-invalid matcher entry — for example an object where a string belongs — causes the entire settings.json to fail validation, which silently disables every hook in that file across every event. No error, no warning, no /doctor output. Issue #75071 documents a user losing roughly 100 hooks for 30 hours this way. The check is /status: a file that failed validation doesn't appear in the Setting sources line at all.
Do Claude Code hooks slow things down?
Yes, and it's the cost nobody writes about. A PreToolUse hook runs before every matching tool call, and each run is a process spawn. With a broad matcher that's every Bash command, every Edit, every Read. You won't notice with one agent; you'll notice hard across many parallel agents, where the cost multiplies by agent count and tool calls per agent. Narrow your matchers, use the `if` field to filter on arguments rather than just tool name, and collapse multiple hooks on one matcher into a single dispatcher script. Hook bloat has no error message — everything just gets gradually slower.
How do I debug a Claude Code hook?
Start with claude --debug-file /tmp/claude.log and tail it in another terminal — the log carries which hooks matched, exit codes, stdout, and stderr. Mid-session, run /debug to enable logging and get the path. Set CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose for matcher-level detail, which is usually the question you actually have. Press Ctrl+O for transcript view, since a hook error notice only appears there. Use --safe-mode to start with all customisations disabled. Critically: debug against one slow agent, not a parallel fan-out — interleaved output makes a hook that didn't fire indistinguishable from one that fired elsewhere. Don't rely on /doctor; it doesn't diagnose hooks.
Does exit code 0 from a PreToolUse hook approve the tool call?
No. Exiting 0 doesn't approve anything — the normal permission flow still applies. To approve explicitly you return hookSpecificOutput.permissionDecision: "allow", and even that doesn't override a deny rule from any settings scope, including managed settings. Hooks can tighten restrictions but never loosen them. The useful flip side: a hook returning "deny" blocks the tool in every permission mode, including bypassPermissions and --dangerously-skip-permissions.
Do hooks fire for tool calls made by subagents?
Genuinely unresolved. The docs say hooks from settings files, managed policy settings, and plugins run inside subagents. But issue #34692 reports that Bash, Edit, Write, Read, and Grep calls from a subagent spawned via the Agent tool don't trigger parent-session PreToolUse or PostToolUse hooks, with no indication hooks are being skipped. It was closed without a fix. Both can be true if the subagent has its own hook set rather than the parent observing child calls. If you use hooks as gates and run many subagents, test both paths separately — there's no signal when enforcement quietly stops.

Start by doing this

5 mins: Run /status and find the Setting sources line. Confirm the file holding your hooks is listed. If it isn't, it failed validation and every hook in it is dead — the fastest high-yield check in this post.

15 mins: Take your most important gate and prove it runs, on one agent, slowly. Simplify it to one handler on one event with an exact tool name, have it append hook_event_name and tool_name to a file, and trigger it. Then run claude --debug-file /tmp/claude.log with CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose and watch the matcher decision happen.

30 mins: Open /hooks and audit everything currently loaded. Delete what's dead, narrow every matcher that's broader than it needs to be, and add if conditions to anything firing on every Bash call. Then make every failure path in an enforcing hook exit 2 rather than exit 1.