The gap
#2225 paired /repo-hygiene:clean's allowed-tools grant with its invocation: SKILL.md now runs
its bundled scripts directly through ${CLAUDE_SKILL_DIR}/scripts/…, and five narrow rules name
those same strings.
The skill's bundled context/*.md files were deliberately left on the old form and still say:
bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/<name>.sh
Files: context/action-router.md, context/clean-batch.md, context/git-branch-cleanup.md,
context/git-tree-reset.md, context/git-tree-reset-batch.md, context/preflight.md.
So the skill carries two invocation forms: SKILL.md in the paired form the grant matches, and
the routed detail files in an interpreter-led form it does not. Commands the model takes from a
context/*.md will not match the new rules and will prompt or fall to the classifier.
This does not weaken #2225's fix — all five granted scripts are invoked from SKILL.md, so the
grant is fully paired for everything it covers. This issue is about finishing the job.
Why it was not just converted
Converting them requires assuming ${CLAUDE_SKILL_DIR} is substituted in a bundled non-SKILL.md
file, and that assumption is unverified. The skills documentation says:
Claude Code substitutes ${CLAUDE_SKILL_DIR} and ${CLAUDE_PROJECT_DIR} in two places: the
skill's markdown content, and Bash rules in the allowed-tools frontmatter.
— https://code.claude.com/docs/en/skills (skills.md:333)
Whether "the skill's markdown content" extends to a bundled context/*.md loaded on demand is
precisely the ambiguity, and the docs resolve it neither way. Note the asymmetry that makes this
non-obvious: ${CLAUDE_PLUGIN_ROOT}'s scope is documented more broadly — plugins-reference scopes it
to "Skill and agent content | Anywhere the placeholder appears" — so the two variables are not
interchangeable here just because both work in SKILL.md.
Failure mode if the assumption is wrong: an unsubstituted body emits a literal
${CLAUDE_SKILL_DIR}/scripts/x.sh, which the Bash tool expands from an unset environment variable
(that variable is not exported into the tool's shell) to /scripts/x.sh. It fails safe — a prompt or
a not-found error, never a wrong action — but it fails silently, which is exactly the defect
class #2225 exists to remove. Shipping it inside that PR would have been the wrong trade.
The empirical test that settles it
One observation, no code change needed:
- Install the plugin normally (not
--plugin-dir), so ${CLAUDE_SKILL_DIR} resolves to a real
cache path.
- In a scratch repo, invoke
/repo-hygiene:clean and route into a step whose detail lives in a
context/*.md — e.g. preflight, which SKILL.md routes to context/preflight.md.
- Temporarily rewrite that one file's invocation to
${CLAUDE_SKILL_DIR}/scripts/preflight.sh
(direct, unquoted) and let the model run the command it reads there.
- Observe prompt-or-no-prompt. No prompt ⇒ the placeholder was substituted and matched the
existing Bash(${CLAUDE_SKILL_DIR}/scripts/preflight.sh:*) rule ⇒ substitution reaches bundled
context files, and the conversion is safe. A prompt, or a /scripts/preflight.sh: No such file
error, ⇒ it does not, and the files must stay on ${CLAUDE_PLUGIN_ROOT}.
Checking the emitted command string directly (transcript or OTEL claude_code.tool_decision) is
stronger than inferring from the prompt, if that route is available.
If substitution does reach context files
Convert all six files and extend plugins/repo-hygiene/scripts/allowed-tools-pairing.test.sh to
assert the paired form across context/ as well as SKILL.md — the gate already walks every *.md
under the skill, so this is a scope widening, not new logic.
If it does not
Leave them on ${CLAUDE_PLUGIN_ROOT}, and record the two-form split as an accepted, documented
residual in the convention so the next author does not "fix" it on the same wrong assumption.
Related
Provenance
Originating inbox item 20260811-024628-claude-config-audit-permission-grants-defects-and-fleet-grant-hygiene,
ledger .work/handoff-inbox-batch-4/ledgers/I10-permission-grants-fleet.md, half B row B4. Split
out of #2225 by orchestrator decision rather than shipped on an unverified assumption.
The gap
#2225 paired
/repo-hygiene:clean'sallowed-toolsgrant with its invocation:SKILL.mdnow runsits bundled scripts directly through
${CLAUDE_SKILL_DIR}/scripts/…, and five narrow rules namethose same strings.
The skill's bundled
context/*.mdfiles were deliberately left on the old form and still say:Files:
context/action-router.md,context/clean-batch.md,context/git-branch-cleanup.md,context/git-tree-reset.md,context/git-tree-reset-batch.md,context/preflight.md.So the skill carries two invocation forms:
SKILL.mdin the paired form the grant matches, andthe routed detail files in an interpreter-led form it does not. Commands the model takes from a
context/*.mdwill not match the new rules and will prompt or fall to the classifier.This does not weaken #2225's fix — all five granted scripts are invoked from
SKILL.md, so thegrant is fully paired for everything it covers. This issue is about finishing the job.
Why it was not just converted
Converting them requires assuming
${CLAUDE_SKILL_DIR}is substituted in a bundled non-SKILL.mdfile, and that assumption is unverified. The skills documentation says:
Whether "the skill's markdown content" extends to a bundled
context/*.mdloaded on demand isprecisely the ambiguity, and the docs resolve it neither way. Note the asymmetry that makes this
non-obvious:
${CLAUDE_PLUGIN_ROOT}'s scope is documented more broadly — plugins-reference scopes itto "Skill and agent content | Anywhere the placeholder appears" — so the two variables are not
interchangeable here just because both work in
SKILL.md.Failure mode if the assumption is wrong: an unsubstituted body emits a literal
${CLAUDE_SKILL_DIR}/scripts/x.sh, which the Bash tool expands from an unset environment variable(that variable is not exported into the tool's shell) to
/scripts/x.sh. It fails safe — a prompt ora not-found error, never a wrong action — but it fails silently, which is exactly the defect
class #2225 exists to remove. Shipping it inside that PR would have been the wrong trade.
The empirical test that settles it
One observation, no code change needed:
--plugin-dir), so${CLAUDE_SKILL_DIR}resolves to a realcache path.
/repo-hygiene:cleanand route into a step whose detail lives in acontext/*.md— e.g.preflight, whichSKILL.mdroutes tocontext/preflight.md.${CLAUDE_SKILL_DIR}/scripts/preflight.sh(direct, unquoted) and let the model run the command it reads there.
existing
Bash(${CLAUDE_SKILL_DIR}/scripts/preflight.sh:*)rule ⇒ substitution reaches bundledcontext files, and the conversion is safe. A prompt, or a
/scripts/preflight.sh: No such fileerror, ⇒ it does not, and the files must stay on
${CLAUDE_PLUGIN_ROOT}.Checking the emitted command string directly (transcript or OTEL
claude_code.tool_decision) isstronger than inferring from the prompt, if that route is available.
If substitution does reach context files
Convert all six files and extend
plugins/repo-hygiene/scripts/allowed-tools-pairing.test.shtoassert the paired form across
context/as well asSKILL.md— the gate already walks every*.mdunder the skill, so this is a scope widening, not new logic.
If it does not
Leave them on
${CLAUDE_PLUGIN_ROOT}, and record the two-form split as an accepted, documentedresidual in the convention so the next author does not "fix" it on the same wrong assumption.
Related
${CLAUDE_SKILL_DIR}usedin pre-compute but not documented as harness-substituted, with three skills possibly reporting fake
pre-compute data. Same root uncertainty about substitution scope; settling one very likely settles
the other, and the empirical test above would serve both.
docs/conventions/permission-rule-hygiene/README.md:120-126— the convention's current statementof which variables substitute where.
Provenance
Originating inbox item
20260811-024628-claude-config-audit-permission-grants-defects-and-fleet-grant-hygiene,ledger
.work/handoff-inbox-batch-4/ledgers/I10-permission-grants-fleet.md, half B rowB4. Splitout of #2225 by orchestrator decision rather than shipped on an unverified assumption.