Skip to content

fix(claude-config): audit-permission-grants reports a clean bill with no denominator, and four amplifiers around it #2283

Description

@kyle-sexton

audit-permission-grants reports a clean bill without ever reporting a denominator. "No fragile
permission grants found." prints identically whether it parsed forty allowed-tools blocks and found
them healthy or parsed none at all — and the skill's own body tells the operator to take that string at
face value. Around that one string sit four amplifiers: the only loadability filter is vendor/, so
non-loadable copies are scanned and loadable surfaces outside the root are not; the audited repo can
declare its own exemptions with no disclosure requirement, which makes a suppressed report
indistinguishable from a clean one; the four advertised scope filters are not implemented, so scope: settings still pays the full frontmatter find cost; and the only lever that scopes the scan is still
named as a test fixture.

#2249 closed the worst case — an unresolvable root now exits 2 instead of sweeping the user profile —
but a resolved root that contains nothing still prints the clean-bill string.

Rows

  • A5scripts/permission-rule-check.sh:303-305 and SKILL.md:81 — "clean" and "scanned nothing" are the same string; nothing counts files visited, allowed-tools blocks parsed, or allow rules read.
  • A8scripts/permission-rule-check.sh:258-262 — the only exclusion is ! -path '*/vendor/*'; the rationale that justifies it generalizes to node_modules/, worktrees, marketplace mirrors, and versioned cache copies, and is applied exactly once.
  • A15SKILL.md:85-89 — a consuming repo may declare additional fragile tokens or a documented exemption, with no disclosure requirement, no narrowing-only constraint, and no separate reporting slot.
  • A16SKILL.md:3, :35-40, :63-64 vs scripts/permission-rule-check.sh:55-69 — the four scope filters are advertised in the argument hint and the body, and the detector's entire argument surface is --count / --help.
  • A11scripts/permission-rule-check.sh:71-72, :85-86PERMISSION_HYGIENE_FIXTURE_DIR is the first branch of root resolution and therefore the only override; fix(claude-config): permission-rule-check falls through to $PWD and sweeps the user profile, exiting 0 #2249 documented it in SKILL.md:59 and --help, but its name still tells an operator it is for tests.

Evidence

A5 — no denominator

scripts/permission-rule-check.sh:303-305 at HEAD, unchanged:

if [[ "${#findings[@]}" -eq 0 ]]; then
  echo "No fragile permission grants found."
else

Nothing in the script counts files visited, allowed-tools blocks parsed, or allow rules read.
SKILL.md:81 compounds it: "A clean scan ("No fragile permission grants found.") is a valid outcome —
report it as such."

(The originating observation — that the audited surface had zero allow rules and zero local
allowed-tools, so the operator received a clean bill from a scan of nothing — is an environment fact
the validation pass did not re-measure. The code defect above is at HEAD.)

This is also the mechanism A15 exploits, and the reason A4/#2249's refusal branch had to be extended to
--count: a count of 0 from a scan that never resolved a root reads exactly like a clean bill. The same
argument applies one level up — a count of 0 from a root with nothing in it reads the same as a count of
0 from a healthy root.

A8 — one exclusion, a rationale that generalizes

scripts/permission-rule-check.sh:258-262 — the frontmatter walk:

  find "$ROOT" -type f \( \
    ...
    \) ! -path '*/vendor/*' 2>/dev/null | sort -u

! -path '*/vendor/*' is the only exclusion. No exclusion for node_modules/, worktrees, marketplace
mirrors, or versioned plugin-cache copies, and no consultation of installed_plugins.json. The stated
rationale that would extend to all of them is at reference/criteria.md:18-20 — vendored copies are
"not loadable skills/agents/commands, so their allowed-tools never take effect and a finding on them
would be a false positive."

Scope this honestly. The originating measurement (448 raw findings → 109 after filtering to live
installs) was taken against the plugin cache, not this repository, and the validation pass did not
re-scan that tree. Inside this repo the noise sources it measured largely do not exist, so this row is
LOW at HEAD; it bites when the skill is pointed at a home directory — i.e. it is really an amplifier of
the fail-open scan #2249 just closed.

A15 — the audited repo can narrow its own audit

SKILL.md:85-89 at HEAD:

A consuming repo may declare, in its own `CLAUDE.md` / `.claude/rules/`, additional interpreter tokens
or path shapes it treats as fragile, or a documented exemption (e.g. a deliberately broad grant behind
a PreToolUse hook). Read those when present; this skill does not assume them.

No disclosure requirement, no narrowing-only constraint, no separate reporting slot. Combined with A5's
identical clean/empty string, a suppressed report is indistinguishable from a clean one. The escalation
is real because the audited repo authors the exemptions, so the input is attacker-controllable in the
threat model the docs themselves name — https://code.claude.com/docs/en/skills, fetched 2026-08-11:
"Review project skills before trusting a repository, since a skill can grant itself broad tool access."

Graded MED rather than higher because the surface is advisory report text, not an enforcement boundary.
The minimum fix is a disclosure slot: every exemption applied gets named in the report, and an exemption
may widen the finding set but never silence one without saying so.

A16 — advertised, not implemented

Advertised at SKILL.md:3:

argument-hint: "[scope] — scope: frontmatter|settings|plugins|all (default: all)"

and enumerated at SKILL.md:35-40. The implementation, SKILL.md:63-64: "If a scope filter was given,
run the full detector and present only the matching checks (P1/P2 map to …)". The detector's whole
argument surface is --count / --help (scripts/permission-rule-check.sh:55-69);
grep -n 'scope\|--frontmatter\|--settings\|--plugins' finds no flag. So scope: settings still pays
the full frontmatter find cost, which is the cost that made the unbounded-root case expensive.

A11 — the one scoping lever is still named for tests

grep -rn 'PERMISSION_HYGIENE_FIXTURE_DIR' plugins/claude-config/ at HEAD returns hits in the script
header, the root-resolution branch at scripts/permission-rule-check.sh:71-72, the --help text, the
test harness — and, new since #2259, SKILL.md:59 and the --help body, both telling the operator to
"set $PERMISSION_HYGIENE_FIXTURE_DIR explicitly" when no root resolves.

So the undocumented half of this row is fixed and the misnamed half is not. The variable is now
the documented operator-facing remedy for an exit-2 refusal, while its name still says it is a test
fixture — which is exactly the friction the row was filed for. reference/criteria.md still does not
mention it. Either rename it with a back-compatible alias, or state in criteria.md that using it in
production is sanctioned.

Adjacent, deliberately not duplicated

Provenance

Severity: MED (cluster highest; A8, A16 are LOW) · Provenance: AUDITOR_VERIFIED. Two legs are explicitly not re-measured by the validation pass and are marked inline: A8's 448→109 filtering figure (taken against the plugin cache, not this repo) and A5's supporting environment observation.
Origin: handoff-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 § A5, A8, A11, A15, A16
Verified against repo HEAD 9b34a82a. Line anchors re-derived at that commit — #2259 moved the detector's root resolution and added six lines to SKILL.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions