Skip to content

fix(plugins): pair seven allowed-tools grants with the invocations they must match - #2225

Merged
kyle-sexton merged 9 commits into
mainfrom
fix/2221-allowed-tools-grant-pairing
Aug 11, 2026
Merged

fix(plugins): pair seven allowed-tools grants with the invocations they must match#2225
kyle-sexton merged 9 commits into
mainfrom
fix/2221-allowed-tools-grant-pairing

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Seven skill allowed-tools grants across five plugins were interpreter-led (Bash(bash <path>…)),
and four of them named ${CLAUDE_PLUGIN_ROOT} — which is not substituted in allowed-tools, so
those rules stayed literal strings that never matched. One more (repo-fleet-hygiene) was dead from
an unfiled quote mismatch, and two (code-tidying/audit-comment-residue, docs-hygiene/audit-noise)
worked only because leading/trailing wildcards absorbed both the bash wrapper and the body's
quotes — the wildcarded-interpreter shape auto mode drops.

The corrected mechanism (the filed rationale was falsified)

The originating item prescribed "drop bash from the rule." That is wrong. bash is not one of
the wrappers Claude Code strips before matching a Bash rule — the stripped set is timeout, time,
nice, nohup, stdbuf, command, builtin, noglob — and this repo's own convention already
records that at docs/conventions/permission-rule-hygiene/README.md:218-220. Every one of the seven
skill bodies invoked its script through bash "<path>", so a rule without bash stops matching
the command the body actually runs. Rule-only edits would have produced dead grants, and for the
two that work today it would have been a straight regression.

Per the operator ruling (RECONCILE.md OR-5 / DQ-3), this is a paired body+rule rewrite applied
identically across all five plugins: the bodies invoke their bundled scripts directly and
unquoted
, and the rules name that same string via ${CLAUDE_SKILL_DIR}, the token that is
substituted in allowed-tools. Quoting is part of the pairing — an unquoted rule does not match a
quoted body path, which is exactly how repo-fleet-hygiene's grant shipped dead after #1798 fixed
only the variable half and explicitly parked quoting as "Unverified, not asserted."

Two empirical checks were run before committing to this shape, because the whole policy depends on
them: the scripts' exec bits survive plugin-cache install (-rwxr-xr-x in
~/.claude/plugins/cache/...), and direct invocation by bare unquoted path works there.

Per-plugin

