SKILL.md `allowed-tools:` does not restrict the tool surface (5 declared → ~46-57 received, incl. Write/Edit)

Status Open
Reported on v2.1.241
Maintainer reply None cached
Activity 0 comments · opened Aug 24, 2026

Summary

allowed-tools: in a skill's SKILL.md frontmatter is documented as restricting tool access, but it neither grants nor restricts. A skill declaring 5 tools receives ~46–57, including Write, Edit, Agent, and MCP tools such as ssh_execute.

The failure is silent — nothing warns that the key had no effect.

Version

Claude Code 2.1.241, macOS (Darwin 27.0.0). Also observed at 2.1.224.

Documented behavior

Skill-authoring reference documents the key as:

allowed-tools: Read, Grep, Glob   # Restrict tool access

Actual behavior

Two probe skills, identical except for one variablecontext: fork present vs. absent. Both declare exactly five tools: Bash, AskUserQuestion, WebFetch, Monitor, NotebookEdit. Both deliberately omit Read, Write, Edit, Glob, Grep, Agent, Skill, TaskCreate, ToolSearch.

| variant | declared | received | undeclared but granted |
|---|---:|---:|---|
| context: fork | 5 | ~46 | Agent, Read, Write, Edit, Skill, ToolSearch, SendMessage, Cron*, ssh_execute, 14× browser-control tools, … |
| no context: | 5 | ~48 | same, plus Workflow, ScheduleWakeup, Task* |

Of the five declared tools, four arrived and one did not. So the key fails in both directions: it does not restrict, and it does not guarantee.

context: fork is not the variable — this reproduces with context: absent. Please don't triage it as fork-specific.

Tools were confirmed live, not merely listed: Read was called and returned a genuine tool-level "file does not exist" for a path independently confirmed missing via Bash.

Contrast — the agent-definition path IS enforced

agents/*.md using tools: / disallowedTools: works correctly. Measured the same day on both dispatch paths (direct Agent-tool dispatch, and a context: fork skill's agent: key): Write, Edit, NotebookEdit and Agent had no schema at all, and ToolSearch("select:Write,Edit,NotebookEdit,Agent") returned No matching deferred tools found.

Enforcement survives an adversarial declaration: a probe skill declaring Write and Edit in its own allowed-tools: while bound to a runner that denies them still did not receive them. The agent layer wins, which is the correct precedence — the skill layer simply isn't consulted.

Impact

Skills are widely authored with allowed-tools: as a containment boundary, and capability claims ("read-only", "no write tools") get written on the strength of it. In our setup this produced two false guarantees — including a review skill operating on uncommitted work while claiming to be read-only by declaration, i.e. exactly the context where an unintended write is unrecoverable.

Reproduction

Save as .claude/skills/toolprobe/SKILL.md in a scratch directory:

---
name: toolprobe
description: "Diagnostic probe. Reports the tool surface actually granted. Triggers on: toolprobe."
user-invocable: true
model: sonnet
allowed-tools:
  - Bash
  - WebFetch
  - Monitor
  - NotebookEdit
---

# toolprobe

Your ONLY job is to report, truthfully, which tools were actually granted to you.
This is a measurement; an inaccurate answer is worse than no answer.

1. List EVERY tool name present in your context, exactly as named in your tool schemas.
   This skill declared exactly four: Bash, WebFetch, Monitor, NotebookEdit. It did NOT
   declare Read, Write, Edit, Glob, Grep, Agent, Skill, TaskCreate, ToolSearch.
2. Actually attempt `Read` on /etc/hostname and `Glob` for '*.md' — neither is declared.
   A real call attempt is stronger evidence than introspection.
3. Report `undeclared_but_PRESENT` and `declared_but_ABSENT` as two explicit lists.

Then:

cd <scratch-dir> && claude -p --permission-mode bypassPermissions "/toolprobe"

Add a second copy with context: fork in the frontmatter to confirm the result holds on both axes.

Suggested resolutions (any one)

  1. Enforce allowed-tools: as documented.
  2. Emit a startup warning that it is advisory, and document the agent:-binding path as the supported containment mechanism.
  3. Reject or warn on an inert key at skill load.

Option 2 alone would have been enough to prevent the false guarantee on our side.

Deliberately excluded

We separately measured that AskUserQuestion is absent inside a forked skill (present in the interactive parent, absent in the fork spawned from it, with bypassPermissions held fixed across both). That is not part of this report — it is plausibly intended, since a background fork has no interactive user, and bundling it would weaken a clean and clearly-unintended finding. Happy to file it separately if useful.

View original on GitHub ↗