anthropics / anthropics/claude-code

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

Open
#95,002 0 comments 0 reactions 0 assignees View on GitHub
area:agents area:permissions area:security bug has repro platform:windows
Dominant language
Python
Stars
145k
Forks
23.1k
PR merge metrics
PR metrics pending

Description

### 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:*)`:**

```bash
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:

```yaml
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 worktree`s, 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.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by tracing the subagent spawn path for the tools field and the claude plugin validate entry point. Reproduce both command examples, then inspect how Bash(git diff:*) is interpreted compared with an unscoped Bash entry. Done means the entry is rejected or warned about, or actually restricts Bash to the declared pattern, with coverage for the reported control and reproduction.

Written by the indexing model from the issue text.

Assessment

Tech stack
bash
Domain
cli, security, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
50/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.