[BUG] PreToolUse prompt-hook deny returns the hook's entire configured prompt to Claude in the tool error, not just the reason

Status Open
Reported on v2.1.234
Maintainer reply None cached
Activity 0 comments · opened Aug 20, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [ ] I am using the latest version of Claude Code

On the last box: this reproduces on 2.1.234, which is ahead of the current npm stable dist-tag (2.1.228). The newest published version is 2.1.237 (npm publish time 2026-08-19T23:57:54Z). The release notes for 2.1.235, 2.1.236 and 2.1.237 mention no change to hook error formatting, but I have not re-run the reproduction on 2.1.237, so I am leaving the box unchecked rather than claiming otherwise.

What's Wrong?

When a PreToolUse hook of type: prompt denies a tool call — the evaluating model answers {"ok": false, "reason": "..."} — the tool_result Claude Code hands back to the calling agent contains the hook's entire configured prompt field, in brackets, ahead of the reason:

PreToolUse:<ToolName> hook error: [<the hook's entire raw configured prompt>]: <the reason>

Three things go wrong at once:

  1. The hook author's prompt text is disclosed to the model and to every consumer of the tool result (transcript, --output-format stream-json).
  2. The only actionable content — the reason — sits at the very tail, behind however many characters of prompt the hook was configured with.
  3. The prompt is inserted raw, with the $ARGUMENTS placeholder left unsubstituted, so what the agent sees is not even the text that was evaluated.

An equivalent type: command hook denying the same call with hookSpecificOutput.permissionDecision: "deny" and the same reason delivers only the reason — no bracket and no prefix.

Setting continueOnBlock: true does not change the delivered text: it is byte-identical. Of the documented prompt-hook configuration fields (type, prompt, model, timeout, continueOnBlock), only continueOnBlock was exercised directly in these runs, and no field in that table is documented as controlling the message.

The size relationship is linear and unbounded, measured rather than estimated: the same hook configured with a 3122-character prompt produced a 3172-character tool_result, with every padding marker intact and no truncation anywhere on the path.

What Should Happen?

A prompt-hook deny should deliver the same agent-facing message a command-hook permissionDecision: "deny" delivers: the reason, and nothing else. The hook's configured prompt is hook-author configuration, not information the blocked agent needs, and it is the one hook field whose contents are neither implied by the tool call nor bounded in size.

Steps to Reproduce

Prerequisites: a POSIX-style shell (bash, zsh, or Git Bash on Windows), the claude CLI installed and on PATH, and Python 3 reachable as python (use python3 if that is how your platform exposes it).

Everything below lives under one throwaway root. CLAUDE_CONFIG_DIR is used so the runs cannot pick up hooks from a real user configuration.

1. Create the scratch tree.

export REPRO=/tmp/hook-repro     # on Windows/Git Bash use a plain absolute path such as C:/hook-repro
mkdir -p "$REPRO/project" "$REPRO/cfg" "$REPRO/settings"

2. $REPRO/project/sample.py

def widget_total(items):
    """Sum the numbers in items."""
    return sum(items)

3. $REPRO/settings/prompt-hook.json — the prompt hook (defect case). The marker strings make the leak unambiguous in the output.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Grep",
        "hooks": [
          {
            "type": "prompt",
            "prompt": "HOOKPROMPTMARKER01 Evaluate this tool call: $ARGUMENTS Always answer exactly {\"ok\": false, \"reason\": \"DENYREASONMARKER02\"}"
          }
        ]
      }
    ]
  }
}

4. $REPRO/deny_hook.py and $REPRO/settings/command-hook.json — the control case. Substitute python3 for python if your platform does not alias one to the other, and note that command needs a literal absolute path.

import json
import sys

sys.stdin.read()
print(json.dumps({
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": "DENYREASONMARKER02",
    }
}))
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Grep",
        "hooks": [
          {
            "type": "command",
            "command": "python /tmp/hook-repro/deny_hook.py"
          }
        ]
      }
    ]
  }
}

Keep both settings files side by side and copy the one you want into $REPRO/cfg/settings.json before each run, so no run destroys the configuration the next one needs.

5. Authenticate the isolated config directory once — a fresh CLAUDE_CONFIG_DIR has no credentials. Run CLAUDE_CONFIG_DIR="$REPRO/cfg" claude and log in, or export an API key.

6. Run the defect case.

cp "$REPRO/settings/prompt-hook.json" "$REPRO/cfg/settings.json"
cd "$REPRO/project"
CLAUDE_CONFIG_DIR="$REPRO/cfg" claude -p \
  "Use the Grep tool exactly once to search for the pattern widget_total in sample.py in the current directory. Then report what happened." \
  --output-format stream-json --verbose --allowedTools "Grep"

