[BUG] Skill allowed-tools rule is dropped when the Skill tool finishes before the response stream ends (2.1.289)
Summary
A skill's allowed-tools rule usually never reaches the Bash call it should allow. This is in Claude Code 2.1.289. The Skill tool result carries the rule, expanded correctly. Claude Code then drops it whenever the Skill tool finishes before the model's response has finished streaming. A skill loads in a few milliseconds, so it nearly always finishes first: the rule held in 3 of 28 headless runs across two plugins.
The skills docs say the grant applies during the turn that invokes the skill, so this looks like a bug. It is likely the cause of #14956, which reports the same symptom from 2.0.75 on. A race also explains why the rule works for some people there and not for others.
Repro
A plugin, skill-rule-repro, with one skill and one script.
skills/hello/SKILL.md:
---
name: hello
description: Say hello by running the hello script. Use when the user asks for a hello.
allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/hello.sh *)
---
Run `${CLAUDE_PLUGIN_ROOT}/scripts/hello.sh world` with Bash and report its output.
scripts/hello.sh, executable:
#!/bin/sh
echo "hello $1"
.claude-plugin/plugin.json:
{ "name": "skill-rule-repro", "version": "0.0.1", "description": "Repro" }
Run:
claude -p "Invoke the skill-rule-repro:hello skill, then do what it says." \
--plugin-dir ./skill-rule-repro --permission-mode default --allowedTools Skill \
--output-format stream-json --verbose < /dev/null
- Expected: Bash runs
hello.sh worldwith no prompt. - Actual: 5 of 6 runs were denied with "This command requires approval". One printed
hello world.
In every run, passing or failing, the Skill tool result holds "allowedTools":["Bash(<expanded plugin path>/scripts/hello.sh *)"]. The rule and its ${CLAUDE_PLUGIN_ROOT} expansion are fine.
What changes the outcome
These runs used a second plugin's skill, with the same flags:
| Condition | Ran with no prompt |
| ------------------------------------------------------------------------------ | ------------------ |
| Normal skill | 2 of 22 |
| Skill body runs ` !sleep 1 , so it finishes loading after the stream ends | 9 of 9 |--allowedTools` on the command line, no skill | 6 of 6 |
| Skill and Bash calls in the same assistant message | 3 of 3 |
| The same rule given with
The command's form made no difference: a pipe, a file redirect, and quoted or plain arguments all failed alike. The debug log shows the denial about 5 ms after the Skill result, with no API request between them.
Likely cause
I read this in the minified 2.1.289 bundle, so the names are minified:
- While the response is still streaming, a loop over
streamingToolExecutor.getCompletedResults()drains finished tool results. It yieldsA.messageand never readsA.newContext. The Skill tool'sallowed_toolscontext rides innewContext, so it is lost. - After the stream ends, the loop over
getRemainingResults()doesif(h.newContext)he={...h.newContext,…}. The rule then carries into the next tool call. - The executor has a
getUpdatedContext()that would recover the context. Nothing in the bundle appears to call it.
The two experiments in the table fit this. A slow skill always finishes after the stream ends. A Skill and Bash call in one message share the executor's context, so the dropped path is never used.
Environment
- Claude Code 2.1.289 on macOS, in headless
-pmode. - The interactive REPL is untested. It likely shares the same query loop.
- A typed
/skillgoes through a different path and is also untested.
Workaround
A plugin PreToolUse hook that returns permissionDecision: "allow" for the skill's exact command shape.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