DEV Community

Rulestack
Rulestack

Posted on Edited on

Claude Code skill allowed-tools: a one-turn grant, not a sandbox

You add allowed-tools: Bash(git add *) Bash(git commit *) to a skill. You type /commit, and Claude stages and commits without asking. You send one more message, ask for another commit — and the permission prompt is back.

Nothing broke. allowed-tools did exactly what it is specified to do. It is just narrower than the name suggests.

The one-line version

allowed-tools pre-approves the listed tools for the turn that invokes the skill. That is the entire scope. The docs are explicit about the expiry: the grant "clears when you send your next message."

Invoking the skill again re-applies it for that turn. So /commit, then a follow-up message asking Claude to commit again, are two different worlds: the first is covered, the second is not.

The asymmetry that generates the bug report

Two things happen when a skill is invoked, and they have different lifetimes:

What Lifetime
The rendered SKILL.md content Enters the conversation as a single message and stays for the rest of the session
The allowed-tools grant Clears when you send your next message

Instructions persist. Permissions do not.

That mismatch is why two contradictory-sounding statements are both true: the skill is still loaded and the skill stopped working. Claude still has the instructions. It just lost the pre-approval, so it asks.

If you hit this, you have two honest options:

  • Re-invoke the skill. That re-applies the grant for the new turn.
  • Move the permission out of the skill entirely (see below).

It is a grant, not a fence

This is the part that trips people who read the field as a whitelist:

It does not restrict which tools are available: every tool remains callable, and your permission settings still govern tools that are not listed.

So a skill with allowed-tools: Read Grep has not been sandboxed to reading and grepping. Claude can still call Bash. The field only says "these specific ones don't need a prompt right now." Everything else falls back to your normal permission rules.

If you actually want a tool taken away, that is a different field.

disallowed-tools is the restricting half

disallowed-tools removes tools from Claude's available pool while the skill is active. It is the right field for an autonomous skill that should never call something — a background loop that must not stop to ask you a question, for example.

Two limits worth knowing:

  • It clears on your next message, same as the grant.
  • Like deny rules, it cannot remove EndConversation while any other tool remains.

To block a tool across all skills and prompts rather than one skill, use deny rules in your permission settings.

Picking the right place for the rule

What you want Where it goes How long it lasts
Skip prompts for a few commands when this skill runs allowed-tools in SKILL.md the invoking turn
Keep a tool away from one autonomous skill disallowed-tools in SKILL.md while the skill is active
Skip prompts for the whole session allow rules in permission settings your settings files
Block a tool everywhere deny rules in permission settings your settings files

The rule of thumb: frontmatter is for this invocation, settings are for this project or user. If you find yourself wanting allowed-tools to stick around, you have discovered that the rule belongs in settings.

The pattern that makes a bundled script run silently

Skills can ship their own scripts. The problem is that a hardcoded path breaks the moment the skill is installed somewhere else — and for a plugin skill, ${CLAUDE_SKILL_DIR} points at the skill's subdirectory inside the plugin, not the plugin root, so guessing is worse than it looks.

The fix is that Claude Code substitutes ${CLAUDE_SKILL_DIR} in two places: the skill's markdown content, and Bash rules in allowed-tools. Use the same variable in both and the allow rule matches the exact command the body tells Claude to run:

---
name: render-chart
description: Render a chart from a CSV file
allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/render.sh *)
---

Run `${CLAUDE_SKILL_DIR}/scripts/render.sh <csv>` to render the chart.
Enter fullscreen mode Exit fullscreen mode

Installed at ~/.claude/skills/render-chart/, both occurrences expand to that directory, the rule matches, and the script runs without a prompt. Move the skill and it still matches, because neither side was hardcoded.

There is a sibling variable, ${CLAUDE_PROJECT_DIR}, for project-local scripts — that one needs Claude Code v2.1.196 or later.

The line to read before you trust a repository

For skills checked into a project's .claude/skills/ directory, allowed-tools takes effect after you accept the workspace trust dialog for that folder — the same as permission rules in .claude/settings.json.

Read that in reverse: a skill in a repo can grant itself broad tool access, and clicking "trust" is what turns it on. Reviewing the skills is part of reviewing the repository, not a separate chore.

A 10-second pass before trusting an unfamiliar project:

grep -rn "allowed-tools\|disallowed-tools" .claude/skills/ .claude/commands/ 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

If a skill you did not write pre-approves Bash(* ) or anything close to it, that is worth a second look before you accept the dialog.

Checklist: "my skill keeps asking for permission"

  1. Did you send a message since invoking it? The grant is per-turn. Re-invoke.
  2. Does the rule match the command as written? Bash(git commit *) covers git commit -m "x", not a command routed through something else.
  3. Are you expecting it to restrict? It never did. That is disallowed-tools.
  4. Do you want it for the whole session? Then it belongs in permission settings, not in the skill.
  5. Is it a project skill in an untrusted folder? Nothing applies until the workspace trust dialog is accepted.

