Plugin-native PreToolUse hooks (auto-discovered via hooks/hooks.json) are not enforced in interactive sessions — exit-2 works only in print mode, JSON permissionDecision:"deny" doesn't work in either mode
Summary
For a hook shipped by a plugin and auto-discovered from that plugin's ownhooks/hooks.json (as opposed to a hook declared directly in the user'ssettings.json), PreToolUse enforcement is unreliable in a way that depends on
both the interactive/print-mode split and the response protocol the hook uses:
| Hook source | Protocol | Interactive session | claude -p (print mode) |
|---|---|---|---|
| settings.json-declared | exit 2 + stderr | ✅ blocks | ✅ blocks |
| Plugin-native (hooks/hooks.json) | exit 2 + stderr | ❌ does not block | ✅ blocks |
| Plugin-native (hooks/hooks.json) | JSON hookSpecificOutput.permissionDecision:"deny" | ❌ does not block | ❌ does not block |
The middle row means a plugin author who does everything the documented exit 2
protocol asks for still gets silently no-op'd the moment their hook runs in an
interactive session — the single most common way Claude Code is actually used.
The bottom row means the JSON permissionDecision protocol, also documented as
valid, is not honored from a plugin-sourced hook in either mode we tested.
Environment
- Claude Code version: 2.1.263 (
claude --version) - OS: Windows 11 (10.0.26200), Git Bash as the shell
- Reproduced with a real, installed marketplace plugin:
everything-claude-code
(ecc@ecc) v2.2.1 — https://github.com/affaan-m/everything-claude-code
(enabledPlugins["ecc@ecc"] = true in settings.json, hooks sourced purely by
the plugin's own hooks/hooks.json, no manual settings.json hook entries for
the hooks under test)
Expected behavior
A PreToolUse hook auto-discovered from a plugin's hooks/hooks.json should be
invoked and its decision enforced identically to a hook declared directly insettings.json, in both interactive and print-mode sessions, for both theexit 2/stderr protocol and the JSON hookSpecificOutput.permissionDecision
protocol — both are documented as valid response shapes at
https://code.claude.com/docs/en/hooks.
Actual behavior
- Control (proves the harness works generally): a
settings.json-declared
PreToolUse hook (deploy-guard.js, exit 2 protocol) correctly blocked a
probe command (supabase functions deploy ...) in an interactive session.
- Plugin-native,
exit 2protocol (block-no-verify.jsfrom theecc@ecc
plugin, auto-discovered via the plugin's hooks/hooks.json, matcher Bash):
blocking a git push --no-verify probe —
- Interactive session: the command ran to completion; the hook did not fire /
did not block.
- Fresh `claude -p ... --no-session-persistence --permission-prompts none
--output-format text subprocess: correctly blocked, with the exact expected`
stderr surfaced:
``
PreToolUse:Bash hook error: [node -e "..." node scripts/hooks/pre-bash-dispatcher.js]: BLOCKED: --no-verify flag is not allowed with git push. Git hooks must not be bypassed.
- Plugin-native,
Edit|Writematcher,exit 2protocol
(config-protection.js from the same plugin): editing an existing
.eslintrc.json (a protected-config-shaped file) in an interactive session
was not blocked — the edit went through uninterrupted. Confirms this is
matcher-general, not Bash-specific.
- Plugin-native, JSON
permissionDecisionprotocol
(gateguard-fact-force.js from the same plugin, Bash matcher, destructive-command
gate): probing dd if=/dev/zero of=<scratch>/probe.bin bs=1 count=0 and
git rm -rf <scratch-dir> —
- Interactive session: command executed; no deny surfaced.
- Fresh
claude -psubprocess (same invocation style as (2)): command **still
executed** — git rm genuinely removed the target file
(rm 'throwaway5.txt' in the raw tool output), no deny of any kind
surfaced.
- We independently re-verified the hook's own logic is correct at every layer
before concluding this is a runtime issue rather than a plugin bug:
- The raw hook module (
gateguard-fact-force.js), invoked directly, returns
a well-formed {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}\n with exit code 0 for both probes.
- The full dispatcher pipeline (
bash-hook-dispatcher.js→
pre-bash-dispatcher.js) run standalone with the probe command on stdin
produces the same correct deny JSON.
- The literal bootstrap chain the plugin's
hooks/hooks.jsonactually
spawns (plugin-hook-bootstrap.js, with CLAUDE_PLUGIN_ROOT /
ECC_PLUGIN_ROOT injected as the real hook process does) run as a real
subprocess also produces the same correct deny JSON on stdout with
exit 0.
- The cached plugin snapshot Claude Code actually executes from
(~/.claude/plugins/cache/ecc/ecc/2.2.1/) is byte-identical to the
source checkout for every file inspected — not a staleness issue.
- So: the deny decision is computed correctly and emitted correctly to
stdout with the documented shape and exit code, in both modes, and the
runtime does not act on it in either mode.
Reproduction procedure (safe — uses only disposable scratch state)
- Install any plugin that ships a
hooks/hooks.jsonwith aPreToolUsehook
using the JSON permissionDecision protocol (or use everything-claude-code
v2.2.1's ecc@ecc plugin, gateguard-fact-force.js, Bash matcher).
git inita throwaway scratch repo, commit one throwaway file.- Interactively, in a normal Claude Code session with the plugin enabled, ask
Claude to run git rm -rf <that file> (or any command the plugin hook's
destructive-pattern matcher is documented to catch). Observe: no deny; file
is removed.
- Separately, from a shell (not inside a live session), run a fresh, isolated
probe:
```
echo '{"tool_name":"Bash","tool_input":{"command":"git rm -rf throwaway.txt"}}' \
| claude -p "run: git rm -rf throwaway.txt" \
--no-session-persistence --permission-prompts none \
--output-format text > repro_out.txt 2>&1
exit 2
(adjust to however your plugin's matcher is invoked) and observe the same
non-enforcement in print mode for the JSON-protocol hook, while an
-protocol plugin hook (e.g. one that blocks git push --no-verify`)
does correctly block in this same print-mode invocation style — isolating
the JSON-protocol-specific failure from the plugin/interactive-mode failure.
- Compare against a
settings.json-declared hook (not plugin-sourced) doing
the same exit 2 check, run interactively — it correctly blocks, isolating
the plugin-sourced-hook failure from a general PreToolUse regression.
Related existing issues, and why this is additive
- #10875 — plugin
hook stdout skips the parse step that inline settings.json hooks go
through (closed, v2.0.31). Consistent with what we see, but closed and
doesn't address the interactive-vs-print-mode split we're reporting, nor the
exit 2 case (which does work for a plugin hook, just only in print mode).
- #52822 — JSON
permissionDecision:"allow" not honored interactively (open, v2.1.119,
reported as a regression from v2.1.59). Same JSON-protocol-not-honored shape
we see, but for "allow" rather than "deny", and doesn't establish the
print-mode-vs-interactive distinction or the plugin-vs-settings.json source
distinction we isolated here.
- #31250 — PreToolUse
hooks silently not firing (closed, stale). General shape match, no isolation
of cause.
None of the above pins down the specific combination we found: hook source
(plugin-native vs. settings.json) × protocol (exit 2 vs. JSONpermissionDecision) × mode (interactive vs. print) as three independent
axes, two of which (source, protocol) each independently cause the failure. We
believe this three-axis isolation is new, actionable information for
triage even though it corroborates the same underlying area as the issues
above — filing as a distinct, additive report rather than a duplicate.
Suspected minimal fix (unverified — we don't have visibility into the
runtime's hook-dispatch internals, this is a hypothesis based on the observed
matrix)
The fact that a plugin-sourced exit 2 hook works in print mode but not
interactive suggests the interactive code path treats plugin-discovered hooks
differently from settings.json-declared ones somewhere between hook discovery
and enforcement — possibly a code path that loads/executes plugin hooks for
transcript/telemetry purposes in interactive mode without gating the tool call
on their result, while print mode (or the settings.json path) does gate on
it. Separately, JSON hookSpecificOutput.permissionDecision appears to never
be consumed for a plugin-sourced hook's result even where the process is
otherwise invoked correctly (print mode) — suggesting the interactive/print
result-consumption code for PreToolUse may only look for the exit 2/stderr
shape when the hook came from a plugin, not the JSON shape, regardless of mode.
We don't have access to the closed-source runtime to confirm; flagging as the
most likely single root cause worth checking first.
Why we're confident this isn't a config or plugin-code problem
- The plugin's cached, executed copy is byte-identical to its source checkout.
- The hook's own logic, its full local dispatcher, and the literal bootstrap
subprocess Claude Code actually spawns all independently produce correct,
well-formed output for both protocols.
- A
settings.json-declared hook using the exact sameexit 2protocol works
correctly in the same interactive session where the plugin-sourced one does
not.
Happy to share the full plugin source (hooks/hooks.json, the dispatcher, and
the hook scripts) if useful for triage — it's a public repo
(https://github.com/affaan-m/everything-claude-code).