[BUG] updatedInput PreToolUse response does not work when multiple PreToolUse hooks are executed

Resolved 💬 20 comments Opened Dec 31, 2025 by werdnum Closed May 14, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

I have been trying to set up a PreToolUse hook to modify bash commands (add timeout, rewrite known bad patterns) but no matter how hard I try, I can't get it to work.

See #9185, #4368

What Should Happen?

Updated Input should be respected

Error Messages/Logs

Claude's diagnosis:

Bug Report: updatedInput ignored in PreToolUse hooks

Environment

  • Claude Code version: 2.0.76
  • Platform: Linux (devcontainer)

Summary

PreToolUse hooks that return permissionDecision: "allow" with updatedInput have the updatedInput field completely ignored. The original tool input is executed instead of the modified input.

Blocking (exit code 2) works correctly. Only updatedInput with allow is broken.

Hook Script Output (correct)

When run manually:

$ echo '{"tool_name": "Bash", "tool_input": {"command": "echo foo"}}' | python3 /path/to/hook.py

stdout:

{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Rewriting command: 'echo foo' → 'echo bar'\n Reason: TEST rule",
"updatedInput": {
"command": "echo bar"
}
}
}

The hook script exits with code 0 and outputs valid JSON with updatedInput.

Claude Code Streaming Output (shows bug)

$ echo "run: echo foo" | claude -p --debug hooks --output-format stream-json --verbose 2>&1

Tool call from Claude:

{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01...","name":"Bash","input":{"command":"echo foo","description":"Print 'foo' to stdout"}}]}}

Tool result (shows original command ran, not modified):

{"type":"user","message":{"content":[{"tool_use_id":"toolu_01...","type":"tool_result","content":"foo","is_error":false}]},"tool_use_result":{"stdout":"foo","stderr":"","interrupted":false,"isImage":false}}

  • Expected: "stdout":"bar" (modified command should run)
  • Actual: "stdout":"foo" (original command ran, updatedInput ignored)

Blocking Mode Works (for comparison)

When hook returns exit code 2 with stderr message instead of JSON with allow:

{"type":"user","message":{"content":[{"type":"tool_result","content":"Hook PreToolUse:Bash denied this tool","is_error":true,"tool_use_id":"toolu_01..."}]},"tool_use_result":"Error: Hook PreToolUse:Bash denied this tool"}

This correctly blocks the tool and Claude sees the error. So hooks ARE running, just updatedInput is ignored.

Minimal Reproduction

  1. Create hook script that returns updatedInput:

#!/usr/bin/env python3
import json, sys
input_data = json.load(sys.stdin)
if input_data.get("tool_name") == "Bash":
cmd = input_data.get("tool_input", {}).get("command", "")
if cmd == "echo foo":
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {"command": "echo bar"}
}
}))
sys.exit(0)
sys.exit(0)

  1. Configure in settings.json:

{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "python3 /path/to/hook.py"}]
}]
}
}

  1. Run: echo "run: echo foo" | claude -p
  1. Expected: Output shows "bar"
  2. Actual: Output shows "foo"

Related Issues

  • #4368 - Feature request (closed as completed in v2.0.10)
  • #9185 - Documentation issue noting feature exists but undocumented
  • #11113 - TypeError crash with updatedInput on Grep tool

hook script that returns updatedInput:

#!/usr/bin/env python3
import json, sys
input_data = json.load(sys.stdin)
if input_data.get("tool_name") == "Bash":
cmd = input_data.get("tool_input", {}).get("command", "")
if cmd == "echo foo":
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {"command": "echo bar"}
}
}))
sys.exit(0)
sys.exit(0)

  1. Configure in settings.json:

{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{"type": "command", "command": "python3 /path/to/hook.py"}]
}]
}
}

  1. Run: echo "run: echo foo" | claude -p
  1. Expected: Output shows "bar"
  2. Actual: Output shows "foo"

Related Issues

  • #4368 - Feature request (closed as completed in v2.0.10)
  • #9185 - Documentation issue noting feature exists but undocumented
  • #11113 - TypeError crash with updatedInput on Grep tool

Steps to Reproduce

  1. Create hook script that returns updatedInput:
#!/usr/bin/env python3
import json, sys
input_data = json.load(sys.stdin)
if input_data.get("tool_name") == "Bash":
    cmd = input_data.get("tool_input", {}).get("command", "")
    if cmd == "echo foo":
        print(json.dumps({
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "allow",
                "updatedInput": {"command": "echo bar"}
            }
        }))
        sys.exit(0)
