Skip to content

fix(claude-config): drop P2's unsupported no-expansion claim, refuse an unresolvable scan root, and key the postures report per project - #2259

Merged
kyle-sexton merged 4 commits into
mainfrom
fix/2248-permission-grants-and-postures-keying
Aug 12, 2026
Merged

fix(claude-config): drop P2's unsupported no-expansion claim, refuse an unresolvable scan root, and key the postures report per project#2259
kyle-sexton merged 4 commits into
mainfrom
fix/2248-permission-grants-and-postures-keying

Conversation

@kyle-sexton

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

Copy link
Copy Markdown
Contributor

Summary

Three findings, one plugin. Every claim below was executed or grepped this pass, not derived from a
model of the code — the standard this batch adopted after PR 2's review found three defects of the shape
"a comment asserting something the code does not do".

P2's rationale is unsupported by the docs, and false on rule classes it fires on (#2248)

reference/criteria.md:63 read "Bash rules match literally with no ~/$HOME/env expansion".

Grepped, not assumed. Both pages pulled with curl to a file (skills.md 87,211 bytes;
permissions.md 61,351 bytes) and searched:
grep -in "no ~/\$HOME|match literally|literally with no|no expansion|does not expand" returns zero
hits across both
. The Bash section (permissions.md:162-176) specifies wildcard glob matching and
states no no-expansion rule. So the claim is unsupported, not merely over-broad.

Two documented behaviors contradict it:

  • skills.md:333"Claude Code substitutes ${CLAUDE_SKILL_DIR} and ${CLAUDE_PROJECT_DIR} in two
    places: the skill's markdown content, and Bash rules in the allowed-tools frontmatter."
    The
    canonical example at :339 is allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/render.sh *). The
    skill was telling authors to remove the documented zero-prompt pattern.
  • permissions.md:190 — known-safe leading env-assignment stripping, and scoped: an allow rule
    won't match past an assignment of any other variable, while deny/ask match past any. Stated flatly it
    would over-generalize, so the new text carries the scoping.

And the message is emitted on rule classes where it is false twice over. The single string at
permission-rule-check.sh:139 serves every class. Probed against the shipped script:

