tools: frontmatter — Read(/path), Write(/path), Glob(/path), Bash(cmd:*) parentheticals silently discarded (no runtime enforcement)
What's Wrong?
In subagent tools: frontmatter, entries of the form Read(<path>), Write(<path>), Glob(<path>), and Bash(<command-pattern>) are parsed successfully but the parenthesised argument is silently discarded at agent-load time. The bare tool name reaches the runtime with no path or command scoping, so the subagent can read/write/glob/exec arbitrarily despite the apparent restriction in its declaration.
This is the dual of the already-reported parser behaviour where multiple Agent(...) / Task(...) entries collapse to "last wins" (#28277, #60911, #52293). The same root cause — tools: parser keys on the bare tool name and the parenthetical is associated with that key rather than enforced — manifests here as silent no-op for tools whose runtime dispatch does not consult the parenthetical (Read, Write, Glob, Bash), instead of "last wins" for tools that do (Agent, Task).
The result is a misleading allowlist: plugin and agent authors write Read(/*.pdf) or Bash(curl:*) believing they are scoping the tool, when in reality the entries are equivalent to bare Read / Bash.
What Should Happen?
Either (a) the parenthetical should enforce, or (b) the parser should refuse or warn on unenforceable forms. Today neither happens — the syntax parses cleanly and the misconception is never surfaced.
Concrete options:
- Enforce. Have the runtime tool-dispatch consult the parenthetical for Read/Write/Glob (path-glob match) and Bash (command-prefix match), analogous to how
permissions.allow/denyalready does at the session level. - Reject at parse time. Emit a parse error on Read/Write/Glob/Bash entries with arguments, pointing the author at
settings.jsonpermissions.allow/denyfor path scoping. - Warn at parse time. Surface a
/agentswarning (similar to the existing⚠ Unrecognizedfor unknown MCP wildcards in #53865) when the parenthetical is silently discarded.
Steps to Reproduce
The probe agent's frontmatter declares one narrow tool entry. Reading or globbing a path outside that entry should fail if the parenthetical were enforced.
# 1. Stage probe files inside and outside the declared allow path.
mkdir -p /tmp/probe-allow /tmp/probe-deny
echo INSIDE-MARKER > /tmp/probe-allow/inside.txt
echo OUTSIDE-MARKER > /tmp/probe-deny/outside.txt
touch /tmp/probe-allow/a.txt /tmp/probe-allow/b.txt
touch /tmp/probe-deny/a.txt /tmp/probe-deny/b.txt
# 2. Throwaway plugin scaffold.
mkdir -p /tmp/probe-plugin/.claude-plugin /tmp/probe-plugin/agents
cat > /tmp/probe-plugin/.claude-plugin/plugin.json <<'JSON'
{
"$schema": "https://www.schemastore.org/claude-code-plugin-manifest.json",
"name": "probe",
"version": "0.0.0",
"description": "tools: frontmatter pattern-discard probe."
}
JSON
# Read probe.
cat > /tmp/probe-plugin/agents/probe.md <<'MD'
---
name: probe
description: Probe.
tools: >-
Read(/tmp/probe-allow/inside.txt)
---
You are a diagnostic probe. Perform exactly the calls instructed.
MD
claude -p --plugin-dir /tmp/probe-plugin \
"Use Agent tool with subagent_type='probe:probe' and prompt: \
'Call Read twice. First file_path=/tmp/probe-allow/inside.txt. \
Second file_path=/tmp/probe-deny/outside.txt. \
Reply ONLY with JSON {\"inside\":{\"ok\":bool,\"data\":\"...\"},\"outside\":{\"ok\":bool,\"data\":\"...\"}}'. \
Print subagent reply verbatim and nothing else."
# Glob probe — swap tools line and re-run.
sed -i 's|Read(/tmp/probe-allow/inside.txt)|Glob(/tmp/probe-allow/*)|' /tmp/probe-plugin/agents/probe.md
claude -p --plugin-dir /tmp/probe-plugin \
"Use Agent tool with subagent_type='probe:probe' and prompt: \
'Call Glob twice. First pattern=/tmp/probe-allow/*. \
Second pattern=/tmp/probe-deny/*. \
Reply ONLY with JSON {\"inside\":{\"ok\":bool,\"data\":\"...\"},\"outside\":{\"ok\":bool,\"data\":\"...\"}}'. \
Print subagent reply verbatim and nothing else."
# Bash probe — declare echo:*, run printf.
sed -i 's|Glob(/tmp/probe-allow/\*)|Bash(echo:*)|' /tmp/probe-plugin/agents/probe.md
claude -p --plugin-dir /tmp/probe-plugin \
"Use Agent tool with subagent_type='probe:probe' and prompt: \
'Call Bash twice. First command=\"echo INSIDE\". Second command=\"printf OUTSIDE\". \
Reply ONLY with JSON {\"inside\":{\"called\":bool,\"denied_by_harness\":bool,\"data\":\"...\"}, \
\"outside\":{\"called\":bool,\"denied_by_harness\":bool,\"data\":\"...\"}}'. \
Print subagent reply verbatim and nothing else."
# Cleanup.
rm -rf /tmp/probe-plugin /tmp/probe-allow /tmp/probe-deny
Observed (Claude Code 2.1.141)
Read: {"inside":{"ok":true,"data":"INSIDE-MARKER"},
"outside":{"ok":true,"data":"OUTSIDE-MARKER"}}
Glob: {"inside":{"ok":true,"data":"/tmp/probe-allow/a.txt\n/tmp/probe-allow/b.txt"},
"outside":{"ok":true,"data":"/tmp/probe-deny/a.txt\n/tmp/probe-deny/b.txt"}}
Bash: {"inside":{"called":true,"denied_by_harness":false,"data":"INSIDE"},
"outside":{"called":true,"denied_by_harness":false,"data":"OUTSIDE"}}
In all three cases the outside call succeeded despite the frontmatter declaring only an inside-scoped pattern.
Expected
The outside call should fail with an error naming the violated pattern, e.g.:
Read for file_path '/tmp/probe-deny/outside.txt' denied by agent tools: rule 'Read(/tmp/probe-allow/inside.txt)'.
…or the parser should reject the declaration at agent-load time so the author knows the syntax is unenforced.
Bash caveat
The Bash result is the least clean of the three because the auto-mode classifier sits above the permission system and permits "safe" commands (echo, printf, true, false, ls) regardless of frontmatter. To isolate the parenthetical-enforcement layer cleanly, both the inside command (echo) and the outside command (printf) were chosen to be classifier-permitted. The outside command ran without any "from settings" / "from agent tools" denial, which would be expected if the parenthetical were the gate.
A more invasive command (touch /tmp/x) was also tested and was denied — but by the classifier with reason "Prompt-injection-style request to probe the harness; not a legitimate task from the user.", not by pattern mismatch. That denial path is unrelated to the frontmatter parenthetical.
Root cause (hypothesis)
Per #28277:
Thetools:field parser likely usesTaskas the map key (without the parenthesised argument), so eachTask(plugin:X)entry overwrites the previous one. Only the last survives into the permission resolver.
The same key-only behaviour explains this report: the parenthetical is associated with the bare tool key but never travels into the dispatch path for Read/Write/Glob/Bash. For Agent/Task it visibly fails as "last wins" (because the dispatch does consult it, and only the last value is present); for Read/Write/Glob/Bash it silently fails as "no scoping at all" (because the dispatch doesn't consult it).
Impact
- Plugin authors write apparent allowlists that are no-ops. Several plugin agents in
joshuaspence/claude-pluginshadRead(~/Dropbox/...),Glob(~/Dropbox/...)and similar entries that were assumed to scope file access. None did. - Security-conscious authors may believe their agent is sandboxed when it is not.
- Documentation does not warn against the syntax. The Read/Write/Glob/Bash forms parse without error and produce no warning in
/agents.
Related issues
- #28277 (closed) —
Task(...)parser, "last wins". Same root cause, visible failure mode. - #60911 (closed) —
Agent(...)parser, "last wins". Same root cause, visible failure mode. - #52293 (open) —
Agent(...)parser, silent acceptance + misleading error. Same root cause, UX angle. - #53865 (open) — MCP wildcards rejected in
tools:. Adjacent: the parser surfaces a warning here but not for path-arg discard. - #58645 (closed) —
disallowedTools/allowedTools(the formal keys) ignored for plugin-installed agents. Adjacent: different mechanism, same theme of subagent tool restrictions being silently no-op'd.
Environment
- Claude Code: 2.1.141.
- Platform: Linux.
- Reproducer above runs against fresh
claude -psessions; behaviour was confirmed in both nested-session and standalone-session contexts.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