sys.exit(0)
  1. Configure in settings.json:
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{"type": "command", "command": "python3 /path/to/hook.py"}]
    }]
  }
}
  1. Run: echo "run: echo foo" | claude -p
  1. Expected: Output shows "bar"
  2. Actual: Output shows "foo"

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.0.76

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Other

Additional Information

_No response_

View original on GitHub ↗

20 Comments

github-actions[bot] · 6 months ago

---

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/11282
  2. https://github.com/anthropics/claude-code/issues/12031
  3. https://github.com/anthropics/claude-code/issues/13439

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

werdnum · 6 months ago

I had Claude do some reverse engineering. It seems like the problem occurs when there are multiple hooks for the same tool.

<details>

<summary>Claude's analysis</summary>

Bug Analysis: updatedInput ignored in PreToolUse hooks

Environment

  • Claude Code version: 2.0.76
  • Platform: Linux (aarch64, devcontainer)

Summary

PreToolUse hooks that return permissionDecision: "allow" with updatedInput have the updatedInput field ignored when multiple hooks are registered for the same tool. The original tool input is executed instead of the modified input.

Root Cause

Found via code archaeology in /home/claude/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js:

The bug is in the hook result aggregation logic in the ms function. When multiple hooks run:

  1. permissionBehavior (allow/deny/ask) is correctly aggregated across hooks using a tracking variable E
  2. BUT updatedInput is NOT aggregated - each hook's yield uses only its own z.updatedInput
  3. The consuming code (Gu5) overwrites z with each hookPermissionResult: case"hookPermissionResult":z=j.hookPermissionResult;break;
  4. The LAST hook's result wins, even if it has updatedInput: undefined

The Buggy Code Pattern

// E correctly aggregates permissionBehavior across hooks
if(z.permissionBehavior)switch(z.permissionBehavior){
  case"deny":E="deny";break;
  case"ask":if(E!=="deny")E="ask";break;
  case"allow":if(!E)E="allow";break;
  case"passthrough":break
}

// BUG: This yields after EVERY hook when E is set
// It uses the CURRENT hook's z.updatedInput, not an aggregated value
if(E!==void 0)yield{
  permissionBehavior:E,
  hookPermissionDecisionReason:z.hookPermissionDecisionReason,
  updatedInput:z.updatedInput&&z.permissionBehavior==="allow"?z.updatedInput:void 0
};

What Happens With Multiple Hooks

Example: User hook + bash-guard plugin (2 hooks for "Bash" tool)

  1. Hook 1 (user's hook): Returns {permissionDecision: "allow", updatedInput: {command: "echo bar"}}
  • E is set to "allow"
  • Yields: {permissionBehavior:"allow", updatedInput:{command:"echo bar"}}
  • In Gu5: z = {behavior:"allow", updatedInput:{command:"echo bar"}}
  1. Hook 2 (bash-guard): Returns nothing (exit 0, no JSON = passthrough)
  • z.permissionBehavior is undefined
  • E stays "allow" (already set from hook 1)
  • Since E !== void 0, it yields AGAIN:

{permissionBehavior:"allow", updatedInput:undefined} (because z.updatedInput for THIS hook is undefined)

  • In Gu5: z = {behavior:"allow", updatedInput:undefined} - OVERWRITES HOOK 1's RESULT
  1. Result: Tool executes with original input, updatedInput is lost

Reproduction

Minimal Hook Script (Node.js)

#!/usr/bin/env node
const Fs = require("node:fs");

async function main() {
  const chunks = [];
  for await (const chunk of process.stdin) chunks.push(chunk);
  const json = JSON.parse(Buffer.concat(chunks).toString("utf8"));

  Fs.appendFileSync("/tmp/debug.log", `input: ${JSON.stringify(json)}\n`);

  if (json.tool_name === "Bash" && json.tool_input?.command === "echo foo") {
    const res = JSON.stringify({
      hookSpecificOutput: {
        permissionDecision: "allow",
        updatedInput: { command: "echo bar" }
      }
    });
    Fs.appendFileSync("/tmp/debug.log", `out: ${res}\n`);
    process.stdout.write(res);
    process.exit(0);
  }
  process.exit(0);
}

main().catch((e) => { console.error(e); process.exit(2); });

Configuration

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{"type": "command", "command": "node /path/to/hook.js"}]
    }]
  }
}

Test Command

echo 'run: echo foo' | claude -p --output-format stream-json --verbose

Debug Log Shows Hook Works Correctly

input: {"tool_name":"Bash","tool_input":{"command":"echo foo",...}}
out: {"hookSpecificOutput":{"permissionDecision":"allow","updatedInput":{"command":"echo bar"}}}

