SKILL.md `allowed-tools:` does not restrict the tool surface (5 declared → ~46-57 received, incl. Write/Edit)
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 variable — context: 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)
- Enforce
allowed-tools:as documented. - Emit a startup warning that it is advisory, and document the
agent:-binding path as the supported containment mechanism. - 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.