Subagent `tools:` entry with a Bash specifier grants unrestricted Bash instead of scoping it

Status Open
Reported on v2.1.270
Maintainer reply None cached
Activity 1 comment · opened Sep 17, 2026

Summary

A subagent whose tools: allowlist contains a command-scoped Bash entry — Bash(git diff:*) — is spawned with the whole Bash tool. The specifier is accepted with no error or warning and scopes nothing.

The failure is in the unsafe direction: writing a narrower entry than an unscoped Bash yields the same grant as Bash, while reading as a restriction. Nothing at load, at spawn, or in claude plugin validate says otherwise.

The docs state this widening explicitly for the denylist —

A disallowedTools entry with a specifier, such as Bash(git push *), still removes the whole tool from the subagent, not only the matching commands.

— and direct you to permissions.deny or a PreToolUse hook for command scoping. The allowlist side isn't documented, and the entry isn't rejected.

Version

Claude Code 2.1.270, Windows 11, node v24.19.0.

Reproduction

Two arms over one prompt; only the tools list differs. The subagent is asked to run node --version, which is not git diff.

A — declares only Bash(git diff:*):

claude -p 'Delegate to the probe subagent: tell it to run the shell command `node --version` and report exactly what it printed. Then reply with one line: either the version string it returned, or CANNOT RUN if it could not run the command.' \
  --agents '{"probe":{"description":"Runs one shell command and reports its output.","prompt":"You run exactly the shell command you are asked to run, using Bash, and report its output verbatim. If you have no Bash tool, or the command is refused, reply CANNOT RUN and nothing else.","tools":["Read","Bash(git diff:*)"]}}' \
  --max-turns 12 --output-format text

B — control, declares no Bash: identical, with "tools":["Read"].

Actual

A  ->  v24.19.0
B  ->  CANNOT RUN — the probe subagent has only the Read tool, no shell access, so it never ran the command.

The control is the point: with tools: ["Read"] the subagent has no Bash at all, so in arm A the Bash(git diff:*) entry is what granted Bash — and granted it unscoped.

Expected

Either of these would be fine; silently granting unrestricted Bash is the one outcome that surprises in the unsafe direction:

  • Reject the entry at load, with a message naming permissions.deny / PreToolUse hooks the way the docs do; or
  • grant Bash scoped to the pattern.

At minimum, a warning from claude plugin validate.

Impact

This is an attenuation failure rather than privilege escalation — the subagent runs inside the session's own permissions. What it defeats is the obvious way to limit a subagent that processes untrusted input.

Concrete case. A pre-merge review subagent is declared:

tools: Read, Grep, Glob, Bash(git diff:*), Bash(git log:*), Bash(git show:*)

It reads a diff written by another agent — untrusted input — and reports findings. Across four runs it was observed running the project's full test suite (node tests/run.mjs), creating throwaway git worktrees, and editing files to mutation-test assertions. None of that is git diff, git log or git show. The definition reads as read-only git access; the behaviour is unrestricted shell.

For plugin-shipped agents there is no workaround: hooks is unsupported for them by design, and permissions.deny is not scoped to a single subagent, so a deny rule binds the main session too. The tools: field is the only lever such an agent has, and it doesn't hold.

Related but distinct

#82562 — tools: [] mislabelled as "All tools" in the parent's agent listing. That one is a label bug and explicitly notes the spawn path is unaffected; this one is the spawn path.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