Rule Result
Bash(/c/Users/kyle/x.sh:*) flagged P2
Read(/c/Users/kyle/notes.md) flagged, with the "Bash rules match literally" message
Edit(/Users/alice/src/**) flagged, same message
Read(~/Documents/notes.md) not flagged (correct)
Bash(${CLAUDE_SKILL_DIR}/scripts/x.sh *) not flagged (correct)
Bash(${CLAUDE_PROJECT_DIR}/scripts/lint.sh *) not flagged (correct)

Read/Edit rules use gitignore pattern syntax and do resolve ~/ (permissions.md:280:
Read(~/Documents/*.pdf)/Users/alice/Documents/*.pdf). So on a Read finding the old message named
the wrong rule class and asserted a mechanism false for that class.

The emitted message now carries only what is true of every class — the portability break — and names the
portable form per class. The mechanism detail moves into criteria.md as a per-rule-class table, syncing
down from docs/conventions/permission-rule-hygiene/README.md:106-134, which already held the
corrected doctrine and two limits the ledger's sketch omitted: ${CLAUDE_PROJECT_DIR} substitution
requires v2.1.196+, and ${CLAUDE_PLUGIN_ROOT} is not substituted at all, so a rule using it is
inert. No convention edit needed — this is the sync direction.

The scan fell through to $PWD and swept the user profile, exiting 0 (#2249)

permission-rule-check.sh:71-76 ended ROOT="${CLAUDE_PROJECT_DIR:-$PWD}". Outside a repository $PWD
is whatever directory the session stands in — on a developer machine, usually the user profile — and
both scans walk it with find, no -maxdepth, no -prune, stderr discarded, then exit 0. A timeout
or a swallowed permission error was indistinguishable from a clean bill, on a skill that is
model-invocable (disable-model-invocation: false at SKILL.md:5).

The ladder now ends at ${CLAUDE_PROJECT_DIR}; an unresolvable root exits 2, reusing the
environment-gap channel the contract already documents for a missing jq rather than minting a code, so
the advisory exit-0-for-findings contract is untouched. --count refuses too — a 0 from a scan
that never resolved a root reads exactly like a clean bill, and that path exited 0 separately at :264.

Ledger correction, verified: the ledger lists SKILL.md:56 as a third "always exits 0" site to
update. grep -n "exits 0\|exit 0" over SKILL.md returns no match; :56 is the jq-exits-2 line.
SKILL.md carries no always-exits-0 claim. The real count is five, in two files:
reference/criteria.md:20 plus the script's header, usage block, and --help text. All moved together.

The postures report had no project dimension (#2250)

audit-prompting-postures/SKILL.md:78 persisted to one fixed
${CLAUDE_PLUGIN_DATA}/audit-prompting-postures/last-audit.md. ${CLAUDE_PLUGIN_DATA} resolves to
~/.claude/plugins/data/{id}/ where {id} is the plugin identifier, never the project — so the
skill's only durable deliverable was silently overwritten by the next run from any other root.

The filed fix sketch is not what shipped, deliberately. It proposed ${CLAUDE_PROJECT_DIR} with a
"when set, else" fallback. That placeholder substitutes inline in skill content, so the model never
sees the literal token and cannot evaluate "when set" — the defect filed separately against audit-pass
as F9. Implementing it as sketched would have introduced that defect while removing this one. The
derivation is therefore written as commands to run.

The scheme is audit-pass's, reused rather than reinvented
<repo-identity>/<worktree-discriminator> from run-state-and-resumability.md §3 — because a second
scheme for one concern is the drift this batch exists to remove. One rung added: that ladder has
git-with-remote and git-without-remote and no non-repo rung. audit-pass does not need one (PR #2234
made it refuse non-git targets); this skill is report-only and legitimately audits them — the run that
produced the finding was rooted at a non-repo home directory.

Plus the mandated three-line header (resolved root, scope filter, UTC timestamp), so a surviving report
is self-describing rather than merely un-overwritten.

What review caught — including the same defect class, in my own work

Five threads, all resolved, four follow-up commits. Two of the findings were the exact shape this batch
keeps hitting, and both were mine:

  • The snippet read remote.origin.url while the sentence above it claimed verbatim reuse of a scheme
    that says "the first configured remote URL".
    Reproduced: a repo whose only remote is upstream
    returns empty and fell to the local/ rung despite having a remote, so one repository keyed
    differently depending on what someone named their remote. Now git remote | head -1.
  • A remote URL is arbitrary text that becomes directory components, and I did not validate it.
    Reproduced with git remote add origin ../central.git: state key ../central/2a8fd283, and the
    report path normalizes to /…/central/…outside this skill's namespace entirely. Absolute-local
    and Windows-path remotes break the same way. The identity is now accepted only in the shape the scheme
    means, and anything else keys by hash, still deterministically.

Plus three smaller ones: root now strips CRLF to match the sibling script this same PR touches; the
new -d "$ROOT" guard shipped untested next to four assertions for its sibling branch, and now has five
cases; and CI's machine-path detector caught four literals I added, where the test file already had a
runtime-assembly idiom I had failed to follow.

Verified after the remote fix across seven shapes — relative, absolute-local, Windows, https, scp-style
ssh, no-remote, non-repo:

  CONTAINED  remote/feb98d0c6fe2/5bdf8482       relative filesystem remote
  CONTAINED  remote/9025af3e59ae/72f53c5b       absolute local remote
  CONTAINED  remote/ee5e188160e5/f80a5d9b       windows local remote
  CONTAINED  github.com/acme/widget/5fb34de0    normal https remote
  CONTAINED  github.com/acme/widget/31e38d12    scp-style ssh remote
  CONTAINED  local/aa0eeb6c3f8f/aa0eeb6c        no remote at all
  CONTAINED  nonrepo/862f1fe5638e/862f1fe5      not a repo

  remote name: upstream   key: github.com/acme/widget/51e207e2   (was local/… before the fix)
  stable across runs; with several remotes the first wins

Trust surface

Narrows. The scan refuses an unresolved root instead of walking the user's home, and the report path can
no longer escape the skill's own directory via a crafted remote. Nothing widens. No new grant, hook, or
network read.

Test plan

Fail-before / pass-after, both rows. The new assertions run against the pre-fix script
(git checkout origin/main -- permission-rule-check.sh, test file kept):

$ bash plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh
FAIL: P2 detail does not assert a blanket no-expansion rule
FAIL: P2 detail does not scope its rationale to Bash rules
FAIL: Read-rule finding does not claim Bash semantics
FAIL: Read-rule finding does not deny ~ expansion
FAIL: unresolvable root exits 2, not 0
FAIL: refusal names the failure
FAIL: refusal is not a clean bill
FAIL: --count also refuses rather than printing 0
14/68 checks failed.

Restored, and after the fix:

$ bash plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh
All 68 checks passed.

Baseline for reference was All 50 checks passed. at the merge-base.

Two of the new assertions are guards, not failing tests, and are presented as such: the
${CLAUDE_SKILL_DIR} positive case and "explicit fixture root still scans" both pass before the change
too. The A4 refusal test unsets CLAUDE_PROJECT_DIR as well as PERMISSION_HYGIENE_FIXTURE_DIR and sets
GIT_CEILING_DIRECTORIES, so it fails for the intended reason rather than inheriting a root from the
outer session.

The CC-F1 derivation was executed, not just written. All three identity rungs, in real contexts:

--- context 1: this repo (has a remote)
github.com/melodic-software/claude-code-plugins/8163d6b9
--- context 2: git repo with NO remote
local/c508785eede5/c508785e
--- context 3: not a repo at all
nonrepo/fa5107130e4e/fa510713

And the worktree discriminator does what it exists for — two worktrees of this repository:

C:/Projects/melodic/worktrees/batch4-laneA-audit -> 8163d6b9
C:/Projects/melodic/claude-code-plugins         -> 009628cd

Portability caught by executing rather than assuming: sha256sum is absent on stock macOS, so the
snippet carries a shasum -a 256 fallback (both verified present here).

Static gates:

$ shellcheck permission-rule-check.sh permission-rule-check.test.sh     (clean)
$ jq empty audit-prompting-postures/evals/evals.json plugin.json        (clean)
$ bash check-evals-quality.sh audit-prompting-postures/evals/evals.json
check-evals-quality: PASS (0 warning(s) across 1 file(s))
$ CHECK_SKILL_SKILLS_ROOT=... check-skill.sh audit-permission-grants
CHECK-SKILL audit-permission-grants: PASS — 0 errors, 1 warning(s)   (pre-existing: no Gotchas surface)
$ CHECK_SKILL_SKILLS_ROOT=... check-skill.sh audit-prompting-postures
CHECK-SKILL audit-prompting-postures: PASS — 0 errors, 0 warning(s)
$ npx markdownlint-cli2 <5 changed md files>                            Summary: 0 issues in 0 files

CHANGELOG verified as additions only: git diff --cached CHANGELOG.md | grep -c '^-[^-]' = 0, so the
shipped 0.30.0 and 0.31.0 entries are untouched.

Related

Closes #2248
Closes #2249
Closes #2250

Inbox items: batch-4 ledger I10 rows A1 and A4; ledger I9 row CC-F1, whose mechanism was
replaced per reconciliation OR-3 (rationale_falsified in effect, caught cross-ledger).

Bump: claude-config 0.31.0 → 0.32.0 — minor, because two graded behaviors move (an unresolvable
root now refuses; the report path changes).

Reproduced but deliberately not fixed: criteria.md:60-61 claims //… forms are not flagged, and
Read(//Users/alice/secrets/**) — the docs' own literal example at permissions.md:278is flagged.
That is ledger row A2 (MED, implement_now: false), a different hunk in the same file. The
reproduction is recorded on #2248 as corroborating evidence for it; the new text asserts nothing about
// exemption. Also left: A3 (P2 prints an 8-char fragment), A11, A14, and CC-F2CC-F11.
Filing for those is held pending the operator's call on batch-4 volume (OR-4).

…an unresolvable scan root, and key the postures report per project

P2 told authors "Bash rules match literally with no ~/$HOME/env expansion".
Grepping the complete raw markdown of both the permissions and skills pages finds
no such sentence on either — the claim is unsupported, not merely over-broad —
and two documented behaviors contradict it: ${CLAUDE_SKILL_DIR} and
${CLAUDE_PROJECT_DIR} are substituted in allowed-tools Bash rules, which the docs
present as the way to run a bundled script without a prompt, and a leading
assignment of known-safe env vars is stripped.

The same string is emitted on Read and Edit findings, where it is false twice
over. Probing the shipped detector confirms P2 fires on Read and Edit rules
carrying a Bash-scoped message, while those classes use gitignore syntax and DO
resolve ~/. One message serves every class, so it now carries only the
portability break, which is true of all of them, and names the portable form per
class. The mechanism moves into criteria.md as a per-class table synced down from
the permission-rule-hygiene convention, which already held the corrected
doctrine. The wrong text was pinned by a passing assertion; the test moved too.

Root resolution fell through to $PWD, which outside a repo is usually the user
profile — then walked it with find, no depth bound, stderr discarded, exit 0, on
a model-invocable skill. A timeout and a clean bill were indistinguishable. The
ladder now ends at $CLAUDE_PROJECT_DIR and an unresolvable root exits 2, reusing
the environment-gap channel already documented for missing jq rather than minting
a code. --count refuses too, since a 0 from a scan that resolved nothing reads
exactly like a clean bill. All five always-exits-0 statements moved with it;
SKILL.md carried none, contrary to the ledger's citation.

audit-prompting-postures persisted to one fixed last-audit.md under
CLAUDE_PLUGIN_DATA, which resolves per PLUGIN, never per project — so its only
durable deliverable was overwritten by the next run from any other root. The path
now carries a state key reusing audit-pass's <repo-identity>/<worktree-
discriminator> rather than minting a second scheme, plus one added non-repo rung
that audit-pass does not need because it refuses non-git targets. The derivation
is written as commands to run, never as a condition over ${CLAUDE_PROJECT_DIR} —
that placeholder substitutes inline, so the literal token never reaches the model
and the filed fix sketch would have introduced that defect while removing this
one. A mandated three-line header makes a surviving report attributable.

Suite 50 -> 62 checks; 8 of the new assertions fail against the pre-fix script.

Closes #2248
Closes #2249
Closes #2250

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 9s —— View job


Security review complete

  • Gather context (PR diff, changed files)
  • Review permission-rule-check.sh for injection / path handling issues
  • Review permission-rule-check.test.sh
  • Review audit-prompting-postures/SKILL.md derivation commands
  • Review other changed files (criteria.md, plugin.json, CHANGELOG.md, evals.json)
  • Post final findings

Scope: only the 8 files changed in this PR (gh pr diff / git diff origin/main...HEAD). permission-rule-check.sh was checked closely since it's the only executable changed; everything else is markdown/JSON.


IMPORTANT — Unsanitized git remote.origin.url builds a filesystem path, enabling path traversal in the report-write location

File: plugins/claude-config/skills/audit-prompting-postures/SKILL.md#L91-L98

The new <state-key> derivation snippet computes identity directly from git config --get remote.origin.url when a remote exists:

identity=$(printf '%s' "$remote" \
  | sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' \
  | tr '[:upper:]' '[:lower:]')

This sed pipeline strips a scheme, userinfo, and .git suffix, but does not strip .. path segments, leading slashes, or other filesystem-meaningful characters. identity then becomes part of the literal path the skill is told to write the audit report to (line 79):

${CLAUDE_PLUGIN_DATA}/audit-prompting-postures/<state-key>/last-audit.md

Failure scenario: a repo whose origin remote is set to something like https://x/../../../../../../tmp/pwned (via git remote set-url, a crafted .git/config, a repo-bundled setup script/hook, or simply how the repo was obtained) yields identity = x/../../../../../../tmp/pwned. The report then gets written outside the intended ${CLAUDE_PLUGIN_DATA}/audit-prompting-postures/ tree, at an attacker-chosen directory (filename is fixed as last-audit.md, but the containing directory is fully attacker-controlled). This is a real, concrete gap in the sanitization logic — not merely theoretical: nothing in the pipeline rejects . / .. segments.

This is meaningfully higher risk than a typical "trusted local doc snippet" because:

  • The skill has disable-model-invocation: false (SKILL.md:5), so it can be auto-invoked by the model (e.g. "after authoring a new skill or agent") without the user explicitly asking for an audit — including inside an untrusted/just-cloned repository.
  • The instructions explicitly tell the model to run these exact commands and use the result verbatim ("Run those and use the result... Derive the key from commands you actually run," lines 118-121), so there's no sanitization step the model is directed to apply before using state_key as a write path.

Note: the sibling local/ (no-remote) and nonrepo/ branches (lines 100, 104) are safe — they hash the value with sha256 before use. Only the has-remote branch embeds unsanitized, repo-controlled text directly into the path.

Suggested fix: after the existing transforms, strip/reject any .. path components and any character outside a safe allowlist (e.g. [a-z0-9._/-]), or simply hash the normalized remote URL the same way the local//nonrepo/ branches already do, rather than embedding it verbatim.

Confidence: Medium — the missing sanitization in the code is confirmed by inspection; full exploitation depends on the model faithfully executing the documented snippet and then using the resulting path for a Write without independently noticing the traversal, which is plausible but not certain given this is markdown-driven instruction rather than an unconditionally-executed script.

Fix this →


No other security issues found

  • permission-rule-check.sh's new root-resolution/refusal logic (lines 71-98) is a net security improvement — it now refuses to scan (exit 2) rather than falling back to $PWD/user-home, closing the "silent sweep of the user profile, exit 0" issue described in the PR. $ROOT is always properly quoted and only sourced from git rev-parse --show-toplevel, $CLAUDE_PROJECT_DIR, or the fixture env var — not attacker-controlled free text — so no injection concerns there.
  • settings.local.json handling is unchanged in this PR and still correctly extracts only .permissions.allow via jq, never echoing the file wholesale (relevant since that file may hold tokens).
  • plugin.json, CHANGELOG.md, criteria.md, permission-rule-check.test.sh, and evals.json are docs/tests/metadata only — no executable logic, no security surface.
  • No GitHub Actions/workflow files, dependency pins, or permissions: blocks are touched by this PR.

Branch: fix/2248-permission-grants-and-postures-keying

@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: 06f9f8f3fd

ℹ️ 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-prompting-postures/SKILL.md
… and placeholder the doc quotes

CI's machine-specific-paths detector flagged four sites I added: the new
Read/Edit test fixture, the docs quote in criteria.md's rule-class table, and its
two echoes in the changelog.

The test file already had the idiom and I did not follow it — it assembles
POSIX_MP and WIN_MP from a SL variable precisely so no contiguous path literal
appears in source. The new Read/Edit fixtures now do the same, and the prose uses
<home> rather than reproducing the permissions page's example username verbatim.

Verified locally against the same path classes the CI detector reports on, over
every file this branch touches, before pushing.

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

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff at HEAD 2975eae vs origin/main)
  • Check whether prior findings (path traversal via git remote URL) were addressed by the latest commit
  • Review permission-rule-check.sh changes for injection/path handling
  • Review audit-prompting-postures/SKILL.md state-key derivation snippet
  • Review remaining changed files (criteria.md, plugin.json, CHANGELOG.md, evals.json, test script)
  • Post final findings

Scope: the 8 files changed in this PR (gh pr diff 2259). The second commit (2975eae) only reworded machine-path literals flagged by CI's own detector (test fixtures, a docs quote, changelog echoes) — I diffed 06f9f8f..2975eae and confirmed audit-prompting-postures/SKILL.md is byte-identical between the two commits, so the state-key derivation logic is unchanged from what was reviewed on the prior push.


IMPORTANT — Unsanitized git remote.origin.url still builds an unconstrained filesystem path (path traversal), unaddressed since the last review

File: plugins/claude-config/skills/audit-prompting-postures/SKILL.md#L94-L98

This is the same defect flagged on the prior push (both by this bot and by the Codex review comment on SKILL.md:98) — it has not been touched by the follow-up commit.

remote=$(git config --get remote.origin.url 2>/dev/null || true)
...
if [ -n "$remote" ]; then
  identity=$(printf '%s' "$remote" \
    | sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' \
    | tr '[:upper:]' '[:lower:]')

identity becomes the leading path component of the report location the skill is told to write to (line 79):

${CLAUDE_PLUGIN_DATA}/audit-prompting-postures/<state-key>/last-audit.md

The sed pipeline strips a URL scheme, userinfo, and .git suffix, but never rejects/strips .. segments or other path-meaningful characters. Two independent trigger shapes, both real:

  • Codex's report: a relative filesystem remote — git remote add origin ../central.git is ordinary, undoctored git usage (no crafted config needed) — normalizes to identity=../central, which walks the write target up and out of the audit-prompting-postures/ directory.
  • Prior finding here: a remote deliberately set to something like https://x/../../../../../../tmp/pwned yields an identity that escapes ${CLAUDE_PLUGIN_DATA} entirely.

By contrast, the local/ (no-remote) and nonrepo/ branches (lines 99-104) hash their input with sha256 before use — only the has-remote branch embeds attacker/repo-controlled text verbatim into a directory path.

Why this still matters here: the skill has disable-model-invocation: false (SKILL.md:5), so it can be auto-invoked while working in a just-cloned or otherwise untrusted repo, and the instructions tell the model to run these exact commands and use the result as the write path with no sanitization step in between.

Suggested fix (unchanged from before): hash the normalized identity from the has-remote branch the same way the local//nonrepo/ branches already hash theirs, or explicitly strip/reject .. segments and any character outside a safe allowlist ([a-z0-9._-] per path segment) before using it as a directory component.

Severity: IMPORTANT. Confidence: Medium — the missing sanitization is confirmed by inspection and the relative-remote trigger requires no adversarial crafting at all; full exploitation still depends on the model faithfully executing the documented snippet and using the resulting path for a Write without independently normalizing it.


No other security issues found

  • permission-rule-check.sh's refusal logic (root ladder ending at ${CLAUDE_PROJECT_DIR}, exiting 2 on an unresolved root instead of falling back to $PWD) is a net security improvement — closes the prior "silent sweep of the user profile, exit 0" gap. $ROOT is quoted everywhere it's used in find/string operations and is sourced only from the fixture env var, git rev-parse --show-toplevel, or $CLAUDE_PROJECT_DIR — never attacker-controlled free text.
  • settings.local.json handling is unchanged and still extracts only .permissions.allow via jq, never echoed wholesale.
  • plugin.json, CHANGELOG.md, criteria.md, permission-rule-check.test.sh, and evals.json are docs/tests/metadata only in this diff — no executable logic, no new security surface.
  • No GitHub Actions/workflow files, dependency pins, or permissions: blocks are touched by this PR.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Code review complete

  • Gather context (PR diff, changed files, CLAUDE.md / REVIEW.md guidelines)
  • Review permission-rule-check.sh and its test file
  • Review audit-permission-grants docs (SKILL.md, criteria.md)
  • Review audit-prompting-postures/SKILL.md and evals
  • Review metadata files (plugin.json, CHANGELOG.md)
  • Post inline findings and summary

Scope: the 8 files changed in this PR, per gh pr diff. Per REVIEW.md, this lane owns correctness/design/conventions/error-handling/tests/docs and leaves security to the dedicated security lane (.github/workflows/claude-security-review.yml exists in this repo) — so the path-derivation-as-attacker-controlled-input concern already raised by the automated security review and by Codex's inline comment on SKILL.md:98 is intentionally left to that lane, not re-litigated here.

Two findings posted inline on audit-prompting-postures/SKILL.md:

  1. 🔴 Important (line 91) — the derivation hardcodes git config --get remote.origin.url, but the scheme it's cited as reusing (audit-pass's run-state-and-resumability.md §3) specifies keying off "the first configured remote", not specifically one named origin. A repo whose sole/primary remote isn't named origin silently falls through to the local/ (no-remote) branch, which keys off the worktree's own path instead of a portable remote identity — deviating from both the cited spec and the stated purpose of separating repo-identity from worktree-discriminator.
  2. 🟡 Nit (line 92) — the same git rev-parse --show-toplevel call in permission-rule-check.sh (also touched by this PR) is piped through tr -d '\r' to handle Windows Git's CRLF output; this new, near-identical call in the postures skill isn't. Low impact since the value is only hashed, but it's an inconsistency within the same change.

Everything else held up under review:

  • permission-rule-check.sh's root-refusal ladder (fixture dir → git toplevel → $CLAUDE_PROJECT_DIR, exit 2 on failure in both normal and --count mode) is correctly unconditional on mode, so --count really does refuse rather than print a misleading 0.
  • criteria.md's new P2 mechanism table matches what the script actually emits per rule class (verified by reading scan_rule()'s emitted string directly, not just the PR's claims).
  • The new/updated test cases (permission-rule-check.test.sh) exercise the refusal path from a directory with GIT_CEILING_DIRECTORIES set and both CLAUDE_PROJECT_DIR/PERMISSION_HYGIENE_FIXTURE_DIR unset — correctly isolated from the outer session's environment.
  • plugin.json version bump (0.31.0 → 0.32.0) and the CHANGELOG.md entry are consistent with the diff.
  • evals.json and plugin.json are valid JSON (checked directly with jq empty).

Not independently verified — flagging per instructions rather than restating as confirmed: the PR body's permission-rule-check.test.sh fail-before/pass-after run counts, the shellcheck/markdownlint/check-skill.sh output, and the live derivation output against real remotes/worktrees are all author-claimed. I could not execute bash/shellcheck in this sandbox (commands required interactive approval not available in this session), so I read the script logic statically instead of re-running the suite.

Branch: fix/2248-permission-grants-and-postures-keying

Comment thread plugins/claude-config/skills/audit-prompting-postures/SKILL.md Outdated
Comment thread plugins/claude-config/skills/audit-prompting-postures/SKILL.md Outdated
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

… before it becomes a directory path

Three review findings on the state-key derivation, all reproduced before fixing.

The snippet read remote.origin.url while claiming to reuse audit-pass's scheme
verbatim — and that scheme says "the first configured remote URL", not one named
origin. Reproduced: a repo whose only remote is `upstream` has a remote, yet fell
through to the local/ rung. That is the same defect class this batch keeps
finding: prose asserting something the code does not do, and this time in a
sentence claiming faithful reuse. It now reads `git remote | head -1`.

A remote URL is arbitrary text and becomes DIRECTORY COMPONENTS here. Reproduced
with `git remote add origin ../central.git`: identity `../central`, and the report
path normalizes to /…/central/…, outside this skill's namespace entirely.
Absolute-local and Windows-path remotes break the same way. The identity is now
accepted only in the shape the scheme means — segments of [a-z0-9._-] each
starting alphanumeric — and anything else keys by hash, still deterministically.

Verified after the fix across seven remote shapes: relative, absolute-local,
Windows, https, scp-style ssh, no-remote, and non-repo. All stay contained; https
and ssh normalize to the same identity; the key is stable across runs and picks
the first remote when several exist.

And root now strips CRLF, matching the sibling script this same PR touches, which
does it because Windows Git can return a CRLF-terminated path.

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

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (PR diff at HEAD ce8b8e1 vs origin/main)
  • Check whether the previously-flagged path-traversal / "first configured remote" findings were addressed by the latest commit (ce8b8e1)
  • Review permission-rule-check.sh changes for injection/path handling
  • Review audit-prompting-postures/SKILL.md state-key derivation snippet
  • Review remaining changed files
  • Post final findings

Scope: the 8 files changed in this PR. ce8b8e1 (the only new commit since the last review pass) touches only CHANGELOG.md and audit-prompting-postures/SKILL.md — it directly targets the path-traversal and "first configured remote" findings raised on the prior two pushes, so this pass re-derives and probes that fix rather than re-covering ground already cleared (permission-rule-check.sh's refusal ladder, settings.local.json handling, JSON/docs-only files).

The "first configured remote" gap and the raw ../central-style traversal are now fixed: remote_name=$(git remote | head -1) picks up non-origin remotes, and the new grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$' check rejects a .. segment (a segment can't start with .) and falls back to a sha256 hash. Traced the regex against absolute paths, Windows drive letters, trailing slashes, and double slashes — all correctly rejected into the hash branch.


IMPORTANT — The new sanitization check validates a line, not the string: an embedded newline in the remote URL still bypasses it

File: plugins/claude-config/skills/audit-prompting-postures/SKILL.md#L93-L112

remote_name=$(git remote 2>/dev/null | head -1)
remote=$(git config --get "remote.${remote_name}.url" 2>/dev/null || true)
...
identity=$(printf '%s' "$remote" | sed ... | tr '[:upper:]' '[:lower:]')
if ! printf '%s' "$identity" | grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$'; then
  identity="remote/$(printf '%s' "$remote" | sha256 | cut -c1-12)"
fi

grep -qE with an anchored ^...$ pattern matches per line, and -q succeeds as soon as any line matches — it does not require the whole input to match on one line. If $identity contains an embedded newline, one innocuous line is enough to make the check pass even though another line in the same string is the malicious payload, and the entire multi-line $identity (untouched) still gets used to build the report path.

Git's own config-file format supports this precondition directly: a double-quoted config value may contain a literal \n escape that git config --get returns as a real newline byte (git-config(1), SYNTAX: \", \\, \n, \t are valid value escapes). So a crafted .git/config — exactly the threat model this same fix commit's message and the earlier reviews on this PR already invoke ("crafted .git/config", "repo-bundled setup script") — can set:

[remote "origin"]
    url = "a\n../../../../tmp/evil"

Verified the bypass mechanically (not just reasoned about it): feeding a two-line value (a / ../../../../tmp/evil) through the exact regex above, only the first line (a) matches — but that's sufficient for grep -q to report success for the whole stream, so the fallback-to-hash branch is skipped and $identity keeps its second, unsanitized line verbatim. The embedded /../../../ sequence in that second line is composed of real / bytes (not shell-escaped), so when the resulting state_key is later concatenated into ${CLAUDE_PLUGIN_DATA}/audit-prompting-postures/<state-key>/last-audit.md and that path string is used to create a directory (mkdir -p, a Write call, etc.), those / characters function as ordinary path separators regardless of the stray newline elsewhere in the string — the traversal still resolves outside audit-prompting-postures/, reproducing the exact escape this commit was written to close.

Suggested fix: reject on embedded newlines before or as part of the shape check, e.g. require the whole (possibly multi-line) value to fail if it contains \n at all — case $identity in *$'\n'*) identity="remote/$(...)";; esac — or anchor the grep check to the whole buffer with grep -zqE (NUL-delimited, single "line") instead of the default per-line mode, or simplest: hash first and only allow-list the result of a transform that itself cannot contain / or \n (i.e. don't try to preserve human-readable remote text in the identity at all).

Severity: IMPORTANT — same root class and impact as the traversal this commit fixes (report write escapes ${CLAUDE_PLUGIN_DATA}/audit-prompting-postures/), just via a narrower trigger (an embedded-newline remote URL rather than a plain relative one). Confidence: High — the per-line vs. whole-string grep semantics were confirmed directly against the shipped regex, and the newline-in-config-value precondition is documented git behavior, not speculative.


No other new security issues found

  • The "first configured remote" fix itself is correct and closes the previously-reported gap: it now reads git remote | head -1 rather than hardcoding origin, matching the cited audit-pass scheme.
  • The ../central-style plain relative-remote traversal flagged by Codex and the prior two review passes is fixed for single-line identities — confirmed by tracing the regex against ../central, absolute paths, Windows drive letters, trailing/double slashes: all correctly fall through to the sha256 hash branch.
  • root now strips \r (tr -d '\r'), addressing the CRLF nit from the prior code-review pass.
  • permission-rule-check.sh, permission-rule-check.test.sh, criteria.md, SKILL.md (audit-permission-grants), plugin.json, and evals.json are unchanged by ce8b8e1 and were already cleared in the prior review pass on this PR — no new surface there.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Code review complete

  • Gather context (PR diff, changed files, CLAUDE.md / REVIEW.md guidelines)
  • Review latest commit ce8b8e1 (first-configured-remote + path validation) against prior review findings
  • Review permission-rule-check.sh and its test file
  • Review audit-permission-grants docs (SKILL.md, criteria.md)
  • Review audit-prompting-postures/SKILL.md and evals
  • Review metadata files (plugin.json, CHANGELOG.md)
  • Post inline findings and summary

Scope: this lane owns correctness/design/conventions/error-handling/tests/docs. Path-traversal / injection concerns already raised by the security lane and by Codex's inline comment are left to that lane, not re-litigated here — except to note, as context for this review, that ce8b8e1 addresses them (see below).


ce8b8e1 correctly resolves the three findings from the prior review pass

Read the commit directly (git show ce8b8e1), not just the message:

  • First-configured-remote: remote_name=$(git remote 2>/dev/null | head -1) replaces the remote.origin.url-only lookup, matching audit-pass's cited spec ("the first configured remote URL", verified at run-state-and-resumability.md:15) instead of hardcoding origin.
  • CRLF: git rev-parse --show-toplevel is now piped through tr -d '\r', matching the sibling script.
  • Path traversal: identity is now validated against ^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$ before use, falling back to a remote/<sha256> hash otherwise. I traced this by hand against the shapes the commit message claims were verified — relative (../central.git), absolute-local, and Windows-drive remotes all fail the regex (each starts with ., /, or contains a bare \/: that survives the sed normalization) and correctly fall to the hash branch; https:// and git@host: scp-style remotes normalize to the same identity and pass. The fix is sound.

Note for the record (not a new finding — matches what's already in this PR's own thread): this validation lives entirely in markdown-embedded bash with no automated test harness, same as audit-pass's own run-state-and-resumability.md, which has no test file either — so that's consistent with existing repo convention for this class of doc, not a new gap this PR introduces.

One nit posted inline

permission-rule-check.sh:109-112 — the new [[ ! -d "$ROOT" ]] guard (a resolved root that doesn't exist / isn't a directory) has no test case in permission-rule-check.test.sh; every run() call in the suite points PERMISSION_HYGIENE_FIXTURE_DIR at a directory it just created. Minor, since the branch is a straightforward two-line refusal, but worth one more assertion given how thoroughly this PR tests the sibling "root never resolved" path.

Everything else held up

  • criteria.md's new P2 mechanism table (Bash glob-over-literal vs. Read/Edit gitignore-syntax-with-anchor-resolution, plus the two documented Bash exceptions) matches what scan_rule() actually emits — verified by reading the ERE/message construction at permission-rule-check.sh:147-176 directly, not just the PR's claims.
  • The doc links added in this PR resolve to real files: ../audit-pass/reference/run-state-and-resumability.md and docs/conventions/permission-rule-hygiene/README.md both exist.
  • The "five sites" ledger correction (all always exits 0 language moved) checks out — grepped SKILL.md, criteria.md, and the script itself; no stale unconditional-exit-0 claim remains for the root-resolution path (the per-flag --help lines still say "exit 0" but that's immediately scoped by the paragraph right below it, not misleading in context).
  • permission-rule-check.sh's refusal is correctly unconditional on mode — it runs before mode is even branched on, so --count really does refuse rather than print a misleading 0.
  • plugin.json (0.31.0 → 0.32.0) and CHANGELOG.md are consistent with the diff; both are valid JSON/well-formed.
  • Evals 4 and 5 added to audit-prompting-postures/evals/evals.json exercise the new per-project keying and non-repo rung at the eval level.

Could not independently execute (bash, shellcheck, command -v all required interactive approval unavailable in this session — same limitation noted on the prior review pass): the test suite run, shellcheck, and check-skill.sh output the PR body describes. I verified logic by reading the script and test file directly rather than running them; the PR body's execution claims are author-claimed and unverified by me.

Branch: fix/2248-permission-grants-and-postures-keying

…guard

I added a `-d "$ROOT"` refusal in this PR and shipped it untested, while writing
four assertions for the sibling never-resolved branch — every existing run()
passes a fixture dir the test had just created, so nothing exercised it.

Five cases now do: a fixture root that does not exist, one that is a regular
file, and --count on a nonexistent root, each asserting exit 2, the reason in the
message, and that it is not reported as a clean bill. Without the guard `find`
would print nothing to a discarded stderr and the run would report "No fragile
permission grants found" for a root it never read.

Suite 62 -> 68 checks; 14 now fail against the pre-fix script, up from 8.

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

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Security review complete

  • Gather context (diff at HEAD 2415f29 vs origin/main, via gh pr diff 2259 since the local shallow origin/main ref was disconnected from this branch's history)
  • Confirm the newest commit (2415f29) is test-only and doesn't touch security-relevant logic
  • Re-derive and mechanically re-test the state-key path-traversal fix (ce8b8e1) against the current HEAD
  • Review permission-rule-check.sh root-resolution/refusal logic
  • Review remaining changed files
  • Post final findings

Scope: the 8 files changed in this PR. 2415f29 (the only commit since the last review pass at ce8b8e1) is confirmed test-only — git diff ce8b8e1..2415f29 touches only permission-rule-check.test.sh, adding coverage for the existing "resolved root doesn't exist / isn't a directory" branch. No production logic changed, so this pass re-verifies the standing finding against current HEAD rather than re-covering already-cleared ground (permission-rule-check.sh's refusal ladder, settings.local.json handling, JSON/docs-only files — all unchanged and previously cleared).


IMPORTANT — Shape-check on the derived identity matches per line, not the whole string: an embedded newline in the remote URL still bypasses it (unaddressed since the prior review pass)

File: plugins/claude-config/skills/audit-prompting-postures/SKILL.md#L407

if ! printf '%s' "$identity" | grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$'; then
  identity="remote/$(printf '%s' "$remote" | sha256 | cut -c1-12)"
fi

^...$ in grep -E anchors to the start/end of each line, not the whole buffer, and grep -q succeeds the instant any line matches. If $identity contains an embedded newline, one innocent line is enough to make the whole check pass even though a different line in the same string carries a traversal payload — and the untouched, still-multi-line $identity is what gets used to build the write path at line 379:

${CLAUDE_PLUGIN_DATA}/audit-prompting-postures/<state-key>/last-audit.md

Precondition is real, documented git behavior, not hypothetical: a double-quoted git config value can contain a literal \n escape (git-config(1), SYNTAX), so:

[remote "origin"]
    url = "a\n../../../../tmp/evil"

git config --get remote.origin.url returns that as a real two-line string.

Mechanically re-verified this pass (not just re-read from the prior finding): wrote a two-line file (a / ../../../tmp/evil) and matched it against the exact shipped pattern —

$ grep -E '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$' idtest.txt
a

Only line 1 (a) is reported, but that's sufficient for grep -q to report success for the whole stream — the fallback-to-hash branch is skipped, and $identity keeps its unsanitized second line. The /../../../ in that line is composed of ordinary / bytes, so wherever the resulting state_key is later turned into a directory (mkdir -p, a Write call), those bytes function as real path separators and the traversal escapes audit-prompting-postures/ — reproducing the exact class of bug (../central.git → path escape) this same commit (ce8b8e1) was written to close, just via an embedded-newline trigger instead of a bare relative-remote trigger.

Why it still matters here: the skill has disable-model-invocation: false (SKILL.md:5), so it can be auto-invoked in a just-cloned/untrusted repo, and the instructions tell the model to run these exact commands and use the result as the write path with no independent sanitization step.

Suggested fix (unchanged from the prior pass, since the code hasn't moved): reject on any embedded newline before/alongside the shape check — e.g. case $identity in *$'\n'*) identity="remote/$(...)";; esac — or anchor the check to the whole buffer instead of per-line matching (grep -zqE for NUL-delimited single-buffer mode), or simplest: always hash the has-remote branch the same way the local//nonrepo/ branches already do, rather than trying to preserve human-readable text in the identity at all.

Severity: IMPORTANT — same class/impact as the traversal ce8b8e1 fixed (report write escapes ${CLAUDE_PLUGIN_DATA}/audit-prompting-postures/), narrower trigger (embedded-newline remote URL vs. a plain relative one). Confidence: High — the per-line-vs-whole-string grep semantics were re-confirmed directly against the shipped regex this pass, and the newline-in-git-config-value precondition is documented git behavior.


No other security issues found

  • permission-rule-check.sh's refusal ladder (fixture dir → git toplevel → ${CLAUDE_PROJECT_DIR}, exit 2 on an unresolved or non-directory root, unconditional on --count vs. normal mode) is a net security improvement — closes the prior "silent sweep of the user profile, exit 0" gap. Traced every use of $ROOT in the script this pass: it's quoted everywhere (find "$ROOT", "$ROOT/.claude/settings.json", parameter-expansion strips) and is only ever assigned from $PERMISSION_HYGIENE_FIXTURE_DIR, git rev-parse --show-toplevel, or $CLAUDE_PROJECT_DIR — never attacker-controlled free text.
  • The "first configured remote" fix (git remote | head -1 instead of hardcoding origin) and the plain relative/absolute/Windows-path traversal fix are both correct and hold on this HEAD — confirmed by re-tracing the regex against those shapes.
  • settings.local.json handling is unchanged and still extracts only .permissions.allow via jq, never echoed wholesale.
  • plugin.json, CHANGELOG.md, criteria.md, permission-rule-check.test.sh, audit-permission-grants/SKILL.md, and evals.json are docs/tests/metadata only in this diff — no executable logic, no new security surface.
  • No GitHub Actions/workflow files, dependency pins, or permissions: blocks are touched by this PR.

Branch: fix/2248-permission-grants-and-postures-keying

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Code review complete

  • Gather context (PR diff at HEAD 2415f29 vs merge-base 96d578d, prior review history on this PR)
  • Review the new commit 2415f29 (test coverage for the -d "$ROOT" guard, previously flagged as a nit)
  • Re-check permission-rule-check.sh / permission-rule-check.test.sh as a whole
  • Re-check audit-prompting-postures/SKILL.md state-key derivation (non-security angle)
  • Re-check remaining changed files (criteria.md, SKILL.md, plugin.json, CHANGELOG.md, evals.json)
  • Post findings

Scope: this lane owns correctness/design/conventions/error-handling/tests/docs. Security concerns (the remote-URL-derived path validation in audit-prompting-postures/SKILL.md, including the embedded-newline gap already raised by the security lane) are left to that lane per REVIEW.md's split and are not re-litigated here.


The new commit (2415f29) correctly closes the previously-flagged test-coverage gap

The prior code-review pass on this PR noted that the new [[ ! -d "$ROOT" ]] guard in permission-rule-check.sh:109-112 had no test exercising it. 2415f29 adds five assertions in permission-rule-check.test.sh:263-282:

  • a fixture root that doesn't exist → exit 2, message names the reason, no clean-bill text
  • a fixture root that's a regular file (not a directory) → exit 2, same message
  • --count against a nonexistent root → exit 2

I traced this against the actual script: the guard's message is printf 'ERROR: scan root does not exist or is not a directory: %s\n' "$ROOT" (permission-rule-check.sh:110), which matches both assert_contains substrings the new tests check for ("does not exist or is not a directory" and "not a directory"). Coverage is correct and closes the gap.

Everything else re-checked holds up

  • criteria.md's per-rule-class table is consistent with docs/conventions/permission-rule-hygiene/README.md's anti-pattern 2 (verified by reading both directly) — the convention doc already scoped the "no expansion" claim to Bash rules with the same two documented exceptions, so this is a genuine sync-down, not a new claim.
  • permission-rule-check.sh's emitted P2 message (permission-rule-check.sh:175) matches what criteria.md documents per rule class, and the audit-permission-grants/SKILL.md exit-2 description matches the actual clean-bill string ("No fragile permission grants found." at permission-rule-check.sh:304) it says must never appear on a refusal.
  • evals.json's two new cases (ids 4-5) map directly onto the two new behaviors (per-project keying, non-repo rung + header) and are well-formed JSON.
  • plugin.json version bump and CHANGELOG.md entries are consistent with the diff; nothing else changed in this last push.

No further correctness/design/test/docs issues found. Prior open items (the "first configured remote" fix, CRLF stripping, and the path-traversal validation in audit-prompting-postures/SKILL.md) were already resolved by ce8b8e1 and confirmed in earlier passes on this thread; the one still-open item (embedded-newline bypass of the new path-shape regex) belongs to the security lane, not this one.

Branch: fix/2248-permission-grants-and-postures-keying

@kyle-sexton
kyle-sexton merged commit 9b34a82 into main Aug 12, 2026
36 checks passed
@kyle-sexton
kyle-sexton deleted the fix/2248-permission-grants-and-postures-keying branch August 12, 2026 00:52
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…make audit-prompting-postures' contract agree with itself (#2408)

Closes #2281
Closes #2283

> **Scope note.** #2281's eight rows are all taken, with **CC-F11
partial and said so below**.
> #2283 takes four of five — **A8 is declined on its rationale** and is
filed as **#2406** with the
> corrected mechanism, so neither closing keyword drops a reproducing
defect. Two follow-ups filed:
> **#2406** (A8) and **#2415** (`audit-pass`'s missing
`disallowed-tools`, blocked on #2403).

## Summary

Two `claude-config` skills whose contracts contradicted themselves.
Released together as **0.37.0**
(minor: new output on every detector run, new env-var surface). #2382
(0.35.3), #2403 (0.35.4) and
#2396 (0.36.0) all merged while this was in flight; 0.37.0 sits above
all three and the CHANGELOG
order gate is green.

> **Review round: 7 P2 threads, all real, all fixed, replied to
individually and resolved** (verified
> unresolved=0 via GraphQL, not inferred). Four were *this PR's own
defect class turned on this PR*,
> which is itself the finding — so they are named rather than folded in
silently:
>
> | # | Finding | Instance of |
> |---|---|---|
> | 1 | P3 axis missing from `audited` | denominator counting only
successes (2nd) — already fixed at HEAD; thread was outdated |
> | 2, 5 | A candidate `find` can list but the process cannot **read**
is counted nowhere | same shape (3rd) — two reviewers converged
independently |
> | 6 | `audited` counts "produced a finding" on two axes, "examined" on
the third | same shape (4th) |
> | 3 | `disallowed-tools` claimed to make read-only "a property of the
tool set" | **a false assurance claim, mine** |
> | 7 | Phase D "dropped **or** demoted" vs the new schema's "kept for
the record" | contract disagreeing with itself, introduced by its own
fix |
> | 4 | P7 searched rules + hooks but not the **script gate** its own
catalog blesses | same, introduced by its own fix |
>
> After three instances of one shape, the completeness property stopped
being asserted per-site and
> is **derived once**: four exhaustive buckets, reconciled against the
enumeration on every run, with
> a negative test that deletes a bucket increment and asserts the check
fires. The denominator's unit
> is stated once for all three axes — *an input successfully read and
examined, never one that
> produced something* — and printed on every run.

### `audit-permission-grants` — reports a clean bill with no denominator
(#2283)

**A5 — the headline.** `No fragile permission grants found.` printed
identically whether the run
parsed forty `allowed-tools` blocks and found them healthy or parsed
none at all. Every run now ends
with a coverage block, and a run whose denominator is zero prints
`NOTHING TO AUDIT` instead.

**The denominator counts what was *not* read, because one built only
from successes is the same
defect in a new spelling.** Establishing that turned up two fail-open
paths that were not in the
issue, both folded in:

- A settings file present but **not valid JSON** was skipped by a silent
`|| return 0`. Its rules
were never read and the run still printed a clean bill — and an
unparsable rules file is exactly
  where a fragile grant would sit unexamined. Now reported per scope as
  `NOT VALID JSON — its rules were not read`.
- Both `find` walks discarded stderr. This script's own header already
argues against that: *"a
swallowed permission error was indistinguishable from a clean bill."*
Unreadable paths are now
  captured and counted.

The `vendor/` exclusion moved out of the `find` predicate into the loop
so the run can report how
many files it removed. Same predicate, same result set — but an
exclusion whose count is printed
cannot suppress silently. `--count` keeps the bare integer on stdout
(the machine contract) and puts
the block on stderr.

**A11.** `$PERMISSION_HYGIENE_SCAN_ROOT` is now the sanctioned name for
the one scoping lever, with
`$PERMISSION_HYGIENE_FIXTURE_DIR` kept as a back-compatible alias (new
name wins when both are set).
#2249 made that variable the documented operator remedy for the exit-2
refusal while its name still
told them it was a test seam; `reference/criteria.md`, which never
mentioned it at all, now sanctions
it explicitly.

**A15.** Consumer-declared exemptions must disclose themselves, may
**widen** the fragile set but
never delete a finding, and a run where every finding is exempted says
so instead of printing a clean
bill. The audited repo authors those declarations — the threat model the
docs name directly (*"Review
project skills before trusting a repository, since a skill can grant
itself broad tool access"*,
fetched 2026-08-12). The report schema grows an `Exempt?` column to hold
it.

**A16 — the filed remedy declined, with the measurement.** The row says
the four scope filters are
"advertised but not implemented". They *are* implemented, as a
presentation filter, and `SKILL.md`
says so at `:70-71`. The real defect is that the argument hint reads
like a scan-scope. The filed fix
(detector flags) rests on a cost claim that no longer holds: since #2249
the root is a git toplevel,
`$CLAUDE_PROJECT_DIR`, or an explicitly named directory — never an
unbounded sweep — and I measured
the two walks over this repository at **0.49 s** and **0.41 s**. Flags
to skip half a second would
buy nothing and add a second place for scope to be defined. Fixed the
wording instead, adopting the
formulation both sibling audit skills already ship.

**A8 — declined, filed as #2406.** It reasons from the `vendor/`
exclusion's own justification ("not
loadable, so the grant never takes effect") to `node_modules/`,
worktrees and marketplace mirrors.
That step is false. <https://code.claude.com/docs/en/skills>, fetched
2026-08-12:

> Skills also load from nested `.claude/skills/` directories below your
working directory. When
> Claude reads or edits a file in a subdirectory, skills from that
subdirectory's `.claude/skills/`
> become available.

So `node_modules/<pkg>/.claude/skills/<name>/SKILL.md` **is** loadable,
and the exclusion would make
P2 — an `error`-tier check whose whole subject is a leaked username —
silently blind to a live grant.
Same failure shape as the `//` path exemption on #2382. The defensible
half (every exclusion reports
its own count) ships here; the rest needs a loadability model, which
#2406 specifies.

### `audit-prompting-postures` — contract disagrees with itself in eight
places (#2281)

All eight rows taken (CC-F11 partial — see its bullet). Prose-only, so
there is no behavioral test to
write and I am not inventing one — same posture #2403 took on the
sibling skill.

- **CC-F3.** P7 blesses a deny-by-default hook or script gate as
presence evidence "without any
prose", while Phase B inventories instruction *text* — so the one
evidence form P7 names was the
one form Phase B could not see, on the posture whose false MISSING is
most expensive. Split the
two: the inventory bounds what may produce a finding, not what counts as
evidence, and Phase C now
looks for the gate before judging P7 — in all three places the catalog
blesses: settings rules,
hook configuration, and, **after review caught the procedure searching
only the first two**, the
script the component delegates the destructive step to, followed and
read. A component whose
destructive action runs through a gating script is gated and nothing in
its own text says so.
Tightened `destructive-capable` from "**can** delete, reset, force-push"
— which matches every
component with a shell — to what the body has the model DO, per the
classification section's own
  opening line.
- **CC-F5 + CC-F10, written as one edit** because both move the same
seam and landing them
separately would ship a Phase A that disagreed with itself. The
best-practices page is fetched
every run and its failure **aborts** (single non-negotiable input; ten
`wording-unverified`
postures is a report shaped like an audit that audited nothing). Model
subpages are fetched lazily
in Phase C per applicable row and fail locally — which is what the
observed run already did and the
wording forbade. The verdict schema was closed at three tokens while the
body mandated two more; it
now carries four verdicts including `info`, with `wording-unverified` /
`(unverified)` named as
  markers that ride alongside a verdict rather than replacing one.
- **CC-F6.** The surface set is named in this skill instead of inherited
by reference from a sibling
that versions independently — the coupling that let `output-styles`
become inventoried here and
unnameable by this skill's own filter. `output-styles` is now a scope
token.
- **CC-F7.** P8 carries the model condition the skill's own gotcha
mandates. **Leg the issue marked
unverified, re-fetched by me 2026-08-12:** the pointed-at section scopes
context awareness to
"Claude Sonnet 5, Claude Sonnet 4.6, Claude Sonnet 4.5, and Claude Haiku
4.5".
- **CC-F4 — and the false assurance claim review caught in it.**
`disallowed-tools: Edit,
NotebookEdit` is declared, but an earlier draft of this PR claimed it
made "never edits a
component" *a property of the tool set*. **That was false and is removed
rather than softened.**
`Write` is retained for the mandated persist and Phase B has already
read every audited component,
so it can overwrite one; `Bash` is retained for the state key, and a
shell mutates files too. The
declaration narrows the accident surface, it does not enforce the
contract — both skills now say
so, and both forbid telling an operator the skill *cannot* edit their
files. A skill whose subject
is auditing assurance must not ship a false assurance claim about
itself; the CHANGELOG records
that the earlier claim was wrong instead of quietly shipping the
corrected text. **Second
unverified leg, re-fetched 2026-08-12** — the frontmatter reference's
semantics, including *"The
restriction clears when you send your next message"*, which is the right
lifetime: whoever accepts
  a proposal can apply it.
- **CC-F8 — the issue's stated mechanism is wrong at HEAD, and I fixed
the real one.** The issue
says `grep -c "audit-prompting-postures"
.../audit-instructions/SKILL.md` → **0**, "the token
appears nowhere in the sibling". At HEAD it returns **1**, at
`audit-instructions/SKILL.md:392`, in
a state-key aside. The grep claim is false; the substantive claim
survives, because a mention in an
aside is not a route-out — `audit-instructions`' Scope boundary section
still never named this
skill. Added that route-out line. Also added two evals: one whose prompt
carries **no slash
invocation**, so description-driven selection is exercised for the first
time (all five existing
cases invoke explicitly), and one pinning CC-F3's mechanical-gate rule.
The
description-drives-discovery claim was itself an unverified leg —
**third one re-fetched
  2026-08-12** (`skills.md:259`, `:425-426`).
- **CC-F11 — partial, and the partial goes one way I should name.** The
uninstall half is fixed: the
state key stops overwrites, not reaping, with the sentence quoted and
`--keep-data` named
(re-fetched 2026-08-12). The row's other two observations are
**recorded, not fixed** — and one
moved the wrong way. `when_to_use` is still unused, and the description
grew from **1,290 to
1,305** of its 1,536-char cap to carry `output-styles` for CC-F6, which
is the opposite direction
from the row's headroom note. That trade is deliberate (a scope token
that is unnameable is the
actual defect; 231 chars of headroom is not), but "all eight rows taken"
would have been the wrong
  sentence to leave standing.

CC-F9 is correctly **not** touched: the issue records it as falsified,
and it is.

### One divergence this PR would otherwise have created

CC-F4 declares `disallowed-tools: Edit, NotebookEdit` on
`audit-prompting-postures`.
`audit-instructions` states the **identical** report-only contract
("There is no `--fix`… never by
this skill") and names neither `Edit` nor `Write` anywhere in its body —
so declaring it on one of
the pair and not the other would have opened a fresh instance of exactly
the sibling divergence
CC-F6 is about, in the release that fixes CC-F6. It is declared on both,
in the file this PR was
already editing. `audit-pass` states the contract too and is **not**
touched: PR #2403 owns that
file right now, so it is filed as **#2415** rather than collided with.

## Test plan

**Fail-before / pass-after for A5.** Three roots that `origin/main`
(`5ea4f87f`) describes with one
identical string, run against the old and new detectors:

```
==================== BEFORE — origin/main (5ea4f87) ====================
--- root with 0 grants ---
No fragile permission grants found.
--- root with 2 healthy grants ---
No fragile permission grants found.
--- root whose only rules file is invalid JSON ---
No fragile permission grants found.
--- --count ---
  empty=0   healthy=0   badjson=0

==================== AFTER — this branch ====================
--- root with 0 grants ---
NOTHING TO AUDIT: 0 allowed-tools block(s) and 0 allow rule(s) were read under this root, so this
run has no denominator. That is a scan of nothing, not a clean bill — do not report it as one.

Scan coverage (the denominator — what this run actually read):
  root: /tmp/tmp.EbQtjPHKzq/empty-root (resolved from $PERMISSION_HYGIENE_FIXTURE_DIR (alias of $PERMISSION_HYGIENE_SCAN_ROOT))
  frontmatter: 0 allowed-tools block(s) parsed from 0 candidate file(s); 0 excluded under a vendor/ path segment as non-loadable
  settings: 0 allow rule(s) from 0 scope(s) read — project: absent; local: absent; user-global (...): absent
  plugins: 0 manifest(s); 0 settings.json parsed
  NOT read: 0 path(s) the walk could not open; 0 settings file(s) and 0 plugin settings.json present but not valid JSON
  never in scope here: managed-policy and enterprise settings, a --settings flag file, and the pre-v2.1.211 start-directory copy...

--- root with 2 healthy grants ---
No fragile permission grants found.

Scan coverage (the denominator — what this run actually read):
  ...
  settings: 2 allow rule(s) from 1 scope(s) read — project: 2 rule(s); local: absent; user-global (...): absent
  ...

--- root whose only rules file is invalid JSON ---
NOTHING TO AUDIT: 0 allowed-tools block(s) and 0 allow rule(s) were read under this root...

Scan coverage (the denominator — what this run actually read):
  ...
  settings: 0 allow rule(s) from 0 scope(s) read — project: NOT VALID JSON — its rules were not read; local: absent; ...
  NOT read: 0 path(s) the walk could not open; 1 settings file(s) and 0 plugin settings.json present but not valid JSON
  ...
--- --count (stdout only) ---
  empty=0   healthy=0
```

The `--count` line is the point restated: stdout is still `0` in both
cases (the machine contract is
unchanged), and stderr now separates them.

**The denominator got the same defect wrong four times, and the fourth
is why it is now structural.**
Recorded in full because the pattern is more useful than any one
instance:

1. `audited` omitted the P3 axis, so a root of clean plugin
`settings.json` printed `NOTHING TO
AUDIT` two lines above `plugins: 2 manifest(s); 2 settings.json parsed`.
Caught in self-review.
2. A candidate `find` can *list* but the process cannot *read* — `find`
needs only directory
traversal to report `-type f` — reached `awk`, failed, wrote to the real
stderr rather than
`WALK_ERR`, and was counted in no bucket, while the coverage block
promised to disclose exactly
   that input. Caught by two reviewers independently.
3. `audited` counted "produced a finding" on the frontmatter and
settings axes but "examined
successfully" on P3's, so a `SKILL.md` with no `allowed-tools` and a
`settings.json` with an empty
`allow` array contributed nothing despite being read and found to grant
nothing.

Three instances means the invariant was maintained *by convention at
each `continue`*, so it is now
derived once. Every enumerated candidate lands in exactly one of four
buckets — vendor-excluded,
unreadable, no `allowed-tools` block, parsed — and
`reconcile_frontmatter` checks they sum to the
enumeration on every run, printing `DENOMINATOR BUG` and naming
**itself** as the defect when they do
not. A check that cannot fail is not a check, so a negative test deletes
a bucket increment from a
copy of the script and asserts the reconciliation fires. And the unit is
stated once for all three
axes — *an input successfully read and examined, never one that produced
something* — and printed:

```
  DENOMINATOR = 3 input(s) successfully examined: 2 frontmatter file(s) + 1 settings scope(s) + 0
  plugin settings.json. The unit on every axis is "read and examined", never "produced a finding".
  reconciled: 3 candidate(s) = 1 vendor-excluded + 0 unreadable + 1 without an allowed-tools block + 1 parsed
```

Extraction stderr now joins the walk's rather than escaping to the
terminal, and a run that audited
nothing **and** could not open its own inputs says so distinctly instead
of reporting an empty tree.

**Suite: 76 → 102 on this branch's own base, then 121 after the review
round, all passing.**
26 of those are this branch's, across the denominator (including the P3
axis), the
unparsable-settings skip, the `--count` stdout/stderr split, the
exclusion disclosure, and the
scan-root rename; the rest are #2382's, which landed mid-flight.

**Merge note — and the trap it walked into.** #2382 (0.35.3), #2403
(0.35.4) and #2396 (0.36.0) all
merged into `main` while this was in flight, twice leaving the PR
`CONFLICTING`. **A conflicted merge
ref suppresses the `pull_request`-triggered runs entirely**, and the PR
then displayed **3 checks,
all passing** instead of 33 — nothing distinguishes that from a real
green except counting the rows.
Caught by comparing against #2382's 34, not by reading the failure
count.

Resolved with merge commits rather than repeated rebases, and verified
marker-free across the whole
tree before each commit:

- `plugin.json` — **0.37.0**, because #2396 took 0.36.0 (the version
this PR originally claimed) and
also rewrote the description to "Nine configuration-health skills".
Theirs kept, version raised.
- `CHANGELOG.md` — 0.37.0 / 0.36.0 / 0.35.4 / 0.35.3 / 0.35.2;
`--check-order` green.
- `permission-rule-check.test.sh` — both sides pure additions, both
kept.
- `permission-rule-check.sh` and `criteria.md` auto-merged with **no**
conflict: #2382's hunks are
the P2 pattern block and P2's criteria section, neither of which this PR
touches.
- #2396 also changed `lib/permission-patterns.sh`, which this detector
sources — so the suite was
re-run against the merged library, not just against this branch's own
base. Still passing.

```
$ bash plugins/claude-config/skills/audit-permission-grants/scripts/permission-rule-check.test.sh
PASS: empty root reports NOTHING TO AUDIT
PASS: empty root does NOT print a clean bill
PASS: empty root still prints the coverage block
PASS: empty root denominator names zero blocks
PASS: healthy root prints the clean bill
PASS: healthy root is not NOTHING TO AUDIT
PASS: clean bill carries a non-zero rule count
PASS: a clean P3-only root is a clean bill
PASS: a clean P3-only root is NOT a scan of nothing
PASS: coverage counts the plugin settings it parsed
PASS: coverage names the project scope it read
PASS: coverage names an absent scope as absent
PASS: unparsable settings file is named, not skipped in silence
PASS: unparsable file is counted under NOT read
PASS: a run whose only rules file will not parse is not a clean bill
PASS: --count stdout is still the bare integer
PASS: --count writes the coverage block to stderr
PASS: --count coverage carries the denominator
PASS: vendor exclusion discloses its count
PASS: coverage names the candidate file total
PASS: PERMISSION_HYGIENE_SCAN_ROOT resolves a root
PASS: coverage names the rung that resolved the root
PASS: the legacy alias still resolves a root
PASS: the sanctioned name wins over the alias
PASS: the alias did not win
PASS: refusal names the sanctioned variable as the fix
...
All 121 checks passed.
```

**The A16 measurement, since it is what declines the row:**

```
$ time (find . -type f \( -name 'SKILL.md' -o \( -name '*.md' -path '*/agents/*' \) \
        -o \( -name '*.md' -path '*/commands/*' \) \) ! -path '*/vendor/*' | sort -u | wc -l)
214
real    0m0.491s
$ time (find . -type f -path '*/.claude-plugin/plugin.json' | wc -l)
65
real    0m0.412s
```

**Repo gates:**

```
$ CHECK_SKILL_SKILLS_ROOT=plugins/claude-config/skills bash plugins/skill-quality/scripts/check-skill.sh audit-prompting-postures
INFO: description length 1305/1536 chars
INFO: all 6 base-ref trigger phrase(s) preserved
INFO: SKILL.md 200/500 lines
INFO: markdownlint clean
CHECK-SKILL audit-prompting-postures: PASS — 0 errors, 0 warning(s)

$ ... check-skill.sh audit-permission-grants
INFO: script test passed: scripts/permission-rule-check.test.sh
CHECK-SKILL audit-permission-grants: PASS — 0 errors, 1 warning(s)   # no-Gotchas warning is pre-existing

$ npx markdownlint-cli2 <the 6 changed markdown files>
Summary: 0 issues in 0 files

$ shellcheck -S warning .../permission-rule-check.sh .../permission-rule-check.test.sh
(clean)

$ bash scripts/check-shell-portability.sh <merge-base>
No unexcused GNU-only constructs in 2 shell file(s).
$ bash scripts/check-skill-portability.sh <merge-base>
No unexcused coupling tokens in 6 skill file(s).
$ bash scripts/check-changelog-parity.sh --check-order
All 76 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.
$ bash scripts/validate-plugins.sh
All plugin manifests and the catalog validated.
$ bash scripts/check-silent-skips.sh
No silent prerequisite skips found in hook entry scripts.
$ bash plugins/skill-quality/scripts/check-evals-quality.sh .../audit-prompting-postures/evals/evals.json
check-evals-quality: PASS (0 warning(s) across 1 file(s))
$ npx ajv-cli@5 validate --spec=draft2020 -s plugins/skill-quality/reference/evals.schema.json -d .../evals.json
.../evals.json valid
```

`check-listing-budget` was run and reports the aggregate **already**
over budget on `main`
(97537/8000) — advisory-only, and this PR's contribution is **+15
chars** (description 1290 → 1305
for `output-styles`). Not introduced here and not resolvable here.

## Related

- **#2281** — closed here; all eight rows (CC-F3 … CC-F11) taken. CC-F9
deliberately untouched, as
  the issue's own "Not in this issue, on purpose" section requires.
- **#2283** — closed here for A5, A11, A15, A16. **A8 split to #2406
before merge** so the auto-close
  drops nothing.
- **#2406** — the A8 follow-up, with the falsified rationale and what a
real loadability model must
  distinguish.
- **#2382** (0.35.3), **#2403** (0.35.4) and **#2396** (0.36.0) — all
merged during this PR's life;
see the merge note in the test plan. #2396 took 0.36.0, so this PR is
**0.37.0**.
- **#2249** (closed) — removed the `$PWD` fallback and added the exit-2
refusal. This is the residue
  it named: a *resolved* root with nothing in it still reported clean.
- **#2250** (closed) — keyed the report path per project; CC-F11 is the
residual amplifier
  (uninstall still reaps the directory).
- **#2259** — added the two evals and 67 lines to
`audit-prompting-postures/SKILL.md`; every anchor
  in #2281 past `:78` was re-derived at this HEAD before editing.

Inbox items:
`20260811-021645-plugin-audit-four-components-and-guard-deadlock-ownership`
(#2281),

`20260811-024628-claude-config-audit-permission-grants-defects-and-fleet-grant-hygiene`
(#2283).
Ledgers:
`.work/handoff-inbox-batch-4/ledgers/I9-021645-four-components.md` §
Lane A;
`.work/handoff-inbox-batch-4/ledgers/I10-permission-grants-fleet.md` §
A5, A11, A15, A16.

---------

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

1 participant