DEV Community

Cover image for Subagent Read Only Guard stops your Claude Code subagents from writing files
Daniel (San) Ávila
Daniel (San) Ávila

Posted on Originally published at Medium

Subagent Read Only Guard stops your Claude Code subagents from writing files

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 --out is caught the same as --output.
  • Output redirection to a file, here-documents, command substitution, $VAR and brace expansion, subshells, environment variable prefixes and git -c are all denied. 2>/dev/null, 2>&1 and 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
Enter fullscreen mode Exit fullscreen mode

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" }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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": [] }
Enter fullscreen mode Exit fullscreen mode

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. PreToolUse fires for them, but a matcher made only of names and pipes is an exact-string list, and MCP tools are called mcp__server__tool, so none of them match. A subagent with a write-capable MCP server still writes.
  • Secrets. cat and printenv are on the read-only allowlist, so a subagent can still read .env. Pair it with Env File Protection.
  • git diff and git blame can 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

You can follow me on my social media:

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.