But Stream Output Shows Original Command Ran

{"tool_use_result":{"stdout":"foo","stderr":"","interrupted":false}}

Expected: "stdout":"bar"

Why It Works For Some People

The bug only manifests when multiple hooks are registered for the same tool matcher. If you have:

  • Only ONE PreToolUse hook for "Bash" → Works correctly
  • Multiple hooks (user hook + plugin hooks) → Last hook's result wins

Most users testing updatedInput probably don't have plugins with PreToolUse hooks installed, so their single hook works fine.

Proposed Fix

Track updatedInput across hooks similar to how permissionBehavior is tracked:

let E;  // existing
let aggregatedUpdatedInput;  // NEW

// In the loop over hooks:
if(z.updatedInput && z.permissionBehavior === "allow") {
  aggregatedUpdatedInput = {...aggregatedUpdatedInput, ...z.updatedInput};
}

// When yielding:
if(E !== void 0) yield {
  permissionBehavior: E,
  hookPermissionDecisionReason: z.hookPermissionDecisionReason,
  updatedInput: aggregatedUpdatedInput  // Use aggregated value
};

Related Issues

  • #4368 - Feature added in v2.0.10 (claimed completed)
  • #9185 - Documentation missing
  • #11113 - TypeError with updatedInput on Grep tool
  • #11282 - Can't use updatedInput with permissionDecision "ask"
  • #15897 - Initial bug report for this issue

Workaround

Until fixed, users can:

  1. Disable all plugins that register PreToolUse hooks for the same tool
  2. Use the "block and suggest" pattern instead (exit code 2 with stderr message)

</details>

coygeek · 6 months ago

Independent Confirmation + Additional Findings

I can confirm this bug on Claude Code 2.0.76 (macOS, Anthropic OAuth).

Test Setup

I created isolated tests with minimal hooks to verify the behavior:

Hook 1 (modifier): Returns updatedInput: {command: "echo MODIFIED"} when it sees echo ORIGINAL
Hook 2 (passthrough): Returns empty {}

Results

| Config Order | Result | Notes |
|--------------|--------|-------|
| Modifier only | MODIFIED | Single hook works correctly |
| [modifier, passthrough] | MODIFIED | Modifier FIRST → works |
| [passthrough, modifier] | ORIGINAL | Modifier SECOND → broken |

Key Finding: Position-Dependent, Not "Last Wins"

The bug behavior is more nuanced than "last hook overwrites." My testing shows:

  • Modifier in position 1updatedInput is preserved
  • Modifier in position 2+updatedInput is silently discarded

This means even a passthrough hook that returns {} (and doesn't touch updatedInput) will break subsequent hooks that DO return updatedInput.

Real-World Impact

I have 12 PreToolUse hooks on the Bash matcher. Three of them use updatedInput to:

  • Inject -y flags for package managers (non-interactive mode)
  • Inject -v flags for file operations (verbose output)
  • Split chained git commands

These hooks are at positions 3-5, with passthrough hooks at positions 1-2. Based on my testing, all three have been silently broken this entire time—commands execute as originally written despite the hooks outputting correct updatedInput responses.

Workaround

Move all updatedInput hooks to the beginning of the chain (position 1, 2, 3...). Passthrough hooks should come after.

Limitation: Only the FIRST updatedInput hook that fires will have its modification applied. Multiple updatedInput modifications cannot compose.

Logs Confirming Hook Execution

My hooks log their input/output to files. The modifier hook IS being called and IS returning correct JSON:

INPUT: {"tool_name": "Bash", "tool_input": {"command": "echo ORIGINAL"}, ...}
OUTPUT: {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": {"command": "echo MODIFIED"}}}

Yet the executed command is echo ORIGINAL. The updatedInput is being discarded somewhere in the aggregation logic.

---

Environment:

  • Claude Code: 2.0.76
  • Platform: macOS (Darwin 25.1.0)
  • Auth: Anthropic OAuth
github-actions[bot] · 5 months ago

This issue has been inactive for 30 days. If the issue is still occurring, please comment to let us know. Otherwise, this issue will be automatically closed in 30 days for housekeeping purposes.

werdnum · 5 months ago

I was still able to reproduce this issue today.

danielbodart · 4 months ago

Another real-world case: graphical-sudo plugin

Hitting this exact bug with graphical-sudo, a plugin that intercepts sudo commands and rewrites them to use sudo -A with a zenity askpass for graphical password prompts on Linux.

The hook output (correct per docs)

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "updatedInput": {
      "command": "SUDO_ASKPASS=\"/path/to/zenity-askpass.sh\" sudo -A ls"
    }
  }
}

