Hook error label prints unexpanded ${CLAUDE_PLUGIN_ROOT} and frames a deliberate block as a crash
What happens
When a plugin hook exits non-zero after writing to stderr, the error label prints the raw, pre-substitution command string from hooks.json:
Error: PreToolUse:ExitPlanMode hook error: ["${CLAUDE_PLUGIN_ROOT}/hooks/verify-plan.sh"]:
<the hook's stderr>
Two separate problems in one line:
${CLAUDE_PLUGIN_ROOT}is shown unexpanded. The variable does expand correctly at spawn time — the hook runs fine — so this is a display issue in the label only. It makes the message useless for locating the script, and reads to users as a broken plugin.- "hook error" describes a working gate as a crash. For a hook whose entire job is to block (a plan/PR review gate), exit 2 is the success path. Users reported it as tooling failure.
Reproduce
hooks.json in any plugin:
{ "hooks": { "PreToolUse": [ { "matcher": "ExitPlanMode",
"hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/gate.sh\"" } ] } ] } }
Have gate.sh write to stderr and exit 2. The label shows the literal ${CLAUDE_PLUGIN_ROOT}.
Suggested fix
Print the resolved path in the label — the value actually spawned. Optionally, distinguish "the hook returned a blocking decision" from "the hook crashed", since exit 2 is documented as the way to block.
Workaround, for anyone who finds this
Put the message on stdout as JSON and leave stderr empty, still exiting 2. Determined by probe, since the docs were ambiguous here:
| variant | blocks? | reaches the model | label |
|---|---|---|---|
| exit 2 + stderr | yes | stderr | labelled as an error |
| exit 2, nothing written | yes | "No stderr output" | labelled |
| exit 2 + stdout JSON, empty stderr | yes | the JSON reason, verbatim | clean |
| exit 0 + permissionDecision: "deny" | yes | the JSON reason, verbatim | clean |
The third is preferable to the fourth for a gate: the block still comes from the exit code, so it can't silently stop blocking if the JSON is ever not honored.
Docs feedback
The hooks reference gave a self-contradicting answer on whether stdout JSON is read when the exit code is 2 — stating within a few paragraphs both that exit 2 blocks unconditionally and that "non-2xx exit codes with JSON … the exit code is ignored". It also describes permissionDecisionReason as a PostToolUse field, which doesn't appear to match the actual schema. Worth a pass.
Not a duplicate of the existing ${CLAUDE_PLUGIN_ROOT} issues
Those (#78490, #81320, #82154, #79487, #72277) are all cases where the variable fails to resolve
or isn't injected. This one is the opposite: resolution works correctly and the hook executes — only
the error label prints the pre-substitution string. Filing separately because the fix is in the
message formatting, not in variable resolution.
Environment
- Claude Code 2.1.231, macOS (darwin 25.6.0)
- Reproduced with a
PreToolUse:ExitPlanModehook, and independently with aBash-matcher probe
hook to isolate the exit-code/stdout behaviour in the table above.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