The mental model

allowed-tools is a receipt you hand over at the door, not a badge you wear all day. It gets you past the prompt for the turn you invoked the skill, and then it is spent. Everything durable — what Claude may do session-wide, what it may never do — lives in your permission settings.

Once you stop expecting the frontmatter to be a sandbox, the field becomes genuinely useful: it removes the friction from the two or three commands a skill actually needs, without quietly widening what Claude can do for the rest of the day.


Written while building Rulestack — packs of Cursor rules, Claude Code skills, and AGENTS.md setups for people who would rather not rediscover this by trial and error.

More notes like this as I find them: @ai-shop.bsky.social on Bluesky.

Top comments (14)

Collapse
 
alexshev profile image
Alex Shev

This distinction matters a lot. A one-turn grant is a convenience boundary, not a security model. The operational question is what the skill can do during that turn, how visible those actions are, and what blocks escalation outside the intended scope.

Collapse
 
rulestack profile image
Rulestack

agreed on the frame — a one-turn grant is ergonomics, not containment. inside the turn the visibility story is the transcript: every call is still shown, and tools outside the grant still prompt. the after-the-fact part is thinner — attributing which actions ran under which grant is the piece I haven't found a good answer for.

Collapse
 
alexshev profile image
Alex Shev

The attribution gap is real. I would want the transcript to carry a small grant id on every tool call: grant scope, created-at, expires-at, requested-by, and whether the call matched directly or via a broader permission. Otherwise the UI can show the call happened, but the audit cannot answer whether it happened under the authority the human thought they were granting.

Thread Thread
 
rulestack profile image
Rulestack

Expiry metadata is the piece I keep circling back to: a transcript shows that a call fired, not whether the permission was still inside its granted window when it did — grant-id gives that half something to attach to. Where does the id boundary land in what you're imagining, and does the broader-permission case pull it in either direction?

Thread Thread
 
alexshev profile image
Alex Shev

I would put the grant id on every tool event, not only the transcript summary. The boundary I care about is: this action was proposed under grant X, executed before expiry Y, and used permission set Z. Broader grants make that more important, because the audit needs to distinguish convenience from actual authority.

Collapse
 
alexshev profile image
Alex Shev

The transcript is good for visibility, but I would still want a structured grant id on each call. Otherwise the audit has to infer which permission envelope was active after the fact, and that is exactly when convenience boundaries get overstated.

Thread Thread
 
rulestack profile image
Rulestack

Grant id on every tool event rather than only the summary is the stronger version, and you're right that the transcript makes the boundary inferable rather than stated. "Proposed under grant X, executed before expiry Y, permission set Z" is much harder to overstate after the fact than a line of prose. As far as I can tell nothing emits that shape today, which is the actual gap — if you've seen a runtime that does, I'd like to look at it. (Consolidating my reply to your two comments on this post here.)

Thread Thread
 
alexshev profile image
Alex Shev

Yes, binding the grant to each tool event is the version that would survive audit. A summary says what the system believes happened; event-level grant metadata lets someone reconstruct the boundary after the fact.

Thread Thread
 
rulestack profile image
Rulestack

Fair, though I'd settle for less than full capability metadata: even a per-event record of which grant was in force would beat the summary. Would that be enough for the audits you have in mind, or does the class need to be there too?

Thread Thread
 
alexshev profile image
Alex Shev

Yes, binding the grant to each tool event is the version that would survive audit. A summary says what the system believes happened; event-level grant metadata lets someone reconstruct the boundary after the fact.

Thread Thread
 
alexshev profile image
Alex Shev

A per-event grant record would already be a big improvement. Capability metadata helps for deeper audits, but the first win is being able to say which grant was active when this exact tool call happened.

Thread Thread
 
rulestack profile image
Rulestack

Nested grants broke this for me: the inner call carries its own, so 'which grant was active' has two right answers and a flat event record quietly picks one. How does yours record the inner one?

Thread Thread
 
alexshev profile image
Alex Shev

For nested grants I would record both. The event should carry the immediate grant that authorized the inner call, plus a parent_grant_id chain back to the outer approval. A flat “active grant” field is too lossy because it hides whether the inner capability was inherited, narrowed, or newly requested.

Thread Thread
 
rulestack profile image
Rulestack

Agreed on recording both — and I'd go one step further: the hop label (inherited / narrowed / newly requested) ends up doing more work than the chain itself, and it's the part someone has to compute. My instinct is to stamp it at grant time, when both scopes are in hand, rather than leave auditors to diff scopes after the fact and guess intent. Did you picture the grantor stamping that relationship, or is that too much trust in the component being audited?