Confirmed: single hook works, multiple hooks breaks

  • With the hookify plugin installed (which registers a PreToolUse hook with no matcher, matching all tools), updatedInput is silently ignored — the original sudo ls runs unmodified.
  • After uninstalling hookify, updatedInput works perfectly — zenity dialog appears, graphical auth succeeds.
  • permissionDecision: "allow" is respected in both cases (no permission prompt). Only updatedInput is lost.
  • Tested on Claude Code 2.1.47, both default and bypassPermissions permission modes.
  • Also tried the deprecated top-level decision: "approve" format with updatedInput — same result.

Impact

This makes updatedInput unusable in any real plugin ecosystem, since users will almost certainly have multiple plugins installed that register PreToolUse hooks (hookify, security-guidance, etc.). The feature only works in isolation.

acushner-rippling · 4 months ago

Hitting this as well. Reproducing reliably on v2.1.51, macOS, --dangerously-skip-permissions mode.

Setup: 4 PreToolUse hooks match Bash tool calls — 2 with no matcher (debug logger, mutation blocker) and 2 with "matcher": "Bash" (command rewriter using updatedInput, task stop blocker). The rewriter produces valid hookSpecificOutput JSON with updatedInput to rewrite commands (confirmed via separate file logging + xxd showing clean { as first byte on stdout).

Observed: --debug log shows all 4 hooks reporting Hook output does not start with {, treating as plain text — including the hook that definitely outputs valid JSON. The updatedInput is applied ~1 in 5 times (race-dependent). The debug output doesn't identify which hook produced which output, making this very difficult to diagnose.

Root cause confirmation: From the decompiled hook aggregation logic, permissionBehavior is tracked in a persistent variable across hooks, but updatedInput is read from each individual hook result — so the last hook processed overwrites it, and since hooks run in parallel, the order is non-deterministic.

Workaround: Switched to permissionDecision: "deny" with the rewritten command in permissionDecisionReason, which forces a retry. Works reliably but adds a round-trip per rewrite. Also including updatedInput in the response for when this gets fixed.

Would love to see either:

  1. updatedInput aggregated/preserved across hooks (non-null wins over null)
  2. An option for sequential hook execution
acushner-rippling · 4 months ago

+1

oxysoft · 4 months ago

Another real-world case: tokf + babel (daemon telemetry) collision

Hitting this on v2.1.50, Linux (Arch/CachyOS), bypass-permissions mode.

Setup

Two PreToolUse hooks, both matching Bash:

  1. tokf (matcher: "Bash") — token filter that rewrites commands to compress output for LLM context. Returns valid updatedInput JSON:

``json
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow",
"updatedInput":{"command":"tokf run git status"}}}
``

  1. babel (matcher: "", catch-all) — OS daemon that indexes Claude sessions across terminals. Purely observational: sets a ToolRunning state in sqlite + pushes to daemon via IPC for live UI. Returns no output (exit 0).

Result

tokf's updatedInput is silently discarded 100% of the time. The original command executes unfiltered. Debug log confirms the rewrite is parsed correctly but never applied:

