[BUG] PreToolUse updatedInput conflicts resolve by hook completion order, making multi-hook rewrites a race

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 2 comments · opened Aug 2, 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?

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

  1. Scaffold a project:

``bash
mkdir -p ~/hookrace/.claude && cd ~/hookrace
``

  1. 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
``

  1. 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
``

  1. 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.

  1. Swap the two sleep values (FROM_A 900 and FROM_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.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