Look at the tool_result inside the user event. If you launch this from inside another Claude Code session, prefix the command with env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT -u CLAUDE_CODE_SESSION_ID so the nested run does not inherit the parent session's identity.

7. Run the control case. Copy command-hook.json into place and re-run the identical command.

8. Variants worth running. Add "continueOnBlock": true to the prompt hook and re-run — the leaked text is unchanged. Write {} into $REPRO/cfg/settings.json and re-run to confirm the isolation is real: Grep then succeeds and returns 1:def widget_total(items):.

Error Messages/Logs

All output below is captured from the runs described above, on 2.1.234. session_id and uuid are elided; everything else is verbatim.

Prompt hook, continueOnBlock unset — the tool_result text the agent received:

PreToolUse:Grep hook error: [HOOKPROMPTMARKER01 Evaluate this tool call: $ARGUMENTS Always answer exactly {"ok": false, "reason": "DENYREASONMARKER02"}]: DENYREASONMARKER02

The raw stream-json event it came from:

{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"PreToolUse:Grep hook error: [HOOKPROMPTMARKER01 Evaluate this tool call: $ARGUMENTS Always answer exactly {\"ok\": false, \"reason\": \"DENYREASONMARKER02\"}]: DENYREASONMARKER02","is_error":true,"tool_use_id":"toolu_01WAku6m4UYFzaiBkpktF8cg"}]},"parent_tool_use_id":null,"session_id":"<elided>","uuid":"<elided>","timestamp":"2026-08-20T14:25:27.374Z","tool_use_result":"Error: PreToolUse:Grep hook error: [HOOKPROMPTMARKER01 Evaluate this tool call: $ARGUMENTS Always answer exactly {\"ok\": false, \"reason\": \"DENYREASONMARKER02\"}]: DENYREASONMARKER02","tool_result_meta":[{"id":"toolu_01WAku6m4UYFzaiBkpktF8cg","non_execution_kind":"permission-rule"}]}

HOOKPROMPTMARKER01 is the start of the configured prompt; $ARGUMENTS is present unsubstituted; DENYREASONMARKER02 — the only part the agent can act on — is the last token.

Same hook with "continueOnBlock": true — byte-identical leaked text:

{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"PreToolUse:Grep hook error: [HOOKPROMPTMARKER01 Evaluate this tool call: $ARGUMENTS Always answer exactly {\"ok\": false, \"reason\": \"DENYREASONMARKER02\"}]: DENYREASONMARKER02","is_error":true,"tool_use_id":"toolu_0116d8p2vyyJUXdCQwxPRja2"}]},"parent_tool_use_id":null,"session_id":"<elided>","uuid":"<elided>","timestamp":"2026-08-20T14:26:36.093Z","tool_use_result":"Error: PreToolUse:Grep hook error: [HOOKPROMPTMARKER01 Evaluate this tool call: $ARGUMENTS Always answer exactly {\"ok\": false, \"reason\": \"DENYREASONMARKER02\"}]: DENYREASONMARKER02","tool_result_meta":[{"id":"toolu_0116d8p2vyyJUXdCQwxPRja2","non_execution_kind":"permission-rule"}]}

Contrast: the command-hook deny decision, same event, same reason

type: command returning hookSpecificOutput.permissionDecision: "deny" — the tool_result text the agent received:

DENYREASONMARKER02

Raw event:

{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"DENYREASONMARKER02","is_error":true,"tool_use_id":"toolu_01TXwrVAZwAczykmxCgovu3R"}]},"parent_tool_use_id":null,"session_id":"<elided>","uuid":"<elided>","timestamp":"2026-08-20T14:25:53.319Z","tool_use_result":"Error: DENYREASONMARKER02","tool_result_meta":[{"id":"toolu_01TXwrVAZwAczykmxCgovu3R","non_execution_kind":"permission-rule"}]}

A second control using the deprecated top-level form {"decision": "block", "reason": "DENYREASONMARKER02"} produced the same bare reason.

To be precise about the scope of that contrast, since the exit-code-2 route behaves differently: a type: command hook that writes the reason to stderr and exits 2 does produce a bracketed label, PreToolUse:Grep hook error: [python /tmp/hook-repro/deny_hook_exit2.py]: DENYREASONMARKER02. So the bracket is not unique to prompt hooks. What is unique is what goes inside it: on the exit-2 command path the bracket holds the hook's command string — short, fixed, already known to whoever wrote the hook — while on the prompt path it holds arbitrary free-form instruction text of unbounded length. And the route the documentation calls equivalent to a prompt-hook ok: false, namely permissionDecision: "deny", produces no bracket at all.

Additional Information

Documentation this contradicts

From the hooks reference, <https://code.claude.com/docs/en/hooks#response-schema> (verified live 2026-08-20):