plugin version change
prototype 0.6.0 → 0.7.0 Both skills repaired. ${CLAUDE_SKILL_DIR} resolves to the skill's subdirectory, so each skill gains a self-locating wrapper under skills/<skill>/scripts/ that execs the still-single-sourced detector at the plugin root — no duplicated logic, no novel rule form.
code-tidying 0.9.0 → 0.10.0 tidy (inert) and audit-comment-residue (works-by-accident) both paired.
repo-hygiene 0.9.1 → 0.10.0 The inert Bash(bash …/scripts/*) wildcard becomes five narrow rules covering the read-only scripts only. See the security note below.
repo-fleet-hygiene 0.10.0 → 0.11.0 Quote mismatch settled; #1798's parked question answered.
docs-hygiene 0.10.1 → 0.11.0 audit-noise paired.

Three skills also gained grants for the read-only commands their pre-computes pipe through (grep,
head, echo): a rule must match each subcommand of a compound command independently, so a script
grant alone left the pipeline uncovered and the pre-compute prompted regardless.

Security review note

This widens a trust surface — dead grants become live ones — so it is called out deliberately:

  • Every new rule is narrow and anchored to the granting skill's own ${CLAUDE_SKILL_DIR}. The
    two leading-wildcard rules that matched a bare wrapper name at any path (including an unvetted
    copy) are gone; so is the one wildcarded-target directory glob.
  • repo-hygiene/clean is the one with real blast radius, and it is narrowed, not widened. Only
    the five read-only scripts (resolve-clean-action, scan, preflight, git-branch-audit,
    git-stash-audit) are pre-approved. The mutating scripts — clean-caches, clean-build,
    git-prune, git-tree-reset, git-tree-reset-batch, remove-path, clean-batch — are
    deliberately not granted and keep routing through the PreToolUse destructive guard and the
    permission flow, where the dry-run-then-confirm contract is actually enforced. Since the old rule
    matched nothing, nothing regresses.
  • The clean PreToolUse guard's own command still resolves ${CLAUDE_PLUGIN_ROOT} and is
    unchanged — hook commands are a different substitution context where that variable is
    documented to work. Breaking that guard would have been the worst outcome available here.

context7 is not in this PR

Ledger row B8 (Bash(npm view ctx7 version*)) is NOT_REPRODUCED — a fully-pinned rule the
convention explicitly exempts at README.md:94-100, flagged only by a P1_ERE regex over-reach
(verifier row A19, owned by claude-config). Checked independently: context7's allowed-tools
carry no bash wrapper and no ${CLAUDE_PLUGIN_ROOT}, so the paired policy has nothing to pair
there. No change, no bump.

Test plan

1. The shipped claude-config detector suite still passes, unmodified (that file belongs to
lane A, which is editing line 135 of it this batch — see "Not done" below):

$ bash plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh
...
PASS: jq required message

All 50 checks passed.

2. The detector's own findings against these six plugins: 8 → 1.

Before (at 685dd381) — seven real defects plus the known context7 false positive:

warning [P1] plugins/code-tidying/skills/audit-comment-residue/SKILL.md  'Bash(bash *audit-comment-residue/scripts/detect.sh*)' ...
warning [P1] plugins/code-tidying/skills/tidy/SKILL.md                   'Bash(bash ${CLAUDE_PLUGIN_ROOT}/skills/tidy/scripts/open-pr-count.sh:*)' ...
warning [P1] plugins/context7/skills/setup/SKILL.md                      'Bash(npm view ctx7 version*)' ...
warning [P1] plugins/docs-hygiene/skills/audit-noise/SKILL.md            'Bash(bash *audit-noise/scripts/detect.sh*)' ...
warning [P1] plugins/prototype/skills/explore-directions/SKILL.md        'Bash(bash ${CLAUDE_PLUGIN_ROOT}/scripts/detect-ecosystems.sh:*)' ...
warning [P1] plugins/prototype/skills/pressure-test/SKILL.md             'Bash(bash ${CLAUDE_PLUGIN_ROOT}/scripts/detect-ecosystems.sh:*)' ...
warning [P1] plugins/repo-fleet-hygiene/skills/audit/SKILL.md            'Bash(bash ${CLAUDE_SKILL_DIR}/scripts/audit-fleet.sh *)' ...
warning [P1] plugins/repo-hygiene/skills/clean/SKILL.md                  'Bash(bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/*)' ...

After — only the row the ledger classified NOT_REPRODUCED remains, which this PR is instructed not
to touch:

warning [P1] plugins/context7/skills/setup/SKILL.md  'Bash(npm view ctx7 version*)' ...

3. New pairing contract, one *.test.sh per changed plugin — asserts no interpreter-led grant
and no ${CLAUDE_PLUGIN_ROOT} in allowed-tools, every ${CLAUDE_SKILL_DIR} invocation in the
skill's markdown unquoted and free of a bash wrapper, and every granted script present,
executable, and actually invoked by a body.

Passes on this branch:

$ bash plugins/repo-hygiene/scripts/allowed-tools-pairing.test.sh
PASS: clean: no interpreter-led grant in allowed-tools
PASS: clean: allowed-tools free of ${CLAUDE_PLUGIN_ROOT}
PASS: clean: granted script resolve-clean-action.sh exists and is executable
PASS: clean: grant for resolve-clean-action.sh is paired with a body invocation
... (scan.sh, preflight.sh, git-branch-audit.sh, git-stash-audit.sh)
All allowed-tools pairing checks passed.

Fails on the pre-fix tree (git archive HEAD | tar -x, tests copied in) — including the two traps
this PR had to avoid:

===== docs-hygiene (PRE-FIX tree) =====
FAIL: audit-noise: allowed-tools carries an interpreter-led 'Bash(bash …' grant
FAIL: skills/audit-noise/SKILL.md: body invokes a bundled script through 'bash' — rule cannot stay non-interpreter-led
FAIL: skills/audit-noise/SKILL.md: body quotes the bundled-script path — an unquoted rule will not match it
FAIL: audit-noise: no ${CLAUDE_SKILL_DIR} bundled-script grant found
allowed-tools pairing contract violated.
exit=1

===== repo-fleet-hygiene (PRE-FIX tree) =====
FAIL: audit: allowed-tools carries an interpreter-led 'Bash(bash …' grant
FAIL: skills/audit/SKILL.md: body quotes the bundled-script path — an unquoted rule will not match it
...
exit=1

That third assertion is the mechanized form of the B5 quote mismatch #1798 could not assert.

3b. repo-hygiene additionally guards its grant narrowing with an explicit allowlist (added in
review). The pairing checks validate whatever is granted; they could not catch the set being
re-widened, because every mutating script under clean is bundled, executable, and invoked in the
skill's markdown — so a grant added for one of them would "pair" correctly. Verified by injecting
Bash(${CLAUDE_SKILL_DIR}/scripts/clean-caches.sh:*) into the frontmatter:

PASS: clean: granted script clean-caches.sh exists and is executable
PASS: clean: grant for clean-caches.sh is paired with a body invocation
FAIL: clean: granted set drifted from the allowlist
    expected: git-branch-audit.sh git-stash-audit.sh preflight.sh resolve-clean-action.sh scan.sh
    actual:   clean-caches.sh git-branch-audit.sh git-stash-audit.sh preflight.sh resolve-clean-action.sh scan.sh
allowed-tools pairing contract violated.
exit=1

The first two lines are the point — it would have gone green without the allowlist.

4. Direct invocation verified against the installed plugin cache, not just the worktree:

$ ls -l ~/.claude/plugins/cache/melodic-software/prototype/0.6.0/scripts/detect-ecosystems.sh
-rwxr-xr-x ... detect-ecosystems.sh
$ C:/Users/.../cache/melodic-software/prototype/0.6.0/scripts/detect-ecosystems.sh
package.json

5. Repo gates:

$ bash scripts/validate-plugins.sh
✔ Validation passed   (all plugin manifests and the catalog)

$ bash scripts/check-changelog-parity.sh --check
Every versioned plugin has a CHANGELOG.md ... and none documents a version above its manifest.
$ bash scripts/check-changelog-parity.sh --check-bump origin/main
Every plugin whose version changed vs origin/main has a '## [<version>]' CHANGELOG.md entry.
$ bash scripts/check-changelog-parity.sh --check-order
All 75 changelog(s) read newest-first with no duplicate versions.

$ bash scripts/check-skill-portability.sh origin/main
No unexcused coupling tokens in 15 skill file(s).

6. Existing tests in the five changed plugins all pass (detect.test.sh, open-pr-count.test.sh,
audit-fleet.test.sh, and the full repo-hygiene/clean suite).

Lane judgment calls (flagged for review)

OR-5 authorized a body+rule rewrite. These three go slightly beyond that wording. Each is defensible
and each is independently revertible without touching the grant fix:

  1. Three skills gained Bash(grep:*) / Bash(head:*) / Bash(echo:*). A rule must match each
    subcommand of a compound command independently, so a script grant alone left the surrounding
    pipeline uncovered and the pre-compute prompted anyway — the fix would not have taken effect
    without them. Bare-name shape, read-only commands, precedented by prototype's pre-existing
    head/echo grants. Back these out and the grants still match; they just don't help yet.

    Open question, flagged rather than assumed. A bare-name grant like Bash(echo:*) reads as
    "this command with any arguments," and the permissions doc's compound-command separators
    (&&, ||, ;, |, |&, &, newline) do not include redirection — so whether a redirect
    operand rides along inside such a grant is undocumented, and I did not verify it. That deserves a
    deliberate call rather than a guess from me. Surrounding facts: prototype already shipped
    Bash(head:*) and Bash(echo:*) on main before this batch, so the shape is existing repo
    posture rather than something introduced here; the net-new instances are code-tidying's two
    skills and docs-hygiene:audit-noise. If the answer is "yes, redirects ride along," narrowing is
    a separate mechanical change (exact-match rules for the three fixed fallback strings) that does
    not disturb the grant pairing.

  2. repo-hygiene grants five of twelve scripts, not twelve. Choosing which scripts to
    pre-approve is a scope decision, not a mechanical rewrite. Rationale in the security note above.
    Nothing regresses either way, since the old rule matched nothing.

  3. repo-hygiene's context/*.md were converted too. Reverted in 0fba87c8 at
    orchestrator direction — this PR now touches SKILL.md only.
    Converting them assumed
    ${CLAUDE_SKILL_DIR} is substituted in a bundled non-SKILL.md file, which I flagged as
    unverified and which the docs resolve neither way: the skills page scopes substitution to "the
    skill's markdown content", while ${CLAUDE_PLUGIN_ROOT}'s documented scope is broader ("anywhere
    the placeholder appears"), so the two are not interchangeable here just because both work in
    SKILL.md. If the assumption were wrong, the body would emit a literal the substituted rule
    cannot match — failing safe (a prompt, never a wrong action) but silently, which is the exact
    defect class this PR removes.

    The grant fix is unaffected: all five granted scripts are invoked from SKILL.md, so the
    pairing is complete for everything the grant covers, and the pairing gate still passes (output
    below). The context/*.md half is tracked in fix(repo-hygiene): finish clean's invocation pairing in context/*.md, gated on ${CLAUDE_SKILL_DIR} substitution scope #2237, gated on settling the substitution scope,
    with the empirical test that would settle it and a cross-link to skills: ${CLAUDE_SKILL_DIR} used in pre-compute but not documented as harness-substituted — three skills may silently report fake pre-compute data #1824 — the open issue on the
    same variable's substitution behaviour in pre-compute.

The five per-plugin pairing gates are near-identical by design: plugins must be independently
installable, so each owns its copy rather than sourcing a shared file. They are not byte-identical
(each carries its own SKILLS=(…)), so cross-plugin-source-drift correctly does not claim them —
but equally, no gate protects them from diverging.

Not done, deliberately

The lane brief asked to extend plugins/claude-config/.../permission-rule-check.test.sh. That file
belongs to lane A, whose A1 fix sketch names line 135 of it — editing it here would produce
exactly the merge conflict the plugin partition exists to prevent. The suite was run unmodified
instead (50/50, above), the detector's before/after finding counts are recorded, and the new
per-plugin pairing tests carry the contract inside this lane's own fence.

Related

Closes #2221

Follow-up split out of this PR: #2237 — finish clean's pairing in context/*.md, gated on
settling ${CLAUDE_SKILL_DIR}'s substitution scope in bundled non-SKILL.md files. Cross-links
#1824 (open), the neighbouring uncertainty about the same variable in pre-compute.

Inbox item: 20260811-024628-claude-config-audit-permission-grants-defects-and-fleet-grant-hygiene
(ledger I10-permission-grants-fleet.md, half B rows B1B7; B8 NOT_REPRODUCED and excluded).

Prior art: #1798 (closed — fixed repo-fleet-hygiene's variable half only and parked quoting);
#843 (plugin bin/ delivery gap, which is why relocation rather than bin/ is the remedy here).

kyle-sexton and others added 5 commits August 11, 2026 17:37
… it must match

Both skills granted `Bash(bash ${CLAUDE_PLUGIN_ROOT}/scripts/detect-ecosystems.sh:*)`.
`${CLAUDE_PLUGIN_ROOT}` is not substituted in `allowed-tools` — only
`${CLAUDE_SKILL_DIR}` and `${CLAUDE_PROJECT_DIR}` are — so the rule stayed a
literal string, never matched, and the ecosystem pre-compute fell through to a
prompt or the classifier on every invocation.

Dropping `bash` from the rule, the repair that suggests itself, would have made
the grant dead rather than working: `bash` is not among the wrappers Claude Code
strips before matching. The change is paired — the body invokes the script
directly and unquoted, and the rule names that same string.

`${CLAUDE_SKILL_DIR}` resolves to the skill's own subdirectory, so each skill
gets a self-locating entry point under `skills/<skill>/scripts/` that execs the
still-single-sourced detector at the plugin root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ions

`tidy` granted `Bash(bash ${CLAUDE_PLUGIN_ROOT}/skills/tidy/scripts/open-pr-count.sh:*)`,
which never matched — `${CLAUDE_PLUGIN_ROOT}` is not substituted in
`allowed-tools`. `audit-comment-residue`'s `Bash(bash *audit-comment-residue/scripts/detect.sh*)`
did match, but only because its wildcards absorbed the `bash` wrapper and the
quotes around the body's path; that is the wildcarded-interpreter shape auto mode
drops, and it matches the wrapper name at any path.

Dropping `bash` from the rules would have made the first grant dead and
regressed the second from working to broken: `bash` is not among the wrappers
Claude Code strips before matching. Both changes are paired — the bodies invoke
their scripts directly and unquoted, and the rules name those same strings.

Both skills also now grant the read-only commands their pre-computes pipe
through; a rule must match each subcommand of a compound command independently,
so a script grant alone left the pipeline uncovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Bash(bash *audit-noise/scripts/detect.sh*)` matched only because its leading and
trailing wildcards absorbed the `bash` wrapper and the quotes around the body's
path. That is the wildcarded-interpreter shape auto mode drops outright, and a
rule anchored on a bare wrapper name matches it at any path.

Dropping `bash` from the rule would have been a straight regression from a
working grant to a broken one — `bash` is not among the wrappers Claude Code
strips before matching, and removing the wildcards without unquoting the body
breaks the match a second way. The change is paired: the body invokes the script
directly and unquoted, and the rule names that same string, narrow and anchored
to this skill's own directory.

The skill also now grants the read-only commands its pre-compute pipes through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… read-only rules

`Bash(bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/*)` never matched:
`${CLAUDE_PLUGIN_ROOT}` is not substituted in `allowed-tools`. Had it matched it
would have pre-approved every script in the directory behind one
wildcarded-interpreter rule — including `git-tree-reset.sh` and `remove-path.sh`.

Dropping `bash` from the rule would have produced a dead grant rather than a
working one, since `bash` is not among the wrappers Claude Code strips before
matching. The change is paired: `SKILL.md` and every bundled `context/*.md` now
invoke their scripts directly through `${CLAUDE_SKILL_DIR}/scripts/…`, and the
rules name those same strings.

The grant is now five narrow rules covering the read-only scripts only. The
mutating scripts stay unapproved and keep routing through the PreToolUse
destructive guard and the permission flow. Nothing regresses — the old rule
matched nothing. The guard hook's own command still resolves
`${CLAUDE_PLUGIN_ROOT}` and is unchanged; hook commands are a different
substitution context where that variable is documented to work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t grant dead

#1798 corrected this rule's variable half and explicitly parked quoting as
"Unverified, not asserted." The shipped rule wrote the path unquoted while the
body ran `bash "${CLAUDE_SKILL_DIR}/scripts/audit-fleet.sh"` with it quoted. A
Bash rule matches the literal command string, so the character after the wrapper
name is a closing quote where the rule expects a path: the grant has never
matched since that fix landed.

#1798's remaining advice — drop the `bash` prefix only — would not have fixed it
either. `bash` is not among the wrappers Claude Code strips before matching, so a
rule without it stops matching a body that still says `bash <path>`, and dropping
the prefix addresses nothing about the quoting. The change is paired on both
axes: the body invokes the script directly and unquoted, and the rule names that
exact string. Passed arguments stay individually quoted; it is a prefix rule, so
their quoting does not affect the match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 11, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 56s —— View job


Security review — PR #2225

Reviewed the 30 changed files (git diff origin/main...HEAD). Scope: the allowed-tools grant rewrites, the two new detect-ecosystems.sh wrapper scripts, the five new allowed-tools-pairing.test.sh test scripts, and the repo-hygiene/clean skill's narrowed grant set. Deferring to zizmor's advisory lane for anything GitHub-Actions-specific (none of the changed files are workflows).

Verified safe / as-claimed:

  • repo-hygiene/clean's new grant list (resolve-clean-action.sh, scan.sh, preflight.sh, git-branch-audit.sh, git-stash-audit.sh) — I read all five scripts; none contain deletion, --apply, or mutating git operations. The mutating scripts (clean-caches.sh, git-tree-reset*.sh, clean-batch.sh, etc.) are correctly excluded from the grant and still route through the PreToolUse guard, matching the PR's security note.
  • The two new skills/*/scripts/detect-ecosystems.sh wrapper scripts self-locate via BASH_SOURCE (not attacker-influenceable) and forward "$@" correctly quoted into exec — no injection surface.
  • repo-fleet-hygiene/skills/audit/SKILL.md's config handling claim ("validates with git config --file; never sources or executes it") checks out against the unchanged audit-fleet.sh.
  • resolve-clean-action.sh (now reachable without a confirmation prompt for the first time) does pure case-statement token classification on "$@" — no eval, no command construction from input.

Finding (worth a second look, not blocking):

[IMPORTANT / plausible confidence] Three skills gain a brand-new, fully unscoped Bash(grep:*) / Bash(head:*) auto-approval grant that did not exist before this PR:

Before this PR these skills had no grant covering grep/head, so any pre-computed-context call to them prompted the user. Now they're silently pre-approved for the whole turn with no path or pattern restriction — a bare Bash(grep:*) rule matches literally any grep … invocation, on any file the process can read (e.g. ~/.ssh/id_rsa, ~/.aws/credentials, .env). These three skills are disable-model-invocation: false (model-invocable) and exist specifically to scan untrusted, author-written content (code comments, markdown docs) — exactly the kind of input a prompt-injection payload could ride in on. If injected content directs Claude to run an unrelated grep/head against a credentials file in the same turn, that now executes without a confirmation prompt.

This is a real widening (not present pre-PR) and, unlike the repo-hygiene/clean case, isn't discussed in the PR's own "Security review note," which only covers the clean skill's grant narrowing. That said, this repo's own docs/conventions/permission-rule-hygiene/README.md explicitly defines a bare command like Bash(grep:*) as the "correct pattern" (non-interpreter-led, survives auto mode) — so this may be an accepted convention tradeoff the maintainers have already made deliberately, just one that wasn't called out for these three grants specifically. Worth an explicit maintainer decision rather than a blocking issue: e.g. scoping the pre-computed grep/head calls to fixed, non-attacker-influenceable patterns/paths if that's easy, or an explicit note that this tradeoff was made knowingly (as was done for clean).

No other issues found. No command/path injection, no credential handling, no unsafe eval/source of external input, and no GitHub Actions changes in this PR's diff.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 4m 11s —— View job


Review complete

  • Gather context (PR body, diff, CLAUDE.md, REVIEW.md, permission-rule-hygiene convention)
  • Review plugin.json / CHANGELOG bumps for each of 5 plugins
  • Review allowed-tools-pairing.test.sh (new, 5 copies)
  • Review SKILL.md body+rule pairing changes (prototype, code-tidying, docs-hygiene, repo-fleet-hygiene, repo-hygiene)
  • Review new wrapper scripts (prototype detect-ecosystems.sh x2) — found a bug
  • Review repo-hygiene/clean context/*.md changes
  • Post inline findings + summary

This lane covers correctness, design, conventions, error handling, tests, and maintainability; security findings are left to the dedicated claude-security-review workflow per REVIEW.md.

🔴 Important — new wrapper scripts committed without the executable bit

Posted as inline comments on explore-directions/scripts/detect-ecosystems.sh:17 and pressure-test/scripts/detect-ecosystems.sh:17.

I independently confirmed via git ls-files -s that both newly-added wrapper scripts are committed at mode 100644 (not executable), while the plugin-root script they exec (plugins/prototype/scripts/detect-ecosystems.sh) is 100755. The rewritten allowed-tools rule and SKILL.md body both invoke these wrappers directly and unquoted (no bash prefix) — that's the whole point of the pairing fix — which requires the executable bit. Without it, invocation fails ("Permission denied") and the pre-computed Project ecosystems: line silently falls back to "none detected" on every call.

This also appears to contradict the plugin's own new scripts/allowed-tools-pairing.test.sh, which asserts [[ -x "skills/$skill/scripts/$g" ]] for each granted script — against the committed tree that check reads as a FAIL, and the CHANGELOG's claim that the new test verifies scripts are "present, executable, and actually invoked" doesn't hold for what's actually in this diff. (I did not execute the test script myself — sandboxing in this environment blocked running it directly — but the file-mode evidence from git ls-files -s is unambiguous and the test's own -x check is visible in the source.) The PR's Test Plan output showing these tests passing is author-claimed; I have not independently reproduced it and the mode-bit evidence is inconsistent with it.

Fix is mechanical: chmod +x both files and re-commit so git records 100755.

Everything else checked out

  • Version bumps in all five plugin.json files match the PR body's table, and each has a corresponding CHANGELOG.md entry.
  • The repo-fleet-hygiene quote-mismatch fix (dropping both bash and the quotes, on both the rule and the body) is correct and consistent with the stated mechanism — bash is confirmed not in Claude Code's stripped-wrapper set per docs/conventions/permission-rule-hygiene/README.md:218-220.
  • repo-hygiene/clean's narrowed grant is genuinely narrower, not wider: only the five read-only scripts are pre-approved, and every mutating script's body invocation was updated in lockstep (still routes through the unmodified PreToolUse guard, which correctly keeps resolving ${CLAUDE_PLUGIN_ROOT} — a different, working substitution context for hooks).
  • The three added grep/head/echo grants (in code-tidying/audit-comment-residue, code-tidying/tidy, docs-hygiene/audit-noise) match the sub-commands their pre-computed-context pipelines actually use.
  • Existing (non-new) scripts referenced by the rewritten rules (detect.sh, open-pr-count.sh, audit-fleet.sh, and all repo-hygiene/clean/scripts/*.sh) are already executable in the index, so this bug is specific to the two newly-added prototype wrapper files.
  • Each plugin's allowed-tools-pairing.test.sh is correctly scoped to its own skill(s) (SKILLS=(...) matches the plugin), not a copy-paste artifact from another plugin.

Comment thread plugins/prototype/skills/pressure-test/scripts/detect-ecosystems.sh
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 4 times. The lane skips further automatic reviews after 5; deleting this comment resets the count.

`core.filemode` is false in this checkout, so `chmod +x` never reached the
index and the two new prototype wrappers plus the five pairing gates went in as
100644. That is not cosmetic here: the whole point of the paired rewrite is that
the body invokes the bundled script directly, which requires the exec bit to
survive the plugin-cache install. CI's exec-bit gate caught it, and so did the
new pairing gate's own "granted script exists and is executable" assertion —
which is what that assertion is for.

Also silences SC2016 file-wide in the pairing gate. Every single-quoted `${…}`
in it is a fixed string searched for verbatim in markdown and frontmatter, where
those placeholders are substituted by Claude Code at load time; letting the
shell expand any of them would make the gate silently match nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 21s —— View job


Security review — PR #2225 (re-review at latest HEAD 5145980)

  • Gather context (PR body, prior review threads, diff vs origin/main)
  • Read changed allowed-tools grants across the 5 plugins
  • Read new wrapper scripts and pairing test scripts
  • Analyze for injection, authorization, and trust-boundary issues
  • Post findings

Scope: git diff origin/main...HEAD (30 files) at commit 5145980. Deferring to zizmor's advisory lane for anything GitHub-Actions-specific — no workflow files changed in this diff.

Previously flagged issue is now fixed. The wrapper-script executable-bit gap flagged in the earlier review pass (explore-directions/pressure-test detect-ecosystems.sh committed as 100644) is resolved in 5145980 — both files are now 100755 in the index, matching what the paired unquoted invocation requires.


🟡 IMPORTANT — new unscoped Bash(grep:*) / Bash(head:*) grants on skills that process untrusted content (confidence: plausible, turn-scoped)

Three skills gain a brand-new, fully unscoped grep/head/echo auto-approval that did not exist on origin/main:

These three skills are disable-model-invocation: false (model-invocable) and their whole purpose is to scan author-written, untrusted content (code comments, markdown docs) — exactly the kind of surface a prompt-injection payload can ride in on. A bare Bash(grep:*)/Bash(head:*) rule matches any invocation of that command against any path the process can read (e.g. ~/.ssh/id_rsa, ~/.aws/credentials, .env), with no path or pattern restriction.

Per this repo's own docs/conventions/permission-rule-hygiene/README.md:139-142, skill-frontmatter allowed-tools grants are turn-scoped ("clears when you send your next message"), which meaningfully narrows the window — this isn't a persistent session-wide grant. But the window that remains is exactly the dangerous one: it's live for the entire turn in which the skill ingests the untrusted content (comments/docs), which is the same turn injected instructions would need to act in to redirect a grep/head call at a credentials file and have the result surface in the transcript/output.

This is a genuine widening (not present pre-PR) and isn't discussed in the PR's own security note, which addresses only the repo-hygiene/clean grant narrowing. That said, docs/conventions/permission-rule-hygiene/README.md does treat a bare command like grep/head as the structurally "correct" non-interpreter-led shape (vs. Bash(bash ...) or Bash(npx:*)), so this may be a deliberate, repo-accepted convention tradeoff rather than an oversight — it just wasn't called out for these three grants the way the clean narrowing was. Worth an explicit maintainer call: either scope these to the fixed, non-attacker-influenceable patterns the pre-computed-context pipelines actually use (e.g. Bash(grep -E '^(Summary total:|Finding shape:):*') style anchoring isn't supported by the rule syntax, but restricting to piping only from the paired detector script would reduce the blast radius), or an explicit acknowledgment that this tradeoff was made knowingly, as was done for clean.


Verified safe / as-claimed

  • repo-hygiene/clean's narrowed grant (resolve-clean-action.sh, scan.sh, preflight.sh, git-branch-audit.sh, git-stash-audit.sh) — read all five; none contain deletion, --apply, or mutating git operations, and none accept attacker-shaped arguments (resolve-clean-action.sh does pure case-statement classification on $ARGUMENTS, no eval). Mutating scripts stay ungranted and behind the unchanged PreToolUse destructive guard.
  • The PreToolUse guard command in clean/SKILL.md still resolves ${CLAUDE_PLUGIN_ROOT} unchanged — correct, since that's a different (working) substitution context for hooks; this PR doesn't touch it.
  • The two new detect-ecosystems.sh wrappers self-locate via BASH_SOURCE (not attacker-influenceable) and forward "$@" correctly into exec — no injection surface, and now correctly executable (see above).
  • repo-fleet-hygiene/audit's rule/body pairing rewrite doesn't touch audit-fleet.sh itself; spot-checked for eval/unsafe source — none found.
  • The five new allowed-tools-pairing.test.sh scripts operate only on local repo file contents (no network, no eval, no execution of scanned input) — no injection surface.
  • ${CLAUDE_SKILL_DIR} substitution is consistently anchored to each skill's own directory in every rewritten rule; no path escapes or cross-skill grant leakage observed.

No CRITICAL findings. No command/path injection, no credential handling, no unsafe eval/source of external input, and no GitHub Actions changes in this PR's diff.
· branch fix/2221-allowed-tools-grant-pairing

…ired grant

Surface 4 asserted that bundled assets are addressed through
`${CLAUDE_PLUGIN_ROOT}`, which the pairing fix falsified — the grant and the
documented invocation now both resolve through `${CLAUDE_SKILL_DIR}`. Adds the
2026-08-11 re-check line recording that the change admits no new execution,
network, or config surface: the previous rule never matched, so this makes an
already user-invoked script prompt-free rather than granting anything new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 43s —— View job


Security review — PR #2225 (re-review at HEAD 0873c94)

  • Gather context (diff since last-reviewed commit 5145980)
  • Review the new commit 0873c94
  • Re-verify prior findings remain accurate
  • Post findings

Scope: git diff 5145980..0873c94 — only one file changed since the last security-review pass on this PR.


No new security-relevant change

The only diff since the prior review is plugins/repo-fleet-hygiene/skills/audit/reference/security-review.md — a documentation-only update to the plugin's own security-review reference doc. It adds a "re-checked 2026-08-11" note and corrects a stale reference from ${CLAUDE_PLUGIN_ROOT} to ${CLAUDE_SKILL_DIR} (cache-isolation section) to match the code that was already reviewed.

I verified the claims in that doc update against the actual code:

  • plugins/repo-fleet-hygiene/skills/audit/SKILL.md:6 grants Bash(${CLAUDE_SKILL_DIR}/scripts/audit-fleet.sh:*), and the body at line 67 invokes ${CLAUDE_SKILL_DIR}/scripts/audit-fleet.sh directly — the grant and invocation are paired as described.
  • No script logic, grant, or hook changed in this commit. No new execution, network, or config surface is introduced — this is a docs-only correction to keep the plugin's self-documentation accurate.

Findings from the earlier review passes on this PR still stand

Nothing in this final commit changes them:

  • [IMPORTANT / plausible] plugins/code-tidying/skills/audit-comment-residue/SKILL.md, plugins/code-tidying/skills/tidy/SKILL.md, and plugins/docs-hygiene/skills/audit-noise/SKILL.md still carry brand-new, fully unscoped Bash(grep:*) / Bash(head:*) auto-approval grants that didn't exist pre-PR, on model-invocable skills that scan untrusted author-written content. Per docs/conventions/permission-rule-hygiene/README.md:139-142 these grants are turn-scoped, which narrows but doesn't eliminate the window — it's live for the exact turn in which untrusted content (a potential prompt-injection vector) is ingested. This may be an accepted convention tradeoff (the repo's own hygiene doc treats bare grep/head as the structurally "correct" pattern) but wasn't explicitly called out for these three grants the way the repo-hygiene/clean narrowing was. Worth an explicit maintainer acknowledgment, not blocking.
  • The previously-flagged missing executable bit on the two new detect-ecosystems.sh wrapper scripts was fixed in 5145980 and remains fixed (verified: 100755 in the index).

No CRITICAL findings. No command/path injection, no credential handling, no unsafe eval/source of external input, and no GitHub Actions changes anywhere in this PR's diff.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 52s —— View job


Code review — PR #2225 (re-review at latest HEAD 0873c94)

  • Gather context (PR body, REVIEW.md scope split, docs/conventions/permission-rule-hygiene/README.md, prior review threads)
  • Review what changed since the last code-review pass (774cb660873c94: exec-bit fix, quote-mismatch fix, security-review doc re-check)
  • Re-check the executable-bit fix for the two prototype wrapper scripts
  • Re-check the repo-fleet-hygiene quote-mismatch fix and its security-review doc update
  • Spot-check the five allowed-tools-pairing.test.sh copies and SKILL.md pairing
  • Post findings

Scope: git diff against this PR's actual merge-base (685dd38, since local origin/main had diverged past it) — 31 files, matches the PR's stated stat. Per REVIEW.md, this lane owns correctness/design/conventions/tests/maintainability; security findings are left to the dedicated security lane (already covered in this thread's earlier passes).

Previously flagged issues — confirmed fixed

  • Executable bit (explore-directions/pressure-test detect-ecosystems.sh): independently verified via git ls-files -s — both now 100755 in the index, matching the plugin-root script they exec.
  • repo-fleet-hygiene quote mismatch: the rule and body both now use the unquoted ${CLAUDE_SKILL_DIR}/scripts/audit-fleet.sh form; the security-review doc (skills/audit/reference/security-review.md) was updated in step with a dated re-check entry.
  • Cross-checked all five plugins for stray leftover bash ${CLAUDE_SKILL_DIR}... or quoted-path invocations outside the diffed hunks (reference docs, evals) — none found; the pairing is complete everywhere the scripts are mentioned.

New finding (posted inline)

🔴 Important — test-coverage gap on plugins/repo-hygiene/scripts/allowed-tools-pairing.test.sh:72-87: the test validates that whatever is granted is paired/executable, but never asserts the granted set stays limited to the five intended read-only scripts. The PR's own security note stakes real posture on the mutating scripts staying ungranted — nothing in the new test would catch a future accidental grant of one of them. Full reasoning and a suggested fix are in the inline comment.

Suggestion (not blocking)

🟡 The five new allowed-tools-pairing.test.sh files (code-tidying, docs-hygiene, prototype, repo-fleet-hygiene, repo-hygiene) are byte-identical (diffed all pairs myself) except for a single SKILLS=(...) line — 94 lines × 5, ~1 line of real per-plugin variance. This is likely necessary given each plugin installs independently from the marketplace cache (no shared cross-plugin script location), but it means a future fix to the pairing logic itself has to be manually propagated five times with nothing to catch drift if one copy is missed. Not asking for a change here, just flagging the maintenance cost.

Everything else checked out

  • plugin.json version bumps and CHANGELOG.md entries match the PR body's table for all five plugins; no stray unrelated diffs in the manifests.
  • The ${CLAUDE_SKILL_DIR} vs. ${CLAUDE_PLUGIN_ROOT} substitution claims (only the former is expanded in allowed-tools; bash is not in the stripped-wrapper set) check out against the repo's own docs/conventions/permission-rule-hygiene/README.md:120-126,208-220, which the PR cites accurately.
  • detect-ecosystems.sh wrapper path math (../../../ from skills/<skill>/scripts/ back to the plugin root) is correct — verified by hand.
  • repo-hygiene/clean's PreToolUse hook command correctly still resolves ${CLAUDE_PLUGIN_ROOT} unchanged (a different, working substitution context for hooks) — this PR doesn't touch it.
  • The YAML frontmatter parsing in the new test scripts (awk isolating allowed-tools: to the next top-level key) correctly handles both the flow-style single-line array form and the block - list form with an inline comment (repo-hygiene's grant list), without false-triggering on the comment text.
  • No duplicate/conflicting allowed-tools grants introduced (spot-checked each plugin's before/after array for accidental repeats).

Note on verification: I did not execute any of the new *.test.sh scripts myself — this sandbox blocks running scripts (bash <path> requires approval that isn't available here), same limitation noted in the prior code-review pass. All test-behavior analysis above is from reading the test logic, not from running it; the PR's own Test Plan output is author-claimed and unverified by me.
· branch fix/2221-allowed-tools-grant-pairing

Comment thread plugins/repo-hygiene/scripts/allowed-tools-pairing.test.sh
…lowlist

The pairing checks validate whatever IS granted; they could not catch the grant
set being re-widened. Every mutating script under `clean` is bundled,
executable, and invoked in the skill's markdown, so a rule added for
`clean-caches.sh` or `git-tree-reset.sh` would "pair" correctly and pass every
other assertion green — silently undoing the read-only-only boundary this
change just carved out and that the PR's security note stakes posture on.

Adds an optional per-skill allowlist; `clean` names its five read-only scripts
and the granted set must equal it exactly. The other four plugins have no
narrowing decision, so their allowlist is empty and the check is skipped.

Verified by injecting `Bash(${CLAUDE_SKILL_DIR}/scripts/clean-caches.sh:*)` into
the frontmatter: every other check still passed, and the allowlist failed with
the expected/actual diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 45s —— View job


Security review — PR #2225 (re-review at latest HEAD 78331c0)

  • Gather context (diff since last-reviewed commit 0873c94)
  • Review new commit 78331c0
  • Re-verify prior findings remain accurate
  • Post findings

Scope: git diff 0873c94..78331c0 — one commit, five files changed since the last security-review pass on this PR.


No new security-relevant change

The only diff since the prior review (0873c94) touches the five plugins/*/scripts/allowed-tools-pairing.test.sh files, adding an optional expected_granted() allowlist check to each. This is test-only:

  • For four plugins (code-tidying, docs-hygiene, prototype, repo-fleet-hygiene), expected_granted() returns empty for every skill — no behavior change, the hook is just wired up but inert there.
  • For repo-hygiene, expected_granted clean is populated with the exact five read-only scripts (git-branch-audit.sh git-stash-audit.sh preflight.sh resolve-clean-action.sh scan.sh) and the test now fails if the granted set in allowed-tools ever drifts from that list — in either direction (missing or extra).

This closes the test-coverage gap flagged in the earlier code-review pass on this PR (the pairing tests validated that whatever was granted paired correctly, but nothing asserted the granted set stayed limited to the five intended scripts). It's a net-positive change from a security standpoint: it adds a regression guard on exactly the boundary this PR's own security note stakes posture on — the mutating clean scripts (clean-caches.sh, git-tree-reset*.sh, remove-path.sh, etc.) staying ungranted. No production code, allowed-tools grant, script, or workflow file changed in this commit.

I verified the allowlist value matches what's actually granted in plugins/repo-hygiene/skills/clean/SKILL.md's allowed-tools — it does.

Findings from earlier review passes on this PR still stand (unaffected by this commit)

  • [IMPORTANT / plausible] plugins/code-tidying/skills/audit-comment-residue/SKILL.md, plugins/code-tidying/skills/tidy/SKILL.md, and plugins/docs-hygiene/skills/audit-noise/SKILL.md still carry brand-new, fully unscoped Bash(grep:*) / Bash(head:*) auto-approval grants that didn't exist pre-PR, on model-invocable skills that scan untrusted author-written content (code comments, markdown docs). Per docs/conventions/permission-rule-hygiene/README.md:139-142 these grants are turn-scoped, which narrows but doesn't eliminate the window — it's live for the exact turn in which the untrusted content (a potential prompt-injection vector) is ingested. This may be an accepted convention tradeoff (the repo's own hygiene doc treats bare grep/head as the structurally "correct" pattern) but wasn't explicitly called out for these three grants the way the repo-hygiene/clean narrowing was in the PR's security note. Worth an explicit maintainer acknowledgment, not blocking.
  • The previously-flagged missing executable bit on the two new detect-ecosystems.sh wrapper scripts was fixed in 5145980 (confirmed 100755 in the index) and remains fixed.

