I run Sonnet as the main model and Haiku for subagents. Subagents are good at going out, reading a lot of code and coming back with a report. They are less good at deciding on their own that the right next move is to rewrite a file in a repo they only half understand.
So there are two components here. One tells subagents to stay read-only. The other makes sure they do.
The instruction is not the enforcement
The first half is the setting, hooks/subagents-explore-only. It registers a SubagentStart hook that prints a single JSON object with hookSpecificOutput.additionalContext, and Claude Code injects that text into every subagent before its first prompt: investigate with read-only tools, do not create, edit, move or delete files, do not run state-changing commands, and describe the exact change in the final report so the main agent applies it. Built-in agents, custom agents and plugin agents all get it.
SubagentStart is a context-only event. It has no decision control, so it cannot block anything. It is a prompt, and a prompt is a request.
Subagent Read Only Guard is the half that enforces. It runs on PreToolUse, which does have decision control.
What the guard denies
The hook matches Edit|Write|MultiEdit|NotebookEdit|PowerShell|Bash|Monitor and hands the call to a Python script. The first thing that script does is look for agent_id in the hook input. Claude Code sets that field only when the hook fires inside a subagent, so a call from the main conversation exits before any of the checking happens. Your own session is untouched.
- Edit, Write, NotebookEdit and PowerShell are denied outright. The reason string tells the subagent to describe the change in its report so the main agent can apply it.
- Bash and Monitor commands get parsed. A command passes only when every program in it is a bare name from a short read-only allowlist (cat, ls, grep, rg, find, jq, diff, wc, sort, git log/diff/status/show/blame and similar) called without the options that would make that program write a file or run another one. Abbreviated long options count, so
--outis caught the same as--output. - Output redirection to a file, here-documents, command substitution,
$VARand brace expansion, subshells, environment variable prefixes andgit -care all denied.2>/dev/null,2>&1and input redirection are fine. - Anything the classifier cannot parse is denied, including its own bugs. It fails closed.
The block is a real permission decision, not an exit code. The script prints permissionDecision: "deny" with a permissionDecisionReason, and that reason is the text the subagent reads.
Install it
npx claude-code-templates@latest --setting hooks/subagents-explore-only --hook security/subagent-read-only-guard
That drops the guard at .claude/hooks/subagent-read-only-guard.py and registers the hooks in your project settings. The registration is an ordinary hooks block, so you can read it, move it to ~/.claude/settings.json to get it on every project, or trim the matcher:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit|PowerShell|Bash|Monitor",
"hooks": [
{ "type": "command", "command": "python3 .claude/hooks/subagent-read-only-guard.py" }
]
}
]
}
}
Two things I changed from what ships. MultiEdit is gone from the matcher: it is not a built-in tool name any more, so that entry never matches anything. And the command uses a path relative to the working directory, which is not always the project root, so python3 "$CLAUDE_PROJECT_DIR"/.claude/hooks/subagent-read-only-guard.py is the safer spelling. You also need python3 on PATH.
Narrow what it fires on
As installed, the hook runs on every Edit, Write, Bash and Monitor call in the session, including yours, and exits on the first line when agent_id is missing. Cheap, but it is still a python3 process per tool call.
If you want it to fire less often, hooks take an if field next to matcher, using permission rule syntax, exactly one rule, no && or ||:
{ "matcher": "Bash", "if": "Bash(rm *)", "hooks": [] }
if is evaluated on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied. On any other event a hook that carries it never runs.
What it does not cover
- MCP tools.
PreToolUsefires for them, but a matcher made only of names and pipes is an exact-string list, and MCP tools are calledmcp__server__tool, so none of them match. A subagent with a write-capable MCP server still writes. - Secrets.
catandprintenvare on the read-only allowlist, so a subagent can still read.env. Pair it with Env File Protection. -
git diffandgit blamecan still run a diff or textconv driver the repo already has configured.
The trade is real: subagents will sometimes come back with a described change instead of a finished one, and your main agent has to apply it. That is the whole point, but it means the orchestration work moves up to the main prompt. For exploration-heavy sessions with cheap subagents, it is worth it.
Where the hooks live
- Subagent Read Only Guard, the PreToolUse hook in this article
- Subagents Explore Only, the SubagentStart setting that pairs with it
- Env File Protection, for the secrets this guard still lets through
- The rest of the catalog
You can follow me on my social media:
- Twitter (English): https://x.com/dani_avila7
- LinkedIn (Spanish): https://www.linkedin.com/in/daniel-avila-arias/
- Youtube (Spanish): https://www.youtube.com/@daniiielsan
- Instagram (Spanish): https://www.instagram.com/danielsan_avila/


Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.