PreToolUse: the tool call is denied; by default the turn ends and the deny reason appears in the chat as a warning line. Set continueOnBlock: true to instead return the reason to Claude as the tool error so it can adjust and continue, equivalent to a command hook's permissionDecision: "deny". Before v2.1.210, the deny reason was returned to Claude as the tool error and the turn continued

And for the command path, from <https://code.claude.com/docs/en/hooks#pretooluse-decision-control>:

permissionDecisionReason | For "allow" and "ask", shown to the user but not Claude. For "deny", shown to Claude. For "defer", ignored

The runs above show the two are not equivalent: the permissionDecision: "deny" path delivers the reason, the prompt path delivers the reason wrapped in the hook's whole prompt. Nothing in the prompt-hook configuration table (<https://code.claude.com/docs/en/hooks#prompt-hook-configuration>: type, prompt, model, timeout, continueOnBlock) documents or controls the bracketed segment, and no passage on the page states that the prompt field is included in the agent-facing message.

Impact

  • Context cost, paid on every deny, scaling with the prompt. The injected text is the entire prompt field: a 3122-character prompt produced a 3172-character tool_result in the measurement above. A non-trivial policy or judge hook is easily a few kilobytes, and that is paid again on every denied call.
  • The reason is in the worst possible position. It is the last token, after an arbitrarily long block of unrelated instructions addressed to a different model — and it is precisely the content the blocked agent is supposed to act on.
  • Prompt disclosure. A hook author's evaluation prompt, which may encode policy, thresholds, or internal rules, is handed to the model and written into the transcript and the stream-json output, with no documented way to opt out.
  • $ARGUMENTS is unsubstituted, so the leaked text is not even a faithful record of what was evaluated; it is the raw template.

Related issues

  • #86368 — an analogous bracketed label on the type: command exit-code-2 path, where the bracket carries the raw pre-substitution command string (a plugin hook with ${CLAUDE_PLUGIN_ROOT} left unexpanded). A maintainer replied there that the bracketed label is intended and has always been shown that way. It is produced by a separate formatter, not the one in this report, and the distinction that matters here is the bracket's contents: a command string is short and fully known to the hook author, whereas a prompt hook's prompt is arbitrary text of unbounded length — and the documentation explicitly promises the prompt path is equivalent to permissionDecision: "deny", which delivers no bracket at all.
  • #85490 — deny-versus-error labeling with a short hook path in the bracket; same family of formatter, different complaint.
  • #78527 — PreToolUse prompt-hook turn continuation. Related but distinct; it does not cover the leaked prompt.

Supporting evidence: the mechanism

Secondary — every claim above rests on the runs, not on this. Extracting plaintext from the shipped 2.1.234 executable (a single-file bundle containing minified JavaScript) shows the template that composes the prompt-hook blocking error from the prompt and the reason. This string occurs exactly once in the image:

{hook:e,outcome:"blocking",blockingError:{blockingError:`[${e.prompt}]: ${O.data.reason}`,command:e.prompt},preventContinuation:!u&&e.continueOnBlock!==!0,stopReason:O.data.reason}

The permissionDecision: "deny" branch, for contrast, uses the reason alone:

case"deny":u.permissionBehavior="deny",u.blockingError={blockingError:e.hookSpecificOutput.permissionDecisionReason||e.reason||"Blocked by hook",command:t};break;

The exit-code-2 command path uses a third, separate template, which is why its bracket contains the command string rather than a prompt:

`[${de}]: ${ce.stderr||"No stderr output"}`

The same template as the first appears in the 2.1.97 and 2.1.141 bundles as well, so this is longstanding rather than a recent regression — though static presence in an older bundle is not runtime proof, and I did not run those versions.

Environment and scope notes

  • Reproduced on Claude Code 2.1.234 (claude --version2.1.234 (Claude Code); the same string appears as "claude_code_version":"2.1.234" in every captured system/init event). Not re-run on 2.1.237.
  • Windows 11 Pro (build 26200), runs launched from Git Bash in non-interactive claude -p mode. Hook interpreter: Python 3.12.10.
  • The main agent model in these runs was Opus; the prompt-hook evaluator was claude-haiku-4-5-20251001, observed in the run's modelUsage (the documentation says only "defaults to a fast model"). The message is composed by the harness before the tool result reaches the model, so the model choice looks incidental.
  • Isolation: CLAUDE_CONFIG_DIR pointed at the throwaway directory; no .claude/ existed in the project directory; no file-based or policy-delivered managed settings existed on the machine. Every captured system/init reported "mcp_servers":[], built-in agents and skills only, and memory paths inside the throwaway directory. With {} as the settings file, Grep ran unimpeded.

Diagnosability

Whatever shape the fix takes, documenting the exact agent-facing deny-message format for prompt hooks in the hooks reference would spare the next person having to read the shipped bundle to find out whether the bracketed segment can be turned off.

View original on GitHub ↗