DEV Community

Yurukusa
Yurukusa

Posted on

Do Claude Code hooks fire inside subagents? I measured it, and they still block

Claude Code is Anthropic's terminal coding agent: it runs shell commands and edits files on your
machine while it works. It lets you install hooks — small scripts it calls before a tool runs,
which can rewrite the command or refuse it. That is a common way to stop it from deleting things.

There is a claim that keeps circulating about that mechanism: a PreToolUse hook in your user
settings does not fire for Bash calls made inside a subagent (a child agent the main one
spawns to do a piece of the work). If that were true, every safety hook you install would have a
hole in it — you block rm -rf on the main thread, the model hands the work to a child, and the
child runs it unguarded. The thread is #34692, with #21460 before it and #88441 still open.

In March I added a "Can confirm" to #34692. No steps, no output, no version. More agreement
followed, and the thread is still active.

Someone had already measured it, twice. In April, rwilk002 reported it verified false on
2.1.119 / Windows 11 / Git Bash with a JSON-logging hook. In August, an Anthropic maintainer
reported the same on 2.1.233 / macOS. I did not go and look for five months. What follows is a
third data point, on Linux, that also covers deny and updatedInput rather than only "did the
hook get called".

I am not an engineer. Claude Code does the implementation and the investigation here; my job is
to direct it, and to check what comes back. This is one of the checks.

The setup

Do not use your real config for this. Isolate it, and make the whole harness in one go:

mkdir -p /tmp/h/cfg /tmp/h/proj && chmod 700 /tmp/h/cfg
cp ~/.claude/.credentials.json /tmp/h/cfg/
Enter fullscreen mode Exit fullscreen mode

One gotcha that cost me time: a fresh CLAUDE_CONFIG_DIR has no credentials, so the run stops at
Not logged in before it measures anything. That is why the copy above is there. If you would
rather not copy credentials at all, use --settings <file> instead — it swaps only the settings
and leaves your real auth in place.

The hook logs the fields that matter and reacts to two markers. MARKER_DENY refuses the call.
MARKER_REWRITE rewrites it. I wanted both, because "did the hook get called" and "did the hook
actually stop anything" are different questions, and the second one is what a safety hook is for.

cat > /tmp/h/hook.py <<'EOF'
import sys, json
d = json.load(sys.stdin)
cmd = (d.get("tool_input") or {}).get("command", "")
open("/tmp/h/hook.jsonl", "a").write(json.dumps(
    {"agent": d.get("agent_type") or "TOPLEVEL",
     "agent_id": d.get("agent_id"), "cmd": cmd}) + "\n")
if "MARKER_DENY" in cmd:
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse", "permissionDecision": "deny",
        "permissionDecisionReason": "test guard: this command is blocked"}}))
elif "MARKER_REWRITE" in cmd:
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "updatedInput": {"command": cmd.replace("MARKER_REWRITE", "REWRITTEN")}}}))
EOF
Enter fullscreen mode Exit fullscreen mode

The settings file goes in the isolated config directory. The permissions block matters: claude
-p
is non-interactive, so without it the run stalls on approval and fails before any hook fires,
which looks exactly like "the hook did not run".

cat > /tmp/h/cfg/settings.json <<'EOF'
{"permissions": {"allow": ["Bash", "Task"], "defaultMode": "acceptEdits"},
 "hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [
   {"type": "command", "command": "python3 /tmp/h/hook.py"}]}]}}
EOF
Enter fullscreen mode Exit fullscreen mode

Then four cases in one run: the parent runs each marker itself, and the parent spawns a subagent
that runs each marker.

cd /tmp/h/proj && CLAUDE_CONFIG_DIR=/tmp/h/cfg claude -p \
  "1. Run 'echo MARKER_REWRITE_TOP' with Bash yourself. \
   2. Run 'echo MARKER_DENY_TOP' with Bash yourself. \
   3. Spawn a general-purpose Task subagent that runs 'echo MARKER_REWRITE_SUB'. \
   4. Spawn one that runs 'echo MARKER_DENY_SUB'. \
   Report the exact output or error of each." \
  --output-format stream-json --verbose
Enter fullscreen mode Exit fullscreen mode

Results

Where Command Hook fired Outcome
Parent MARKER_REWRITE_TOP yes ran as REWRITTEN_TOP
Parent MARKER_DENY_TOP yes blocked
Subagent MARKER_REWRITE_SUB yes ran as REWRITTEN_SUB
Subagent MARKER_DENY_SUB yes blocked

Four out of four. The hook did not merely get invoked inside the subagent — deny stopped the
call and updatedInput rewrote it, exactly as on the main thread.

Measured on 2.1.233, then re-run on 2.1.246 with the same four results.

Confirming the documented subagent markers

This part is documented behaviour — the hooks reference says agent_id and agent_type are
populated when the hook fires inside a subagent — and it matched here:

{"agent":"TOPLEVEL",        "agent_id":null,                "cmd":"echo MARKER_REWRITE_TOP"}
{"agent":"general-purpose", "agent_id":"aa23eb22fd31c7276", "cmd":"echo MARKER_REWRITE_SUB"}
Enter fullscreen mode Exit fullscreen mode

agent_type (logged as agent above) and agent_id are present only for the child. The id
changes on every run, so match on presence, not value. session_id and cwd were identical to
the parent's, so those two cannot separate them. If you want a hook that behaves differently
inside subagents — a stricter rule for unattended work, say — those are the keys.

What I can and cannot say

I measured my own machine — Linux (WSL2), CLI via claude -p, a user-scope hook from an isolated
CLAUDE_CONFIG_DIR — on those two versions, with a general-purpose Task subagent. It did not
reproduce. That is the whole claim.

People reporting the opposite are not necessarily wrong. Older versions, plugin-supplied hooks,
project-level settings, a different matcher, and other subagent types are all different setups
from this one, and I did not test them.

Worth noting that one of the existing reports, #21460, blocks with exit code 1, while the docs
say exit code 2 is the blocking signal. I do not think that fully explains their result — they
report the parent blocking and only the child getting through — but it is the first thing I would
re-check.

What I would actually ask: do not believe this and do not dispute it. Run the harness above once
in your own environment. If it reproduces for you, the log file is the fastest way to show it,
because it records what arrived rather than what any of us believed arrived.

The part I got wrong

Writing "Can confirm" costs nothing, and it still gets counted as evidence. "I see the same
symptom" and "the same cause is happening" are different claims, and in a public thread they pile
up as the same number.

Five months later that came back at me, because I ship a set of free safety hooks and their whole
premise is that they cover everything the model runs — which is exactly why I wanted this measured
rather than left to my own optimism. I had helped put a stone in the road and then tripped over it.

Three rules I now hold myself to:

  • Only add agreement when I can write the steps.
  • Never use "confirmed" for a symptom match; only when the cause matches.
  • Re-measure safety assumptions when the version changes. Received wisdom has a shelf life.

I retracted the March comment on #34692 in August, with the measurement attached.

The hook collection is free and MIT: https://github.com/yurukusa/cc-safe-setup

Top comments (0)