Do PreToolUse lint gates cover your subagents?
Settings hooks fire on subagent tool calls. The payload gets agent_id and agent_type and keeps the main transcript_path. Includes a synthetic repro, the bug report that started it, and what the repro can't prove.
Yes. Hooks in your settings files fire on subagent tool calls just like they do on the main thread, and exit 2 still refuses the call. The payload gets two extra fields, `agent_id` and `agent_type`. `transcript_path` doesn't change. If your gate applies the same rule to everyone you don't need a subagent check at all. If it has to branch, branch on `agent_id`.
The issue behind this post is anthropics/claude-code#84701, titled "PreToolUse hook "deny" decisions not enforced for Bash commands issued by Task-tool subagents". It was opened 2026-08-07.
The reporter had an unattended background subagent run rm -rf on a home directory. After that they changed their PreToolUse hook, guardian.js, to hard-deny destructive patterns when the command came from a subagent. It decided a call was from a subagent if transcript_path contained /subagents/. They tested it the next day. A subagent ran find -type f -delete and chmod -R 777 against throwaway files and both went through. They checked afterward and the files were gone and the permissions were 777.
There's one comment on the issue and it says the hook never sent a deny at all. i'll get to why further down.
What the hooks reference says
The hooks reference is pretty direct about it:
Hooks from settings files, managed policy settings, and plugins also run inside subagents. When a subagent calls a tool, tool events such as
PreToolUseandPostToolUsefire the same configured hooks as in the main conversation, and the input carries theagent_idandagent_typecommon input fields that identify the subagent.
The subagents page says the same thing. A PreToolUse hook in settings.json "also runs before every tool a subagent uses." Same events and same exit codes.
That's only for hooks in settings files. Hooks in a subagent's own frontmatter work differently and I cover those further down.
One gate for both callers
The repro has one PreToolUse gate in .claude/settings.json. There's a second handler in the same matcher group that just dumps the payload to a log. It always exits 0 and doesn't decide anything. The docs say "All matching hooks run in parallel", so the gate isn't waiting on the dumper and if the dumper breaks it can't open the gate.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|Bash",
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/dump-payload.mjs"], "timeout": 10 },
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/gate.mjs"], "timeout": 30, "onFailure": "block" }
]
}
],
"SubagentStart": [
{ "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/dump-payload.mjs"], "timeout": 10 } ] }
],
"SubagentStop": [
{ "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/dump-payload.mjs"], "timeout": 10 } ] }
]
}
}Both handlers use exec form (command plus args). The reference says to set args any time a hook references a path placeholder, "since each element is passed as one argument with no quoting." Only the gate has onFailure. I come back to that in leaks section below.
This is the whole gate.
// PreToolUse gate. Same rule for every caller: main thread or subagent.
// Refuses ambient time (new Date() / Date.now()) landing in src/services/**,
// via Write/Edit/MultiEdit content or via a Bash command that writes there.
// agent_id is read only to label the refusal. It never changes the verdict.
let raw = '';
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) raw += chunk;
const p = JSON.parse(raw);
const who = p.agent_id ? `subagent ${p.agent_type ?? '?'} (${p.agent_id})` : 'main thread';
const AMBIENT = /\bnew\s+Date\s*\(\s*\)|\bDate\.now\s*\(/;
const inServices = (f) => /(^|\/)src\/services\//.test(f ?? '');
function refuse(why) {
process.stderr.write(
`Refused for ${who}: ${why}\n` +
`Time in src/services comes from the injected Clock (src/kernel/clock.ts).\n` +
`Moving the call to another folder, writing it through Bash, or adding a disable comment will also be refused.\n`);
process.exit(2);
}
const t = p.tool_name, i = p.tool_input ?? {};
if (t === 'Write' && inServices(i.file_path) && AMBIENT.test(i.content ?? '')) refuse(`Write ${i.file_path}`);
if (t === 'Edit' && inServices(i.file_path) && AMBIENT.test(i.new_string ?? '')) refuse(`Edit ${i.file_path}`);
if (t === 'MultiEdit' && inServices(i.file_path) && (i.edits ?? []).some((e) => AMBIENT.test(e.new_string ?? ''))) refuse(`MultiEdit ${i.file_path}`);
if (t === 'Bash') {
const c = i.command ?? '';
const writesServices = /src\/services\//.test(c) && /(>>?|\btee\b|\bsed\s+-i|\bperl\s+-p?i|\bcp\b|\bmv\b|\bpython3?\s+-c)/.test(c);
if (writesServices && (AMBIENT.test(c) || /Date/.test(c))) refuse(`Bash write into src/services`);
}
process.exit(0);agent_id shows up on one line and that line only builds the label for the error message. The verdict comes from the file and the content. It doesn't matter who asked for the write, so theres no subagent branch to get wrong. The refusal also lists the workarounds an agent usually tries next. I did the same thing with the denial in writing an ESLint rule that can refuse an agent's write.
./run-payloads.sh pipes hand-written PreToolUse payloads into the hook on stdin and records the exit code and stderr. Payloads 01 and 02 are the same bad Write. One is shaped like a main-thread call and the other like a subagent call. The diff between them:
payload 01 vs 02: identical apart from agent_id/agent_type
1a2,3
> "agent_id": "acca37433b29c5d00",
> "agent_type": "service-writer",Running the gate on both, plus a clean Write from the subagent:
=== 01-main-bad-write tool=Write agent_id=- exit=2
Refused for main thread: Write /repo/src/services/schedule.ts
Time in src/services comes from the injected Clock (src/kernel/clock.ts).
Moving the call to another folder, writing it through Bash, or adding a disable comment will also be refused.
=== 02-sub-bad-write tool=Write agent_id=acca37433b29c5d00 exit=2
Refused for subagent service-writer (acca37433b29c5d00): Write /repo/src/services/schedule.ts
Time in src/services comes from the injected Clock (src/kernel/clock.ts).
Moving the call to another folder, writing it through Bash, or adding a disable comment will also be refused.
=== 03-sub-clean-write tool=Write agent_id=acca37433b29c5d00 exit=0These payloads are synthetic. I shaped them after the hooks reference and the payload dump in the #84701 comment. None of it was captured from Claude Code. So this output only shows the gate's logic on two inputs. Whether Claude Code actually sends a subagent's call through this hook comes down to the docs quoted above and the commenter's test, which I go through next.
Telling a subagent call apart
Check agent_id. The reference says it identifies "the subagent or in-process teammate the hook fires in", so a normal main-thread call won't have it. transcript_path points at the main session's file on every event. That means transcript_path.includes('/subagents/') is false every time, and whatever rule sits behind it just quietly never runs.
That's what the comment on #84701 found. yurukusa posted it on 2026-08-26. They dumped full PreToolUse payloads on Claude Code 2.1.246, once at top level and once inside a subagent. session_id and transcript_path were byte-identical in both, and the subagent payload had exactly two extra keys, agent_id and agent_type. So the reporter's /subagents/ check could never match. The hard-deny branch never ran and no deny went back to Claude Code. From the comment: "From outside that is indistinguishable from an enforcement bug."
The repro has both versions of that check and feeds them the same find -delete payload. break/gate-transcript-branch.mjs branches on transcript_path like the issue describes:
=== 06-main-find-delete tool=Bash agent_id=- exit=0
=== 07-sub-find-delete tool=Bash agent_id=acca37433b29c5d00 exit=0break/gate-agent-id-branch.mjs is the same idea but branches on agent_id:
=== 06-main-find-delete tool=Bash agent_id=- exit=0
=== 07-sub-find-delete tool=Bash agent_id=acca37433b29c5d00 exit=2
Refused: destructive command from subagent service-writer (acca37433b29c5d00)The only difference between the two scripts is which field they test. Exit 0 with nothing on stdout means the hook made no decision, so the call goes through.
The reporter had also piped the command into guardian.js as synthetic stdin and got a correct deny. The comment asks if that stdin had a transcript_path with /subagents/ in it. If it did then the pipe test and the live run took different branches. The reporter hasn't answered. My repro could fall into the same trap, so its subagent payloads use the main session's path.
#76333 ran into the same field from the other direction. That reporter's gate read transcript_path to check that a required step had happened before an edit. Inside a subagent it read the parent transcript, didn't find any of the subagent's own tool calls, and denied every subagent Edit and Write. So a transcript_path mistake can fail open or fail closed. Depends how you wrote the condition.
If a hook actually needs the subagent's own transcript you have to build the path yourself. bcherny replied on #76333 on 2026-08-17 that transcript_path pointing at the main transcript "is currently intended behavior". The workaround in that reply is to take transcript_path, drop .jsonl, and append /subagents/agent-<agent_id>.jsonl. break/subagent-transcript-path.mjs does that:
=== 01-main-bad-write tool=Write agent_id=- exit=0
stdout: /home/user/.claude/projects/-repo/6a0302f1-0000-4000-8000-000000000001.jsonl
=== 02-sub-bad-write tool=Write agent_id=acca37433b29c5d00 exit=0
stdout: /home/user/.claude/projects/-repo/6a0302f1-0000-4000-8000-000000000001/subagents/agent-acca37433b29c5d00.jsonlWith synthetic paths that just shows the string handling works. The #84701 comment says its author checked the built paths against four real subagent transcripts on disk. Per the reference, SubagentStop is still the only event that gives you agent_transcript_path directly.
Where the gate still leaks
If the gate crashes it fails open. break/gate-crashes.mjs reads tool_input.edits on a Write payload, and Write payloads don't have edits:
=== 02-sub-bad-write tool=Write agent_id=acca37433b29c5d00 exit=1
TypeError: Cannot read properties of undefined (reading 'forEach')
at file:///…/break/gate-crashes.mjs:5:33Exit 1 on PreToolUse is a non-blocking error, so the write would go ahead. The reference says a timeout or a script path that doesn't exist behave the same way. Setting "onFailure": "block" on the gate handler turns those failures into a refusal. The docs show the error text as failed; blocking because onFailure is "block". I'm quoting that from the docs. I didn't see it myself because no Claude Code ran. onFailure needs Claude Code 2.1.295 or later. Exit-code basics are in PreToolUse vs PostToolUse.
Bash is the next gap. The gate does check Bash, and a sed -i into src/services gets refused for both callers (payloads 04 and 05 in the run above). A split-string node -e write from the subagent payload gets through though:
node -e "require('fs').writeFileSync('src/'+'serv'+'ices/schedule.ts', 'export const t = () => new '+'Da'+'te();\n')"
=== 08-sub-bash-obfuscated tool=Bash agent_id=acca37433b29c5d00 exit=0The path never shows up as src/services/ so the Bash branch stops looking. A regex over a shell string will slow an agent down and that's about all it does. Subagents have Bash too. The subagents page lists Bash as one of the built-in tools a background subagent keeps. What actually catches this file is a check after the write, so PostToolUse lint or CI.
The refusal message also overpromises a bit. It says moving the call to another folder will be refused, and this gate only looks at src/services/. The rule in the ESLint post covers all of src/. If you copy the message, copy that scope too.
A hooks: block in .claude/agents/*.md only runs while that subagent is running. For a project subagent it also only runs after you accept the workspace trust dialog for the folder, and the reference says "A -p session doesn't count as accepting it." Plugin subagents ignore the hooks field completely. If a gate is meant for every subagent put it in .claude/settings.json.
For a command that should never run at all, like the rm -rf in the issue, the docs tell you to skip hooks. The hooks reference says to "use the permission system rather than a hook to enforce a hard allow or deny." The subagents page says a Bash deny rule in permissions.deny, written like its Bash(git push *) example, "applies to the main conversation and to subagents." Also, find -delete passed the repro's main gate for both callers (payloads 06 and 07, exit 0). That's expected. That gate is about time in services and was never supposed to catch it.
SubagentStart and SubagentStop
Neither of these gates a tool call.
SubagentStart can't stop a subagent from starting. On exit 2 its stderr shows up as a hook error notice in the subagent's transcript and the subagent keeps going. You can add context with additionalContext though, and that's a decent spot to state the Clock convention before the subagent makes its first edit.
SubagentStop exit 2 keeps the subagent running and gives it the stderr as its next instruction. I'd use that for something like "lint what you touched before you report back". onFailure does nothing on this event. It's also the only event whose input has agent_transcript_path.
What was rejected
Per-subagent gates in frontmatter. Each one lives in a single agent file and gets skipped before trust and in plugins. You'd also have to copy it into every agent, which is alot of copies to keep in sync. The settings hook already covers all of them.
Detecting subagents from the transcript path. The test is always false, and that's the bug in #84701.
Denying the Agent tool so nothing can delegate. The subagents page supports this with Agent(subagent-name) in permissions.deny. But then there's no point using subagents, and the gate already runs inside them anyway.
Linting only at SubagentStop. By then the files are on disk and other agents can read them. Precedent poisoning has the data on agents copying whatever is already in the repo. It's fine as a second check behind the gate.
Trusting what the subagent says it did. Both people on #84701 went by what actually happened on disk, and the live-run plan for this repro does the same.
This gate is one layer of the setup in gates, not guidance. I'd put it in .claude/settings.json with "onFailure": "block". If it has to treat subagents differently, branch on agent_id and only use transcript_path when you actually need to read a transcript.
Limits
No live Claude Code session ran for this post. Claude Code wasn't installed on the machine the repro was built on, there was no signed-in account, and no paid account was used. Every output above came from piping synthetic payloads into the hook scripts with run-payloads.sh. There's a live plan written out in the repro for the main thread, foreground, background and custom subagents, both Bash routes and the crash case. It hasn't been run yet.
So how the live harness behaves rests on the hooks reference and one commenter's test. That test covered four contexts in one run (top level, in-process general-purpose, run_in_background: true, and Explore) on 2.1.246, Linux under WSL2, headless claude -p. All four blocked. That's one version on one OS, and the commenter says as much.
#84701 is still open. Its labeled stale, with no maintainer reply and nothing more from the reporter as of 2026-10-19. The thread shows a hook that couldn't tell it was running in a subagent. It doesn't show Claude Code ignoring a subagent's deny. It also doesn't rule out a real harness bug in some context nobody has tested yet, Windows or a specific permission mode for example.
And the gate's Bash check is just a regex. The repro got around it.
FAQ
Do hooks in ~/.claude/settings.json run inside subagents?
Yes. Hooks from settings files, managed policy and plugins all fire on subagent tool calls. Cloud sessions are the exception for the user file because they don't read your local ~/.claude/settings.json.
Is agent_id set on the main thread?
Not in a normal session. You get it for subagents and in-process teammates. agent_type can show up on the main thread if you start with --agent, so if what you want to know is "is this a subagent", test agent_id.
Why does my hook read the parent's transcript inside a subagent?
It's on purpose, per the maintainer reply on #76333. transcript_path is always the main session's file. Build the subagent's path from agent_id, or read agent_transcript_path on SubagentStop.
Can SubagentStart block a subagent from starting?
No. Exit 2 shows stderr as a notice and the subagent starts anyway. If you want to stop a subagent type from being spawned put Agent(subagent-name) in permissions.deny.
Does #84701 mean subagent denies are ignored? Nobody has shown that. The only test in the thread blocked in all four contexts on 2.1.246. And going by the reporter's own description, their hook never returned a deny in the first place.