No CRITICAL findings. No command/path injection, no credential handling, no unsafe eval/source of external input, and no GitHub Actions changes anywhere in this PR's diff.
· branch fix/2221-allowed-tools-grant-pairing

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 39s —— View job


Code review — PR #2225 (re-review at latest HEAD 78331c0)

  • Gather context (diff since last code-review pass at 0873c94)
  • Review new commit 78331c0 (allowlist guard for clean's grant narrowing)
  • Verify it addresses the prior test-coverage finding
  • Check for any other issues in the new commit
  • Post findings

Scope: git diff 0873c94..78331c0 — one file family changed (the five allowed-tools-pairing.test.sh copies, one hunk each). Per REVIEW.md, this lane covers correctness/design/conventions/tests/maintainability; security findings are left to the dedicated security lane (already covered in earlier passes on this thread).

Prior finding — confirmed fixed

The test-coverage gap flagged in the previous code-review pass (allowed-tools-pairing.test.sh validated pairing for whatever was granted, but never bounded the granted set to the intended five read-only scripts for clean) is resolved in this commit. Full verification posted as an inline comment on the new code — summary:

  • The new expected_granted() allowlist for clean (git-branch-audit.sh git-stash-audit.sh preflight.sh resolve-clean-action.sh scan.sh) matches the five grants actually present in skills/clean/SKILL.md:11-15 — I re-grepped it independently rather than trusting the PR's own claim.
  • It's a strict equality check, not subset/superset, so it catches drift in either direction — including the exact scenario the finding described (a future accidental grant of a mutating script like clean-caches.sh passing every other check silently).
  • The other four plugins correctly leave the allowlist empty (a no-op), since none of them narrow a previously-wildcarded grant the way clean does — consistent with the PR's own scoping of that judgment call to repo-hygiene.

Verification caveat: as in every prior pass on this PR, I did not execute the test script (bash <path> requires approval this sandbox doesn't grant) — the check above is a static read of the shell logic plus an independent grep of the current SKILL.md grant list, not a reproduced test run. The PR's own "All allowed-tools pairing checks passed" output remains author-claimed and unverified by me.

No new issues in this commit

  • The same boilerplate-duplication tradeoff noted in the earlier pass (near-identical allowed-tools-pairing.test.sh across five plugins, now with an added expected_granted stub in each) still applies but isn't new — each plugin must stay independently installable, so per-plugin copies are the accepted tradeoff, not something this commit changes.
  • No other files touched in this delta; nothing else to re-check.

Nothing further outstanding from this lane. All previously-flagged code-review findings on this PR are resolved as of 78331c0.
· branch fix/2221-allowed-tools-grant-pairing

Comment thread plugins/repo-hygiene/scripts/allowed-tools-pairing.test.sh
Converting the bundled context/*.md to ${CLAUDE_SKILL_DIR} assumed that
substitution reaches a bundled non-SKILL.md file. The skills docs scope it to
"the skill's markdown content" and do not resolve whether that includes a
context file loaded on demand; ${CLAUDE_PLUGIN_ROOT}'s documented scope is
broader ("anywhere the placeholder appears"), so the two are not interchangeable
here just because both work in SKILL.md.

If the assumption is wrong the body emits a literal the substituted rule cannot
match. That fails safe — a prompt, never a wrong action — but silently, which is
the defect class this PR exists to remove. Not a trade worth making inside a
change whose thesis is "make the grant actually match the invocation."

The grant fix is unaffected: all five granted scripts are invoked from SKILL.md,
so the pairing is complete for everything the grant covers, and the pairing gate
still passes. CHANGELOG corrected to describe the SKILL.md-only scope and the
reason. #2237 tracks the rest with the empirical test that settles it, and
cross-links #1824 as the neighbouring open question on the same variable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 44s —— View job


Security review — PR #2225 (re-review at latest HEAD 0fba87c)

  • Gather context (diff since last security-review pass at 78331c0)
  • Review new commit 0fba87c
  • Re-verify prior findings remain accurate
  • Post findings

Scope: git diff 78331c0..0fba87c — one commit, 7 files changed since the last security-review pass on this PR. Also re-confirmed exec bits and spot-checked the granted clean scripts for injection surface as a final sanity pass. Deferring to zizmor's advisory lane for anything GitHub-Actions-specific — no workflow files changed anywhere in this PR's diff.


No new security-relevant change

The only diff since the prior review (78331c0) touches plugins/repo-hygiene/CHANGELOG.md and six plugins/repo-hygiene/skills/clean/context/*.md files (action-router.md, clean-batch.md, git-branch-cleanup.md, git-tree-reset-batch.md, git-tree-reset.md, preflight.md). This is a revert: it reverts those context/*.md files' script invocations from the paired ${CLAUDE_SKILL_DIR}/scripts/… (unquoted, no bash) form back to the pre-PR bash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/… form, pending #2237.

This is a safety improvement, not a regression:

  • ${CLAUDE_SKILL_DIR} substitution scope in bundled non-SKILL.md files (like context/*.md) was flagged in the PR body itself as an unverified assumption. Reverting it removes that unverified assumption from the shipped diff rather than leaving it asserted.
  • None of the six reverted files are the file the allowed-tools grant is matched against (SKILL.md frontmatter) — the five ${CLAUDE_SKILL_DIR}-scoped grants in skills/clean/SKILL.md are untouched by this commit and still pair correctly with SKILL.md's own (still-converted) body invocations.
  • Net effect: if a context/*.md-routed invocation of one of the five read-only scripts doesn't match the narrowed grant, it now fails safe into a permission prompt (as it did pre-PR) rather than silently defeating the pairing. No script logic, grant, or hook changed.

I verified this by re-reading the six diffs directly — each hunk is a mechanical revert of ${CLAUDE_SKILL_DIR}/scripts/X.shbash ${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/X.sh, nothing else.

Final sanity pass on the read-only clean grant set

Re-read all five scripts now pre-approved via skills/clean/SKILL.md's allowed-tools (resolve-clean-action.sh, scan.sh, preflight.sh, git-branch-audit.sh, git-stash-audit.sh):

  • resolve-clean-action.sh: pure case statement classification over $ARGUMENTS/positional tokens — no eval, no command construction, no injection surface even with adversarial input.
  • The only source/eval-adjacent pattern across all five scripts is source "$SCRIPT_DIR/lib/clean-common.sh" (and cleanup-paths.sh) — $SCRIPT_DIR is self-derived from BASH_SOURCE, not attacker-influenceable.
  • No curl/wget/network calls in any of the five.
  • Confirmed via git ls-files -s that all newly-added scripts across the PR (detect-ecosystems.sh ×2, allowed-tools-pairing.test.sh ×5) remain mode 100755 at this HEAD — the previously-flagged executable-bit gap stays fixed.

Findings from earlier review passes on this PR still stand (unaffected by this commit)

  • [IMPORTANT / plausible] plugins/code-tidying/skills/audit-comment-residue/SKILL.md, plugins/code-tidying/skills/tidy/SKILL.md, and plugins/docs-hygiene/skills/audit-noise/SKILL.md still carry brand-new, fully unscoped Bash(grep:*) / Bash(head:*) auto-approval grants that didn't exist on origin/main, on model-invocable skills (disable-model-invocation: false) whose whole purpose is to scan untrusted, author-written content (code comments, markdown docs) — exactly the kind of surface a prompt-injection payload could ride in on. A bare Bash(grep:*)/Bash(head:*) rule matches any invocation of that command against any path the process can read, with no path/pattern restriction. Per docs/conventions/permission-rule-hygiene/README.md:139-142 these grants are turn-scoped, which narrows but doesn't eliminate the window — it's live for the exact turn in which the untrusted content is ingested. This may be an accepted convention tradeoff (the repo's own hygiene doc treats bare grep/head as the structurally "correct" pattern), but it wasn't explicitly called out for these three grants the way the repo-hygiene/clean narrowing was in the PR's own security note. Worth an explicit maintainer acknowledgment; not blocking.
  • The previously-flagged missing executable bit on the two new detect-ecosystems.sh wrapper scripts (fixed in 5145980) remains fixed, reconfirmed above.

No CRITICAL findings. No command/path injection, no credential handling, no unsafe eval/source of external input, and no GitHub Actions changes anywhere in this PR's diff.
· branch fix/2221-allowed-tools-grant-pairing

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 9s —— View job


Code review — PR #2225 (re-review at latest HEAD 0fba87c)

  • Gather context (diff since last code-review pass at 78331c0)
  • Review new commit 0fba87c (revert of context/*.md conversion)
  • Check consistency with the rest of the pairing fix
  • Post findings

Scope: git diff 78331c0..0fba87c — one commit, 7 files (repo-hygiene/CHANGELOG.md + the six skills/clean/context/*.md files). Per REVIEW.md, this lane covers correctness/design/conventions/tests/maintainability; security findings are left to the dedicated security lane.

Summary: clean, well-justified revert — no issues found

This commit walks back judgment call #3 from the PR body ("repo-hygiene's context/*.md were converted too") back to the pre-PR form, pending resolution of the unverified ${CLAUDE_SKILL_DIR} substitution-scope question in #2237. I verified this is a complete and correct revert, not a partial one:

  • All six context/*.md files (action-router.md, clean-batch.md, git-branch-cleanup.md, git-tree-reset-batch.md, git-tree-reset.md, preflight.md) now byte-for-byte match their pre-PR form at merge-base 685dd381 — confirmed by diffing each against that commit directly, not by trusting the CHANGELOG's description.
  • No stray ${CLAUDE_SKILL_DIR} references remain anywhere under context/ — grepped the whole directory.
  • SKILL.md itself is untouched and still uses the paired ${CLAUDE_SKILL_DIR}/scripts/... form for its five granted read-only scripts (SKILL.md:11-15), and the PreToolUse guard's command correctly still resolves ${CLAUDE_PLUGIN_ROOT} (a different, working substitution context for hooks) — this commit doesn't touch either.
  • plugins/repo-hygiene/scripts/allowed-tools-pairing.test.sh was not modified in this commit, and I traced through why it doesn't need to be: it globs every *.md under skills/clean (including context/, line 79) but only fails on bash ${CLAUDE_SKILL_DIR}... or quoted-${CLAUDE_SKILL_DIR} patterns specifically — the reverted files now say bash ${CLAUDE_PLUGIN_ROOT}/..., which that check doesn't match, so the test correctly stays green against the reverted tree without any edit.
  • No functional regression: the read-only scripts are still reachable via context/*.md, they just no longer match the narrowed allowed-tools grant when invoked through that path, so Claude falls back to a permission prompt instead of auto-running — a safe (if slightly more friction) fallback, exactly as the CHANGELOG states. Nothing that was working before this PR is broken by the revert.
  • The plugin.json version stays at 0.10.0 (unreleased within this same PR), so amending that version's CHANGELOG.md entry in place rather than adding a new one is correct — no changelog-parity concern.

Minor, non-blocking note: the PR description above (the "Lane judgment calls" section, item 3) still describes the context/*.md conversion as done ("were converted too, not just SKILL.md"), which is now stale relative to this commit. Worth a quick edit to the PR body for anyone reading it fresh, but it's description text, not code, and doesn't affect the merge.

No issues found in this commit. Nothing to flag; all prior findings from earlier passes on this PR (executable bits, the clean grant allowlist) remain resolved and are unaffected by this change.
· branch fix/2221-allowed-tools-grant-pairing

kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…uence resume before discard, and un-invert the research hub (#2311)

## Summary

Six filed issues against `discovery`, one plugin, one release: **0.14.0
→ 0.15.0**. Every one is the same shape — an obligation or an
explanation carried in several files that drifted apart, or a harness
behavior asserted without a source.

**#2270 — the `$ARGUMENTS`-on-preload claim, retired rather than
inverted.** Verified at HEAD before touching it: the empty-string
rendering was asserted at the 5 core sites the issue names, weakly at
`explore/SKILL.md:161`, **and at four more the issue did not count** —
`skills/explore/evals/evals.json:69,73` and
`skills/research/evals/evals.json:124,128`, where it had become a
*grading criterion*. 12 grep hits in total. Re-checked against raw
markdown (not a summarized fetch) on 2026-08-11: the skills page scopes
the placeholder to "All arguments passed **when invoking** the skill"
and says preload "work[s] differently: the full skill content is
injected at startup"; the sub-agents page says only "The full content of
each listed skill is injected into the subagent's context at startup".
**Neither covers argument substitution on that path in either
direction.** So this ships the rule that holds whichever way the harness
renders it — *do not rely on seeing an unfilled slot; a topic that did
not arrive in the dispatch prompt is a parent-envelope failure the agent
reports rather than repairs* — and records the doc status once. Recorded
as **unsupported, not false**; the opposite is not asserted either. The
`${CLAUDE_…}`-caller caveat #2222 separated from this is kept separate.

Same issue's `F6`: three files stated three different write boundaries.
Reconciled to one statement in `reference/topic-docs.md` with a
`scratch-` naming prefix and a cleanup owner. The researcher's
**session** scratch dir is kept as a distinct, harness-owned place
rather than merged — they were never the same location.
`reference/artifact-protocol.md` is byte-identical across four plugins
and is not touched.

**#2272 — a contradiction, resolved by sequencing.** 5 sites, not 3: the
three discard statements plus `explore/reference/dispatch.md:133`, which
back-referenced the discard rule to justify its own. **Resume first;
decide about the slice from what the resume returns**, sourced against
the sub-agents page ("Resumed subagents retain their full conversation
history … picks up exactly where it stopped"). The discard is not
removed — it is sequenced, and stays mandatory once the resume is
refused. `truncated` still means the turn-budget stop, so #2203's
`persistence:` axis is not reopened.

**#2268 — envelope delivery.** `Memory root` becomes a row in the
parent-obligation table and the envelope becomes one labelled template,
reproduced from `research-deep`'s existing literal block so the two
cannot drift. Memory root is the one *degradable* field (derive + flag,
not halt). Per the issue's constraint, **write capability is not
asserted as a flag**: it is not probeable pre-dispatch, the parent's own
`mkdir`/`touch` proves only the parent, and an agent-side probe would
corrupt the freshness baseline — the question is routed to `persistence:
by-value`, which already answers it.

**#2269 — portability.** The baseline command now has one home carrying
POSIX **and** PowerShell forms; the two monorepo pointers become
`${CLAUDE_PLUGIN_ROOT}` form.

**#2267 — the gate's grants and step 1.** `allowed-tools` pairing **does
not apply here**, on three sourced legs from the skills page (raw
markdown, 2026-08-11): `${CLAUDE_PLUGIN_ROOT}` is not substituted in
`allowed-tools` rules (inert grant); `${CLAUDE_SKILL_DIR}` is "the
skill's subdirectory within the plugin, **not the plugin root**", where
these scripts live because one gate serves both families; and the grant
"clears when you send your next message" while the parent runs the gate
a turn later. `bash` is not in the docs' stripped-wrapper list, so a
covering rule would be interpreter-led — this repo's
`permission-rule-hygiene` anti-pattern 1. Following #2225's precedent
means shipping the **rationale plus a test**, not an inert grant: both
skills now state *a gate that could not run is a FAIL, never a skip*,
and the operator-setup path the docs prescribe is recorded. `explorer`
`maxTurns` 30 → 40 **on parity grounds only** — explicitly not offered
as the cause of any past bare-prose return, since `evidence-2.md`
supersedes that reading. "Budget a turn for the payload" is replaced by
emitting the payload early, because an agent cannot observe its own
remaining budget.

**#2271 — the inversion, re-measured at HEAD and reduced.**
`research/SKILL.md` was **6,629 words vs `context/discipline.md`'s
5,087** (confirmed unchanged since `9b34a82a`). This PR **shrinks** it:
**6,629 → 5,026 words, 242 → 232 lines**, now 210 words *below* the
spoke. The two densest lines moved rather than compressed — the
fetch-log spec to `context/artifact-shape.md`, criterion 9's elaboration
into `context/discipline.md`'s existing artifact-ladder section. The
description gains its missing `research-deep` boundary clause.

### New surface

`reference/parent-contract.md` — the parent's **cross-family** contract.
The plugin had two family-specific parent-side spokes and no home for
what is identical across both; that absence is why five statements
existed in 2–6 copies each. Each existing spoke keeps its
family-specific half and points here.

### Not fixed, deliberately

**#2267 `B-F8`** (three consecutive releases fixed assertions that could
not fail) is left unchecked. It is a process observation about past
releases, not a defect at HEAD, and inventing a mechanism for it would
be the silently-checked row this batch is named for. The repo already
owns this class in `scripts/check-discriminating-test-skips.sh`.

It did, however, catch this PR's own test. A first revision asserted
that each agent *"points at"* the write boundary — which passed at the
merge-base too, because both agents already linked `topic-docs.md` for
an unrelated reason. Two vacuous `ok`s, inside a test written to pin
non-vacuity. Those assertions now key on the restatements being
**gone**.

### Two corrections made to this PR's own claims

Recorded because the rest of the batch is about exactly this.

1. **A false mechanism in the first CHANGELOG draft.** It said criterion
9's elaboration was "folded into `context/discipline.md`'s existing
artifact-ladder section". `git diff --numstat` says `discipline.md`
gained **2 lines** — the Tier-3 exception, nothing else. What actually
happened is that criterion 9's cell was **compressed to a pointer at
text `discipline.md` already carried** ("A probe locates a rung; it does
not grade one", the exhaustive-surface rule, the `unresolved` default,
all in "Primary-source-first protocol" before this PR): the hub was
restating a spoke rather than owning anything. Three distinct operations
— one move to `artifact-shape.md` (+644 words), one move to
`discipline.md` (+149), one compress-to-existing — are now stated as
three.
2. **A count that was not executed.** "12 grep hits" was arithmetic, not
output. The union over all five patterns at `a0abaf81` is **10 lines
across 7 files**, and one of those patterns (`reaches a preloaded
skill`, in both `evals.json` grading criteria) had no assertion pinning
it. It has one now.

Two pointers created in the trimming pass were also grep-verified
against their targets rather than assumed: `discipline.md`'s "Recency
gate" does carry the stable-project carve-out and the 30/14/90 windows,
and "Corpus enumeration" does carry both the exhaustive-surface table
and the criterion-drift reasoning.

### Explore/research parity

The trimming pass initially hit `research/SKILL.md` only, which left
three verbatim-shared gate sentences compressed on one side and not the
other. This plugin's 0.12.0 rationale treats that parity as a value
(#2268 cites it), so the same compressions were applied to
`explore/SKILL.md`: **4,154 → 3,977 words.** `explore/SKILL.md` is
*also* hub-inverted against its own 2,007-word `reference/dispatch.md`;
that is unfiled and out of this PR's scope, and is reported rather than
fixed.

## Test plan

New: `plugins/discovery/scripts/contract.test.sh` — 25 assertions,
discovered automatically by `scripts/run-plugin-tests.sh`, which globs
`plugins/**/*.test.sh`.

**Fail-before — the shipped test run against a detached worktree at the
merge-base `a0abaf81`. 23 of 25 fail:**

```console
$ bash plugins/discovery/scripts/contract.test.sh
FAIL - no file asserts $ARGUMENTS substitutes to the empty string — 3 hit(s)
FAIL - no file asserts $ARGUMENTS reaches a preloaded body as the empty string — 2 hit(s)
FAIL - no evals entry grades against "empty under preload" — 2 hit(s)
FAIL - no file asserts $ARGUMENTS is empty under dispatch — 1 hit(s)
FAIL - no evals criterion asserts what does or does not reach a preloaded skill — 4 hit(s)
FAIL - no monorepo-path pointer to the agent definitions — 2 hit(s)
FAIL - the pre-dispatch baseline command has exactly one home — found in: <6 files>
FAIL - the baseline home states a PowerShell form — no match for /New-Item/ in reference/parent-contract.md
FAIL - no file prescribes discard-instead-of-resume — 3 hit(s)
FAIL - no file back-references a discard-rather-than-resume rule — 1 hit(s)
FAIL - the ordering is stated once, in the parent contract — no match for /Resume first/ in reference/parent-contract.md
FAIL - the research parent-obligation table carries a Memory root row — no match for /^\| Memory root \|/ in skills/research/context/dispatch.md
FAIL - the parent contract ships a literal envelope template — no match for /Memory root:/ in reference/parent-contract.md
ok   - no Bash permission rule is written with the non-substituting ${CLAUDE_PLUGIN_ROOT}
ok   - neither skill declares allowed-tools (the un-run case is stated instead)
FAIL - the un-run case is stated — no match for /could not run/ in reference/parent-contract.md
FAIL - explorer maxTurns (30) >= researcher maxTurns (40)
FAIL - research/SKILL.md (6629 words) is NOT smaller than context/discipline.md (5087 words)
FAIL - the research description carries a boundary against research-deep — no match for /^description:.*research-deep/ in skills/research/SKILL.md
FAIL - the write boundary names a scratch prefix — no match for /scratch-/ in reference/topic-docs.md
FAIL - the write boundary assigns a cleanup owner — no match for /[Cc]leanup/ in reference/topic-docs.md
FAIL - no agent restates the write boundary as a closed two-destination list — 1 hit(s)
FAIL - no agent restates the write boundary as a single destination — 1 hit(s)
FAIL - agents/explorer.md defers to the single write boundary — no match for /single write boundary/ in agents/explorer.md
FAIL - agents/researcher.md defers to the single write boundary — no match for /single write boundary/ in agents/researcher.md
23 contract assertion(s) failed.
```

`<6 files>` is elided for width; the real output names
`skills/explore/evals/evals.json`,
`skills/explore/reference/dispatch.md`, `skills/explore/SKILL.md`,
`skills/research/evals/evals.json`, `skills/research/SKILL.md`,
`skills/research-deep/SKILL.md`.

The two `ok`s at the merge-base are the deliberate **no-grant guards** —
they pin a decision (this plugin ships no `allowed-tools` rule, and no
rule is written with the non-substituting `${CLAUDE_PLUGIN_ROOT}`)
rather than a fix, so they correctly hold on both sides.

**Pass-after, at the tip — all 25:**

```console
$ bash plugins/discovery/scripts/contract.test.sh
ok   - no file asserts $ARGUMENTS substitutes to the empty string
ok   - no file asserts $ARGUMENTS reaches a preloaded body as the empty string
ok   - no evals entry grades against "empty under preload"
ok   - no file asserts $ARGUMENTS is empty under dispatch
ok   - no evals criterion asserts what does or does not reach a preloaded skill
ok   - no monorepo-path pointer to the agent definitions
ok   - the pre-dispatch baseline command has exactly one home
ok   - the baseline home states a PowerShell form
ok   - no file prescribes discard-instead-of-resume
ok   - no file back-references a discard-rather-than-resume rule
ok   - the ordering is stated once, in the parent contract
ok   - the research parent-obligation table carries a Memory root row
ok   - the parent contract ships a literal envelope template
ok   - no Bash permission rule is written with the non-substituting ${CLAUDE_PLUGIN_ROOT}
ok   - neither skill declares allowed-tools (the un-run case is stated instead)
ok   - the un-run case is stated
ok   - explorer maxTurns (40) >= researcher maxTurns (40)
ok   - research/SKILL.md (5026 words) is smaller than context/discipline.md (5236 words)
ok   - the research description carries a boundary against research-deep
ok   - the write boundary names a scratch prefix
ok   - the write boundary assigns a cleanup owner
ok   - no agent restates the write boundary as a closed two-destination list
ok   - no agent restates the write boundary as a single destination
ok   - agents/explorer.md defers to the single write boundary
ok   - agents/researcher.md defers to the single write boundary

All contract assertions passed.
EXIT=0
```

**No regression in the two existing suites:**

```console
$ bash plugins/discovery/scripts/check-dispatch-artifact.test.sh   # exit 0
all tests passed
$ bash plugins/discovery/scripts/check-coverage-complete.test.sh   # exit 0
All checks passed.
```

**Repo gates run locally:**

```console
$ bash scripts/check-changelog-parity.sh --check
Every versioned plugin has a CHANGELOG.md (or a stale-guarded baseline entry), and none documents a version above its manifest.
$ bash scripts/check-changelog-parity.sh --check-order
All 75 changelog(s) read newest-first with no duplicate versions.
$ bash scripts/check-changelog-parity.sh --check-bump origin/main
Every plugin whose version changed vs origin/main has a '## [<version>]' CHANGELOG.md entry.

$ npx markdownlint-cli2 --config .markdownlint-cli2.jsonc "plugins/discovery/**/*.md"
Linting: 19 files
Summary: 0 issues in 0 files

$ bash plugins/skill-quality/scripts/check-listing-budget.sh plugins/discovery/skills
CHECK-LISTING-BUDGET: OK — aggregate 2739/8000 chars within budget.
```

Both `evals.json` files re-parsed with `json.load` after the wording
sweep — valid.

## Related

Closes #2267
Closes #2268
Closes #2269
Closes #2270
Closes #2271
Closes #2272

Inbox items: `20260810-225904-discovery-dispatch-persistence-contract`,
`2026-08-10-plugin-quality-audit-four-components` (023241Z),
`20260811-021645-plugin-audit-four-components-and-guard-deadlock-ownership`.

Ledgers: `.work/handoff-inbox-batch-4/ledgers/I1-discovery-dispatch.md`,
`ledgers/I7-four-components-023241Z.md`,
`ledgers/I9-021645-four-components.md`.

Adjacent, not reopened: #2203 / #2222 (the `persistence:` axis), #2225
(the paired body+rule precedent this follows), #2237 / #1824
(`${CLAUDE_SKILL_DIR}` substitution scope), #1414 (no Windows runner,
which is why #2269's class survives green checks).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(plugins): paired body+rule rewrite for seven interpreter-led allowed-tools grants

1 participant