[BUG] PreToolUse updatedInput conflicts resolve by hook completion order, making multi-hook rewrites a race
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?
When two PreToolUse hooks both return updatedInput for the same tool call, the winner is whichever hook finishes last. Not registration order, not config scope. Hook latency decides which rewrite actually executes.
The docs say matching hooks run in parallel and say nothing about how conflicting rewrites resolve, so I probed it. Seven probes on 2.1.220, isolated project with its own git root, fresh claude -p each time. Sleeps are skewed to separate registration order from completion order:
probe setup last to finish executed
P1 rewrite A + passthrough B, equal latency A (tie) REWRITTEN_BY_A
P2 rewrite A + rewrite B, equal latency tie FROM_HOOK_A
P3 one rewriting hook per scope, equal latency PLUGIN FROM_PLUGIN
P4 A slow (registered 1st), B fast A FROM_HOOK_A
P5 plugin fast, other three slow 3-way tie FROM_SETTINGSFLAG
P6 A fast (registered 1st), B slow B FROM_HOOK_B
P7 project slow, other three fast PROJECT FROM_PROJECT
P6 rules out registration order: the first-registered hook finished 850ms early and the second-registered hook's rewrite is what ran. P7 rules out scope precedence: the plugin hook won P3 while finishing last, but make the project hook the slow one and project wins instead. Scopes tested were project .claude/settings.json, .claude/settings.local.json, --settings, and --plugin-dir.
Every unambiguous probe (P3, P4, P6, P7) fits one rule: last hook to finish wins. P2 and P5 are ties settled by sub-millisecond jitter. P6 and P7 each reproduce over three runs.
This is not theoretical. A hook that shells out, hits the network, or is slow under machine load can flip the outcome between runs on the same box, and nothing surfaces which hook won.
What Should Happen?
Conflict resolution should be deterministic and documented. Registration order within a scope, or a fixed precedence across scopes, would both be fine. Any fixed rule is fine. A rule that depends on how long a hook takes is not, because no guard can be written against it.
If the behavior is not going to change, the docs should state plainly that multi-hook rewrite resolution is undefined, so nobody builds on it.
Error Messages/Logs
# Minimal repro below, config 1: A registered first at 50ms, B second at 900ms
$ claude -p 'Run exactly this bash command and nothing else: echo RACE_PROBE' --allowedTools Bash
I ran `echo RACE_PROBE` exactly as asked, twice. Both times the tool result came
back as `FROM_B`, not `RACE_PROBE`.
# Config 2: sleeps swapped, A registered first at 900ms, B second at 50ms
$ claude -p 'Run exactly this bash command and nothing else: echo RACE_PROBE' --allowedTools Bash
FROM_A
# The slower hook wins in both directions, regardless of registration position.
Steps to Reproduce
- Scaffold a project:
``bash``
mkdir -p ~/hookrace/.claude && cd ~/hookrace
- Create the hook. It sleeps for a configurable time, then rewrites the command:
``bash``
cat > hook.py <<'EOF'
import json, sys, time
marker, sleep_ms = sys.argv[1], int(sys.argv[2])
payload = json.load(sys.stdin)
ti = payload.get("tool_input") or {}
time.sleep(sleep_ms / 1000.0)
if "RACE_PROBE" in ti.get("command", ""):
ti = dict(ti)
ti["command"] = "echo " + marker
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": ti}}))
EOF
- Register two rewriting hooks in one matcher group. A is first and fast, B is second and slow:
``bash``
cat > .claude/settings.json <<'EOF'
{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[
{"type":"command","command":"python3 ${CLAUDE_PROJECT_DIR}/hook.py FROM_A 50"},
{"type":"command","command":"python3 ${CLAUDE_PROJECT_DIR}/hook.py FROM_B 900"}
]}]}}
EOF
- Run it:
``bash``
claude -p 'Run exactly this bash command and nothing else: echo RACE_PROBE' --allowedTools Bash
Observed: FROM_B. The second-registered hook wins even though the first-registered one returned its rewrite 850ms earlier.
- Swap the two sleep values (
FROM_A 900andFROM_B 50) and re-run. Observed:FROM_A. The winner follows the slow hook, not the registration position.
Claude Model
Not sure / Multiple models
Is this a regression?
I don't know
Last Working Version
_N/A_
Claude Code Version
2.1.220 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Non-interactive/CI environment
Additional Information
There is a security consequence, because this composes badly with a second measured behavior: every hook receives the original tool_input, so rewrites do not chain and one hook cannot see another's rewrite. Combine the two and a hook that inspects tool_input and returns allow is approving a command that a different hook may already have replaced, with no way to observe the replacement. In the repro above, Claude itself noticed the tool result could not have come from the command it issued.
I measured this on 2.1.168 too and reported it as last-registered-wins in #66203. That reading was under-determined rather than wrong: every hook in that probe had the same latency, so registration order and completion order moved together. Skewing the sleeps is what pulls them apart.
Related: #66203 tracks the documentation gap for multi-hook PreToolUse semantics generally. #15897 reported updatedInput being dropped entirely with multiple hooks; that one is fixed as of 2.1.220, and this is a different defect.
Happy to share the full probe harness if it's useful.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