[DEBUG] Hooks: Parsed initial response: {...updatedInput:{command:"tokf run git status"}}
[DEBUG] Hook output does not start with {, treating as plain text   ← babel's empty response
[DEBUG] Hook approved tool use for Bash, bypassing permission check  ← original command runs

Why this is deterministic (not a race)

tokf is a direct exec into a Rust binary (~37ms). babel's hook goes through bashcat → 2× jqbabel binary (~96ms). tokf always finishes first, so babel's response is always the last yield in the aggregation loop, always clobbering updatedInput with undefined.

This makes it worse than a flaky race — it's a guaranteed, silent, 100% failure that produces no error, no warning, and no indication that the rewrite was dropped. The hook system reports success.

Broader implications

This bug means any two PreToolUse hooks that match the same tool are fundamentally incompatible if either uses updatedInput. The failure mode is silent — both hooks report success, but the rewrite never applies. This is especially insidious for the emerging ecosystem of hook-based tools (tokf, graphical-sudo, babel, etc.) because:

  • Users install tools independently and have no way to know they'll collide
  • The tools themselves work perfectly in isolation
  • There is no diagnostic output indicating the collision
  • The only workaround is a single-dispatcher pattern that manually coordinates all PreToolUse hooks into one entry — defeating the composability that the hook system promises

Suggested fix

The aggregation loop should accumulate updatedInput across hooks (non-null wins over null, or deep-merge), not re-read it from each individual hook response. The permissionBehavior aggregation already does this correctly (deny > ask > allow) — updatedInput just needs the same treatment.

Alternatively, a permissionDecision: "passthrough" that truly passes through (skips the yield entirely) would let observational hooks coexist with rewriting hooks without interference.

Itzik-Trullion · 4 months ago

any update on it ? Thanks.

christophercolumbusdog · 4 months ago

I swear that hooks are the single most bug-ridden feature in all of Claude Code. It's crazy that the case of multiple hooks existing isn't already covered in tests, or at least fixed in the past few months since this issue was opened.

gabelevi · 4 months ago

I just ran into this too. I was hoping to have a PreToolUse hook automatically fill in the session and agent ids for certain tool calls. Pity it doesn't work - updatedInput feels like a pretty powerful tool!

SaltyFish0309 · 3 months ago

I can confirm this bug still exists in Claude Code v2.1.81. I was trying to install rtk-ai/rtk for my Claude Code and encountered this issue. I spent all the quota I have for one session to finally find this bug (kind of going against the purpose of rtk. That's funny).
I call for Anthropic's attention on this bug.

eyudkin · 3 months ago

Approve, we really need that to be fixed

yurukusa · 3 months ago

/tmp/issue-15897-comment.md

jonathanmalkin · 3 months ago

Additional repro: updatedInput ignored even as the last/only rewriting hook

Claude Code: 2.1.87
OS: macOS (Darwin 25.3.0, arm64)
Permission mode: bypassPermissions

Setup

  • Two PreToolUse hooks on Bash matcher: safety-guard.sh (security checks, exits 0 on allow) → rtk-rewrite.sh (rewrites commands via RTK)
  • RTK hook runs last — nothing overwrites its output

What I did

Added debug logging to rtk-rewrite.sh that writes every invocation to /tmp/rtk-hook-debug.log. Ran git status via Bash tool.

Debug log output

23:22:34 INVOKED input_len=448
23:22:34 REWRITE 'git status' -> 'rtk git status'
23:22:34 OUTPUT={"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"RTK auto-rewrite","updatedInput":{"command":"rtk git status","description":"Test command that RTK should rewrite"}}}

Result

Claude Code ran the original git status (16 lines of unfiltered output) instead of rtk git status (compact 6 lines). The hook fired, produced valid JSON, and was completely ignored.

Variations tested (all ignored)

  1. updatedInput inside hookSpecificOutput with permissionDecision: "allow" ← documented format
  2. updatedInput inside hookSpecificOutput without permissionDecision
  3. updatedInput at top level (outside hookSpecificOutput)

Why this matters beyond multi-hook aggregation

The original issue describes the last hook's undefined updatedInput overwriting earlier hooks. In my case, RTK is the last hook — there is nothing to overwrite it. This suggests updatedInput for Bash commands is not being applied at all, not just a multi-hook ordering problem.

Workaround

Shell functions in ~/.zshenv bypass the hook system entirely:

if command -v rtk &>/dev/null 2>&1; then
  git()  { command rtk git "$@"; }
  ls()   { command rtk ls "$@"; }
  find() { command rtk find "$@"; }
  # ... etc
fi

.zshenv is sourced for all zsh invocations including zsh -c -l (how Claude Code runs Bash commands).

juanpablodlc · 3 months ago

+1 I can confirm this is still a bug that needs fixing please!!!

shihyuho · 3 months ago

+1 — hit this with RTK's PreToolUse rewrite hook: adding any second matching hook causes the first hook's updatedInput to be silently dropped.

github-actions[bot] · 2 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

karlkfi · 1 month ago

This appears to be fixed as of 2.1.168 (macOS), even though it was closed not planned.

Repro of the exact condition described here — a Bash PreToolUse chain with a rewrite hook (returns permissionDecision: "allow" + updatedInput) followed by a passthrough hook (exit 0, no JSON), registered in that order, plus other passthrough hooks (guard plugins):

  • The command actually executed was the rewritten one — updatedInput was not overwritten by the trailing passthrough hook(s). That's the failure this issue reported, and it no longer reproduces.
  • The passthrough hook that ran after the rewrite hook received the original command, confirming hooks each get the original tool_input (no chaining) — so the surviving updatedInput is applied at execution, not propagated hook-to-hook.

So the aggregation bug analyzed above (a later passthrough's empty result clobbering an earlier hook's updatedInput) seems resolved in current builds.

The behavior is still undocumented, though, so it's only verifiable by reverse-engineering per version. I opened #66203 to request that the multi-hook PreToolUse semantics (execution order, updatedInput visibility/precedence across hooks, conflict resolution) be documented so people don't have to rediscover this.