Skip to content

feat(claude-config): make audit Category G executable and give the skill a real hook inventory - #2301

Merged
kyle-sexton merged 3 commits into
mainfrom
feat/2274-audit-category-g-hook-inventory
Aug 12, 2026
Merged

feat(claude-config): make audit Category G executable and give the skill a real hook inventory#2301
kyle-sexton merged 3 commits into
mainfrom
feat/2274-audit-category-g-hook-inventory

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Two clusters in claude-config:audit, both of the same shape: the skill claiming coverage it did not have.

Category G was unexecutable (#2274). It is the one Phase 2 category a consumer could not run.

  • Its only overflow detector was /doctor, which needs an interactive TTY, so the category yielded nothing in the harness's own headless mode. It now names the documented headless route — "When the listing exceeds its budget, Claude Code also writes a warning to the debug log, visible with --debug" — and reports which route it took. /context's Skills row is labelled as what it is: a second interactive reading, not a headless substitute.
  • It stated no budget constant, so "overflowed, and by how much?" was unanswerable by hand. It now carries skillListingBudgetFraction default 0.01, SLASH_COMMAND_TOOL_CHAR_BUDGET's documented 8,000-character fallback, skillListingMaxDescChars default 1536, and the 200,000 × 4 × 0.01 = 8,000 arithmetic reconciling the first two.
  • Its "cheapest first" lever list put skillOverrides second while carrying the caveat that it does not reach plugin skills, and never named the substitute upstream prescribes — so on a plugin-heavy roster the ordered list degenerated to the entry it labels "last resort". Levers are now split by roster origin, /plugin is named, and a roster-composition count is required before any lever is recommended. No per-skill name-only state is promised for /plugin; no page documents one.
  • It was the only letter with no checklist table (headings ran A–F, H, I). It has one now, and the sentence at audit-checklist.md:5-7 that said it had none is gone rather than left contradicting the same commit.

Corrected remedy for the table row. The originating item asked for a new check-skill-listing-budget.sh. That measurement already exists as plugins/skill-quality/scripts/check-listing-budget.sh (#1404), so the fix is wiring — and the wiring says out loud what the item did not: the two tools measure different populations. That script walks a repository's skills roots; Category G asks about the listing a consumer's session assembled. It is pointed at as an explicit in-repo proxy, never as the consumer's number. Its measured cost is stated too (below), because a marketplace-wide invocation cannot finish inside a default Bash tool timeout (#2216).

audit asserted hook coverage it could not enumerate (#2275). Category D writes rules for ${CLAUDE_PLUGIN_ROOT} / ${CLAUDE_PLUGIN_DATA} placeholders that appear only in a plugin-provided hook, and Category B's third baseline narrowing turns on whether such a hook is live — while three of the skill's own surfaces disclosed in prose that the enumeration was impossible (context/procedures.md, reference/required-permissions.md twice). Gap disclosed in three places, closed in none.

New scripts/check-hook-coverage.sh (+ .test.sh) closes it. It resolves each enabled plugin through the installed-plugin registry — every record carries a version-pinned installPath, so no version-directory ordering is inferred — and reads its hook config in all four documented shapes (hooks/hooks.json; a hooks path, array of paths, or inline object in plugin.json; plugins-reference: "Location: hooks/hooks.json in plugin root, or inline in plugin.json", key typed string|array|object). It also reports disableAllHooks / allowManagedHooksOnly / strictPluginOnlyCustomization, because a hook a setting has switched off is not coverage.

The exit code is the contract: 0 complete, 1 partial with the unenumerated sources named, 2 no inventory. The fail-open posture is kept and narrowed — it applies to what a partial run could not read, not to every run by default. This also removes the ordering problem the narrowing-3 precondition used to carry: Phase 1.0 runs before Category B, so the lever reading is available even on a scope-filtered /audit permissions. All three "no enumeration path exists" surfaces were rewritten in the same commit, and the eval that asserted "having no way to enumerate a plugin's hooks/hooks.json" was rewritten with them.

A live defect found while writing it, with the mechanism stated correctly. On Git for Windows jq writes stdout in text mode and appends a CR to every line — a property of jq's own output stream, not of the input file's line endings. Untreated, a plugin key read out of jq is name@marketplace\r, every registry lookup misses, and the plugin is reported as not installed on a machine where it is installed. My first comment blamed CRLF input files; the red run refuted that (LF-only fixtures failed too) and the comment was corrected to match. Pinned by a regression case.

Also in this PR:

  • destructive-bash-deny ships with its fragility. Eight argument-constraining globs, rated error when absent, with no caveat — in a file that quotes the permissions page's "Bash permission patterns that try to constrain command arguments are fragile" one section earlier, against a different table. The concrete hole is now stated: matching is prefix-anchored, so Bash(git push --force *) does not match git push origin main --force. The patterns stay — they raise the cost of an accidental force push — but a finding no longer implies they bound a determined one. Deliberately not claimed: that upstream ranks the hook above the glob for destructive commands. That page's remedy recommendation is scoped to URL filtering; only the fragility half is doc-supported, and the PR says so.
  • The "speed bump, not a boundary" hook ranking is scoped, not deleted. Correct for secret exfiltration, where an OS boundary exists. Stated unscoped, it also governed destructive git — where the sandbox's vocabulary is filesystem.* paths and network.* hosts, with no expression for a command's arguments, so it cannot separate git push from git push --force to the same remote.
  • The read-it-verbatim guard covers all of Phase 3, not the single Category F row fix(docs): establish a verbatim fetch route so a truncated env-vars read stops reading as drift #2185 landed it on — and settings, fetched in Phase 3.1, is the page a summarizing fetch already reported three present keys as absent on.

Every doc quote above was re-fetched as raw markdown (curl …/docs/en/<page>.md) on 2026-08-12 and grepped, not recalled.

Test plan

New suite, red-then-green. Red proof: deleting the CR strip from jqs():

$ bash plugins/claude-config/skills/audit/scripts/check-hook-coverage.test.sh
FAIL: case 1: complete inventory exits 0
FAIL: case 1: plugin hook enumerated
FAIL: case 4: CRLF settings still complete
FAIL: case 4: CRLF plugin resolved
FAIL: case 5: inline hook enumerated
FAIL: case 6: custom-path hook enumerated
FAIL: case 9: inventory verdict present
17/34 checks failed.

Green, with the fix restored:

$ bash plugins/claude-config/skills/audit/scripts/check-hook-coverage.test.sh
PASS: case 1: plugin hook enumerated
PASS: case 3: partial inventory exits 1
PASS: case 3: never claims completeness
PASS: case 4: CRLF plugin resolved
PASS: case 5: inline hook enumerated
PASS: case 6: custom-path hook enumerated
PASS: case 7: missing declared path is partial
PASS: case 8: lever reported
PASS: case 9: --json output is valid JSON
PASS: case 10: exit 2 with no readable scope
PASS: case 11: exit 2 when jq missing

All 34 checks passed.

Against the real machine (73 enabled plugins), the inventory the skill previously could not take:

$ bash plugins/claude-config/skills/audit/scripts/check-hook-coverage.sh
Hooks (44):
  SOURCE                       EVENT        MATCHER         COMMAND
  settings:user                PreToolUse   Bash|PowerShell bash ~/.claude/hooks/block-destructive-removal.sh
  plugin:guardrails@melodic-software PreToolUse Bash|PowerShell "${CLAUDE_PLUGIN_ROOT}"/hooks/block-dangerous-git.sh
  …
INVENTORY: complete — every enabled plugin resolved and every hook source parsed.

The Category G proxy-cost figure quoted in the checklist, measured here:

$ time bash plugins/skill-quality/scripts/check-listing-budget.sh plugins/claude-config/skills
Shared listing-budget estimate over 8 listing-eligible skill(s) across 1 root(s):
  aggregate: 5892 chars
  budget:    8000 chars (documented default (SLASH_COMMAND_TOOL_CHAR_BUDGET fallback))
CHECK-LISTING-BUDGET: OK — aggregate 5892/8000 chars within budget.

real    0m5.983s

Repo gates:

$ shellcheck --rcfile=.shellcheckrc -x …/check-hook-coverage.sh …/check-hook-coverage.test.sh
SHELLCHECK CLEAN

$ bash scripts/check-shell-portability.sh --paths …/check-hook-coverage.sh …/check-hook-coverage.test.sh
No unexcused GNU-only constructs in 2 shell file(s).

$ CHECK_SKILL_SKILLS_ROOT=plugins/claude-config/skills bash plugins/skill-quality/scripts/check-skill.sh audit
INFO: markdownlint clean
INFO: script test passed: scripts/check-hook-coverage.test.sh
INFO: script test passed: scripts/check-plugin-drift.test.sh
INFO: script test passed: scripts/check-structure.test.sh
INFO: script test passed: scripts/fix-plugin-drift.test.sh
CHECK-SKILL audit: PASS — 0 errors, 2 warning(s)

$ bash plugins/skill-quality/scripts/check-evals-quality.sh plugins/claude-config/skills/audit/evals/evals.json
check-evals-quality: PASS (0 warning(s) across 1 file(s))

$ npx markdownlint-cli2 plugins/claude-config/CHANGELOG.md "plugins/claude-config/skills/audit/**/*.md"
Summary: 0 issues in 0 files

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

The two check-skill.sh warnings (SKILL.md over the 200-line soft target; no Gotchas surface) are pre-existing on this skill, not introduced here.

Security review note

check-hook-coverage.sh is a new read-only surface. It widens what the skill reads — the user-scope settings.json, the installed-plugin registry, and each enabled plugin's hook config — and adds no write, no network call, and no execution. It never runs a hook command; hook commands are printed as strings only. It grants nothing: no permission rule, no allowed-tools entry, no new hook. The one judgment it deliberately does not make is whether an enumerated hook covers a permission family; that stays with Category B against the three preconditions in required-permissions.md, so the new data cannot by itself downgrade a security finding.

plugin.json bumped 0.33.0 → 0.34.0 (new surface + behavior change) with a matching CHANGELOG entry.

Related

Closes #2274
Closes #2275

Inbox items: 20260810-225906-claude-config-category-g-unmeasurable (#2274), 20260810-225905-claude-config-audit-category-b-hook-blindness (#2275).
Ledgers: .work/handoff-inbox-batch-4/ledgers/I3-claude-config-category-g.md § B1–B5 · .../I2-claude-config-category-b.md § A4, A2, MN1.

Adjacent, not duplicated: #1404 (closed) shipped the measurement engine Category G now points at; #2216 owns its Windows runtime; #2023 / #2034 / #1271 own the marketplace-wide budget program, and no repo-wide aggregate is quoted here on purpose. #1598 is A2's precedent on the Read deny table in the same file. #2185 landed MN1's partial fix (Category F only).

@cursor

cursor Bot commented Aug 12, 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 12, 2026

Copy link
Copy Markdown

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


Claude finished @kyle-sexton's task

Tasks

  • Gather context (CLAUDE.md, REVIEW.md, PR diff)
  • Review new script check-hook-coverage.sh
  • Review test suite check-hook-coverage.test.sh
  • Review doc changes (SKILL.md, procedures.md, validation-categories.md, audit-checklist.md, required-permissions.md, evals.json)
  • Post inline findings
  • Post final summary

Summary

Reviewed for correctness, error handling, test coverage, and maintainability per this repo's REVIEW.md code-review-lane scope (security findings are left to the claude-security-review lane, which exists in this repo's workflows — none surfaced here anyway; the script is read-only by design and I didn't find anything that crosses into that lane).

This repo's CLAUDE.md is present but empty in this checkout, so I reviewed against REVIEW.md's always-check items (audit logging for high-risk actions, atomicity for multi-location writes) — neither applies here: the script is read-only and writes nothing.

Posted 3 inline findings, all on check-hook-coverage.sh:

  1. 🔴 Importantresolve_install_path() picks the last installed_plugins.json record with a non-null installPath, trusting array order for a value this repo's own fleet-state.sh (plugins/claude-ops/skills/plugins/scripts/fleet-state.sh:317-354) explicitly documents as unsafe to trust — multi-entry arrays per plugin key are "common, not an edge case" (e.g. mid-session autoUpdate skew), and that script instead matches against the actual running root rather than array position. Untested here (the test fixture helper only ever writes single-entry arrays).
  2. 🟡 Suggestion — the INVENTORY: complete banner doesn't carry the managed-settings caveat that required-permissions.md and this file's own lever-collection comment both state explicitly — a reader of the script's own output alone could reasonably (and wrongly) read "complete" as "all three suppression levers are known."
  3. 🟡 Suggestion — the object/unsupported-type branches of hooks-key handling lack fixtures for a scalar/null value or a malformed object shape, one of which can silently fall through to "no hooks" rather than UNREADABLE, in tension with the header's own "never silently as 'no hooks'" claim.

Other observations (no action needed)

  • plugin.json version bump (0.33.0 → 0.34.0) and the matching CHANGELOG.md entry are consistent.
  • plugins/skill-quality/scripts/check-listing-budget.sh, which the new Category G checklist row points at as an in-repo proxy, does exist at the path cited.
  • The CR-strip fix in jqs() (tr -d '\r') is well-reasoned and the regression comment accurately describes a jq-stdout-text-mode issue distinct from input-file line endings — I didn't find a case where the strip itself introduces new problems (e.g. it can't corrupt a legitimate embedded \r in a hook command, since command strings pass through @tsv/JSON encoding before this point).
  • The doc changes (SKILL.md, procedures.md, validation-categories.md, audit-checklist.md, required-permissions.md) are internally consistent with each other and with the new script's actual exit-code contract (0/1/2) — I didn't find any doc/implementation mismatches beyond finding fix: correct plugin-system inaccuracies and harden references #2 above.

Not independently verified: I was unable to execute check-hook-coverage.test.sh, shellcheck, or the other commands in the PR's Test Plan in this sandbox (only read-only git/gh/ls operations were permitted here) — the test-run output and gate results quoted in the PR description are author-claimed, not something I confirmed myself. My findings above come from static reading of the script and cross-referencing this repo's own fleet-state.sh precedent.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Security Review Complete

  • Gather context (PR diff, changed files)
  • Review new check-hook-coverage.sh for injection / unsafe handling
  • Review check-hook-coverage.test.sh for unsafe patterns
  • Review other changed files (SKILL.md, procedures.md, validation-categories.md, audit-checklist.md, required-permissions.md)
  • Post final findings

Scope reviewed: the 10 files changed in this PR — the new scripts/check-hook-coverage.sh (+ .test.sh), and documentation-only edits to SKILL.md, procedures.md, validation-categories.md, audit-checklist.md, required-permissions.md, evals.json, CHANGELOG.md, plugin.json. No .github/workflows/* files are touched by this PR, so the pull_request_target/workflow_run/permissions-widening/unpinned-action classes are not in scope here. Standard zizmor-covered classes (supply-chain pins, dangerous triggers, excessive permissions, template injection) are deferred to that lane per instructions.

check-hook-coverage.sh is the only executable surface added. It is genuinely read-only as claimed: no eval, no source, no network calls, no execution of any discovered hook command — commands are only ever extracted with jq and printed as strings. JSON emitted via --json is built correctly with jq -cn --arg, which handles escaping properly (no manual string concatenation into JSON). The CRLF-strip fix (jqs(), line 59) is a correctness fix, not a security one. I did not find command injection, path traversal into a meaningfully different privilege domain, or credential/secret mishandling in this script or its test harness.

One SUGGESTION worth flagging, not a vulnerability in the traditional sense:

  • file: plugins/claude-config/skills/audit/scripts/check-hook-coverage.sh
  • lines: 162-174 (extraction of .command/.url from hook config) and 398-404 (table output)
  • severity: SUGGESTION — confidence: LOW-MEDIUM
  • what: This script widens what the audit ingests from "settings-declared hooks only" to "every enabled plugin's own hook config," walking each plugin's install directory and extracting its hook command/url strings verbatim into the script's output (table or JSON), with no sanitization. That output is exactly the evidence the audit skill (an LLM-driven session with its own tool access) reads and reasons over in Phase 1.0. A plugin installed from a third-party marketplace is a different trust domain than a repo's own settings.json — its author is not the user — so this is a genuine widening of the pool of attacker-influenceable text fed into the auditing agent's context, i.e. a broader indirect-prompt-injection surface than existed before this PR (previously only self-authored settings.json hook strings were ingested this way).
  • why it's not higher severity: This is inherent to any tool that hands an LLM agent third-party-controlled text to reason about, is arguably the point of an auditing tool (it has to look at what plugins declare), and the PR is explicit that the script only enumerates and never executes. It's not a new class of exploit so much as a scope increase on an existing, general risk category for agentic tooling.
  • suggestion: No code change required to ship this PR safely, but consider a note in SKILL.md/procedures.md (Phase 1.0) reminding the audit skill/operator that hook command/url strings originate from third-party plugin authors and should be treated as data, not instructions, when reasoning about the inventory — the same caution the codebase presumably already applies to other externally-sourced content read during an audit.

No other findings. The remaining changed files are documentation/checklist prose only (budget constants, lever ordering, fragility caveats for destructive-bash-deny, hook-ranking scoping) with no code, no credentials, and no executable content.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 348949333a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-config/skills/audit/scripts/check-hook-coverage.sh Outdated
Comment thread plugins/claude-config/skills/audit/scripts/check-hook-coverage.sh Outdated
Comment thread plugins/claude-config/skills/audit/scripts/check-hook-coverage.sh
Comment thread plugins/claude-config/skills/audit/scripts/check-hook-coverage.sh
Comment thread plugins/claude-config/skills/audit/scripts/check-hook-coverage.sh
Comment thread plugins/claude-config/skills/audit/scripts/check-hook-coverage.sh Outdated
Comment thread plugins/claude-config/skills/audit/scripts/check-hook-coverage.sh
@github-actions

Copy link
Copy Markdown

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

kyle-sexton and others added 2 commits August 12, 2026 03:09
…ill a real hook inventory

Category G was the one Phase 2 category a consumer could not run. Its only
overflow detector was `/doctor`, which needs an interactive TTY, so the whole
category yielded nothing in the harness's own headless mode while the documented
`--debug` route went unmentioned. It stated no budget constant, so "overflowed,
and by how much?" was unanswerable by hand. Its ordered "cheapest first" lever
list put `skillOverrides` second while carrying the caveat that it does not
reach plugin skills, and never named `/plugin`, the substitute upstream
prescribes. And it was the only letter with no checklist table.

Separately, `audit` asserted hook coverage it had no way to enumerate. Category
D writes rules for plugin-root placeholders that appear only in a
plugin-provided hook, and Category B's narrowing 3 turns on whether such a hook
is live — while three of the skill's own surfaces said in prose that the
enumeration was impossible. New `scripts/check-hook-coverage.sh` takes that
inventory: enabled plugins resolved through the installed-plugin registry, all
four documented hook-config shapes read, suppression levers reported, and an
exit code that separates "complete" from "partial" from "no inventory". The
fail-open posture survives, narrowed to what a partial run could not read.

Writing it surfaced a live defect worth naming: on Git for Windows `jq` writes
stdout in text mode and appends a CR to every line — its own stream, not the
input file's line endings — so a plugin key read out of `jq` was `name@mkt\r`
and every registry lookup missed. Deleting the CR strip fails 17 of the suite's
34 checks, including cases whose fixtures are pure LF.

Also: the destructive-bash-deny baseline now ships with the fragility caveat the
same file already applies to the Read deny table one section earlier, plus the
prefix-anchoring hole (`Bash(git push --force *)` does not match
`git push origin main --force`); the "speed bump" hook ranking is scoped to the
threat model it was written for rather than governing destructive git too; and
the read-it-verbatim guard covers all of Phase 3 instead of one checklist row.

Closes #2274
Closes #2275

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hygiene job's exec-bit check failed: both new .sh files landed 100644
while every sibling script in the same directory is 100755.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor
cursor Bot force-pushed the feat/2274-audit-category-g-hook-inventory branch from 995c7d4 to 5a0f34e Compare August 12, 2026 03:09
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

The check is green on purpose, and it is not evidence. It certifies that a security pass ran, and this one did not complete — but the cause is outside this PR's control, so merging is deliberately left unblocked rather than locking every merge for the length of the outage. Nothing was reviewed at this head. Where this check is required, it is satisfied without that evidence; a human should review security-sensitive changes here before merging.

Re-run the job to retry the review; a new push also retries it only if the caller's pull_request triggers include synchronize (the canonical security caller keeps it). An automatic retry may already have run — it is skipped when a partial review could duplicate comments, or when the failure class needs an operator.

Re-running does NOT help for every class:

  • rate-limit that persists across re-runs, or auth — the credential or usage budget needs an operator; retrying will not clear it.
  • a run that exhausted its turn budget ("subtype":"error_max_turns" above) will exhaust it again. As the PR author, split the change into smaller PRs; raising --max-turns is a change to the caller workflow, not something you can set on this PR.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-run the job to retry the review. A new push re-triggers this lane only if the caller's pull_request triggers include synchronize (the canonical caller omits it).
An automatic retry may already have run — it is skipped when a partial review could duplicate comments, or when the failure class needs an operator (auth).

…on in hook inventory

- Merge enabledPlugins with local > project > user precedence
- Resolve installPath by scope and projectPath instead of array order
- Read managed-settings JSON when present; surface managed caveat in output
- Add regression tests for scope precedence and install-record selection

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@kyle-sexton
kyle-sexton merged commit 4f98b8d into main Aug 12, 2026
35 checks passed
@kyle-sexton
kyle-sexton deleted the feat/2274-audit-category-g-hook-inventory branch August 12, 2026 03:34
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
)

Fixes #2282 (scoped rows A2, A3, A19).

> **Scope note:** this PR takes three of #2282's five rows. **A7b** and
**A12** both reproduce at HEAD and are untouched; they are now filed as
**#2397**, so the closing keyword above no longer drops them.

## Summary

Scoped fix for three of #2282's five rows in `audit-permission-grants`.

- **A2 — direction reversed after review.** As filed, this row implies
"make the `//` exemption real". The docs settle it the other way:
`permissions.md` gives `//path` = "Absolute path from filesystem root"
with `Read(//Users/alice/secrets/**)` → `/Users/alice/secrets/**`, and
"Use `//Users/alice/file` for absolute paths." So `//Users/<name>/…` is
the canonical *spelling* of a hardcoded user home, and exempting it
would have made an `error`-tier username-leak check blind to the
documentation's own example of the leak. **`criteria.md` moved; the
detector keeps flagging `//`.** The inconsistency the row reports was
real — the two shipped files disagreed — but the document was the wrong
one.
- **A3:** P2 findings report the full `Tool(…)` rule, not an
eight-character path fragment. Two regressions this introduced are also
fixed: the tool name was enumerated as
`(Read|Edit|Write|Bash|PowerShell)` (silently dropping
`WebFetch`/`Glob`/`NotebookEdit`/`mcp__*`/`Agent` rules — `Agent`
indefensible, this script ships `scan_agent()`), and the `//` skip was a
substring test that let `Read(//opt/data/../Users/kyle/secrets)` read
clean.
- **A19:** `Bash(npm view ctx7 version*)` is no longer treated as a bare
package-manager wildcard — `*` must be preceded by a separator.

Out of scope and **now tracked in #2397**: A7b (inert-grant check) and
A12 (`~user` username leak). Both reproduce at HEAD, with evidence
carried into that issue, so closing #2282 here drops nothing.

## Test plan

A19's change verified against the shipped library at this branch,
sourced verbatim — the false positive is gone and every true positive
still matches:

```
$ . plugins/claude-config/lib/permission-patterns.sh
$ for r in 'Bash(npm view ctx7 version*)' 'Bash(npm *)' 'Bash(npm:*)' 'Bash(npx *)' \
           'Bash(pnpm dlx *)' 'Bash(npm test)' 'Bash(npm run build *)'; do
    printf '%-32s -> ' "$r"
    out=$(printf '%s\n' "$r" | grep -oE "$CCPERM_P1_ERE"); [ -n "$out" ] && echo FLAGGED || echo clean
  done
Bash(npm view ctx7 version*)     -> clean      # the false positive A19 filed
Bash(npm *)                      -> FLAGGED
Bash(npm:*)                      -> FLAGGED
Bash(npx *)                      -> FLAGGED
Bash(pnpm dlx *)                 -> FLAGGED
Bash(npm test)                   -> clean
Bash(npm run build *)            -> FLAGGED
```

A2/A3's `P2_RULE_ERE` compared against the pattern it replaces, across
tool names — this is where the two regressions below were found:

```
rule                                   | old P2 | new P2 outcome
Read(//Users/alice/secrets/**)         | /Users/a | SKIPPED (//)
Bash(/c/Users/kyle/x.sh:*)             | /Users/k | FLAGGED  (full rule now — A3 works)
Read(/Users/kyle/.aws/credentials)      | /Users/k | FLAGGED
WebFetch(/Users/kyle/x)                | /Users/k | NOT MATCHED   <-- regression
Agent(/Users/kyle/x)                   | /Users/k | NOT MATCHED   <-- regression
mcp__srv__tool(/Users/kyle/x)          | /Users/k | NOT MATCHED   <-- regression
Read(//opt/data/../Users/kyle/secrets) | /Users/k | SKIPPED (//)  <-- over-broad exemption
```

`permission-rule-check.test.sh` passes on this branch (72 + 16 new
cases); none of the seven rows above is in it, which is why it stayed
green.

## Related

- **#2282** — the owning issue, closed here. Rows **A7b** and **A12**
were split to **#2397** before merge so the auto-close drops nothing.
- **#2397** — the follow-up carrying A7b + A12 with their HEAD
reproductions, the A12-vs-`%USERPROFILE%` split, and A7b's
branching-remedy constraint.
- **#2260** — extracted the P1 rule vocabulary into
`plugins/claude-config/lib/permission-patterns.sh`, which is why A19's
fix lands in `lib/` rather than at the
`scripts/permission-rule-check.sh:138,144` anchors #2282 names. That
library now has a **second consumer** (`audit-permission-state`), so
this change is no longer scoped to one detector.
- **#2248 / #2249** (closed) — the previous `permission-rule-check`
passes this builds on. #2248 rewrote P2's *rationale* without touching
`P2_ERE`; #2249 removed the `$PWD` fallback and added the exit-2
refusal.
- **#2283** — the sibling `audit-permission-grants` issue (clean bill
with no denominator, `vendor/`-only exclusion, unimplemented scope
filters). Not touched here.
- **#2284** — the `criteria.md` staleness cluster. **Directly
relevant:** the `//` exemption this PR implements comes from
`criteria.md:64-65`, and #2284 is the issue about that file's doctrine
being stale. See the review thread.
- **#1398** (open) — faults P1's bare-name-on-PATH `Recommend`, the same
remediation surface.
- **#2301, #2335** — handoff-inbox batch 4 lane CC, the other
`claude-config` waves. #2335 is open and bumps the same manifest, so
this PR and that one will collide on `plugin.json` / `CHANGELOG.md`;
whichever merges second needs a rebase.

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` §
A2, A3, A19.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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

2 participants