fix(claude-config,claude-memory): gate audit-pass on report containment not the --report-to flag, refuse non-git targets, and audit user-global memory surfaces - #2234
Conversation
…a flag, refuse non-git targets, and let claude-memory see user-global surfaces
audit-pass said its report goes under ${CLAUDE_PLUGIN_DATA}, "which resolves
outside any target repository". That is false by construction: the directory
resolves to ~/.claude/plugins/data/{id}/ and no documented setting relocates it,
so it is inside any target at or above ~. True for a repo under C:/Projects/,
stated as a universal, and a dotfiles repo falsifies it.
Worse, the machinery that exists for exactly that situation was gated on the
--report-to flag rather than on the condition the flag happens to create. Name-
based refusal, the pre-write exclusion entry, the operator disclosure and the
digest exclusion were all written under that bullet, so a run against a target
at or above ~ wrote into its own scan set with no exclusion and no disclosure,
then reported indeterminate about itself every time. The governing condition is
now report_path ⊆ target_root, evaluated on every run, with --report-to demoted
to one instance. Class 4 is stated as that predicate rather than a two-item list
so a later member inherits the rule instead of needing someone to extend a list.
Assertions 2.1-2.4 are re-scoped on containment — 2.1 was falsified by the same
class as 2.4, not just 2.4 — and 2.6 adds the default-path twin of the redirect
disclosure.
Separately, SKILL.md called target "the git repository to audit" while the gate
required only "resolves to the active project root", so a non-git directory
passed into a contract with no branch for it: no no-git state-key fallback, a
scan baseline defined as HEAD plus digest, a Class 3 exclusion over git worktree
list with no fallback, an assertion stated over git status --porcelain, and —
the one that is a capability loss rather than a missing derivation — suppression
enacted only by the tracked layer, so no acceptance would ever persist. It is
refused, using the existing non-zero channel rather than a new exit code, and
the refusal states that cost. The operator settled this fork toward refuse;
closing the class is deliberate.
These are independent: refusing non-git targets does not retire the containment
work, because the class that falsifies the containment claim is a git repository
rooted at or above $HOME.
evals 1 and 20 graded the false premise as a pass condition, so a corrected run
would have failed the suite. Both rewritten onto the predicate; eval 30 grades
the refusal. All 29 shipped cases were read; no others carry the premise.
claude-memory's audit could not see the surfaces claude-config hands it. Step 1
discovery was two finds rooted at the cwd, while audit-instructions' surface
partition delegates ~/.claude/rules/ here by name — so ~/.claude/CLAUDE.md, which
loads in every session of every project, was audited by nothing. Discovery moves
into a script so it can do the half inline find cannot: tag every file with its
scope. Without that, widening would fire project-scoped C9 on ~/.claude/CLAUDE.md
and FAIL it for not stating a repo's build commands — under-coverage traded for a
false positive. Step 2 routes on the tag, R-checks apply at both scopes, and
Step 3 gains a cross-scope conflict pass since both layers load together.
Recorded because the filed report argued from the wrong line: claude-memory's
criteria.md:224 excludes personal files from C9 as not repo-scoped, which cuts
against the argument. The load-bearing seam is audit-instructions' criteria.md:96.
Closes #2228
Closes #2229
Closes #2230
Closes #2231
Closes #2232
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
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 finished @kyle-sexton's task in 1m 49s —— View job Security Review — PR #2234
FindingsNo security issues found. Scope reviewed: all 15 files changed in this PR (2 new shell scripts, 5 Notes from the review (informational, not findings):
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 643ec2272c
ℹ️ 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".
|
Claude finished @kyle-sexton's task in 5m 15s —— View job Review complete
SummaryThis PR is prose-and-spec-as-code (SKILL.md / reference docs / evals.json) plus one new bash script. I read all 15 changed files against Independently verified (not just author-claimed):
Could not execute: the sandbox in this environment declined to run (even Finding posted inline ( Everything else held up under review:
|
|
Claude has reviewed this PR 4 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…respect path-scoped rules, and name the directory a non-git refusal refused Three review findings, all real. The dotfiles case eats its own tail. When the project root IS the home directory — the same target shape this PR's audit-pass half calls ordinary — `.claude/rules` relative to the cwd and ~/.claude/rules are the SAME directory, so discovery emitted every rule twice under two path spellings: a duplicate finding per rule, and a Step 3 comparison of a file against itself. The two roots are now compared canonically and, where they coincide, each file is emitted once with scope `both`. It satisfies either --scope filter, because the file really is reachable by each layer. CLAUDE.md cannot collide this way — project discovery is depth-1 at the cwd while the user copy sits inside the config root — and a case pins that. Path-scoped rules are not always-loaded. A user rule carrying `paths:` is absent until a matching file is read, so "a user rule loads every session" over-claimed and would have manufactured repo-relative currency, redundancy and contradiction findings for rules that cannot apply here. Step 2 and the Step 3 comparison now say to establish co-residency from the rule's own frontmatter first. And the non-git refusal could not name what it refused in the case it exists for. With no explicit target and no CLAUDE_PROJECT_DIR the documented default is git rev-parse --show-toplevel, which fails outside a repository — so there was no resolved root to put in the message. The diagnostic falls back to the current directory. That fallback is for the message only and never becomes a target. Suite grows 23 -> 32 checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 53s —— View job Security Review — PR #2234 (re-run at
|
|
Claude finished @kyle-sexton's task in 5m 25s —— View job Code Review — PR #2234
Scope notePer New findings (posted inline)
Verified independently (ran myself, not just author-claimed)
Could not executeSame sandbox restriction the earlier security-lane pass hit: any Everything elseThe containment re-gating ( |
…md R1 compares against My own comment asserted CLAUDE.md could not collide across scopes, and it was wrong. It covered only the repo-rooted-at-~ layout, where the depth-1 CLAUDE.md and <config_root>/CLAUDE.md really are distinct. In the other common dotfiles layout — ~/.claude itself tracked as the repo — they are the same physical file, and the rules-only overlap guard missed it, so that file was emitted twice. Both roots are now compared canonically, files as well as directories, via a canon_file that resolves the containing dir and re-appends the basename rather than depending on readlink -f. Six cases pin the layout, including that a normal distinct-root project never regresses into a both-tagged CLAUDE.md. Separately, R1 asks whether a rule duplicates "CLAUDE.md" — unambiguous while only one could ever be in scope, ambiguous the moment this PR put two there. R1 now pairs within a scope: a user rule against the user CLAUDE.md, a project rule against the project one, because R1 is a redundancy the owner of that layer fixes by deleting one of the two and only a same-scope pair is theirs to fix. Cross-scope overlap stays with Step 3, which reports it against the pair and names each side; routing it through R1 as well would report one overlap twice and address it to the wrong person. Suite 32 -> 38 checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 8s —— View job Security Review — PR #2234 (re-run at
|
|
Claude finished @kyle-sexton's task in 4m 17s —— View job Code Review — PR #2234 (re-run at
|
…a both-scoped rule an R1 partner Two comment/doc errors, both mine, both caught by review reading the code against the prose rather than trusting it. The header claimed a repo rooted at ~/.claude collides CLAUDE.md "and so are the two rules dirs". It does not: project rules there resolve to <config_root>/.claude/rules, which is not <config_root>/rules. My own fixture already agreed with the code and not the comment. The truth is a clean asymmetry — each layout collides exactly ONE surface. A repo rooted at ~ collides the rules dir and leaves the two CLAUDE.md files distinct; a repo rooted at ~/.claude collides CLAUDE.md and leaves the rules dirs distinct. That is precisely why the two comparisons are computed independently rather than from one flag, and it is now stated that way and pinned in both directions instead of being asserted in a comment. The asymmetry also broke R1's new pairing rule. "A both-scoped rule against whichever CLAUDE.md shares its scope" has no target: both-scoped RULES arise only in the ~-rooted layout, and that is exactly the layout where no both-scoped CLAUDE.md exists. Such a rule now compares against every CLAUDE.md in scope, with each finding attributed to the scope of the one it overlapped — not double-reporting, because the rule really is loaded alongside both. Suite 38 -> 44 checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 6s —— View job Code Review — PR #2234 (re-run at
|
|
Claude finished @kyle-sexton's task in 1m 19s —— View job Security Review — PR #2234 (re-run at
|
…an unresolvable scan root, and key the postures report per project (#2259) ## 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:278` — **is** 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-F2`–`CC-F11`. Filing for those is held pending the operator's call on batch-4 volume (`OR-4`). --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Batch-4 filing note — three follow-ons, and one collision warning. #2280 files what this PR left standing in #2279 files the degradation posture: Phase 5's apply-verify names a fresh-context subagent as "the stated fallback" with no fallback of its own, and the report schema's section table ( Collision warning for |

Summary
Five findings across two plugins. Three are about
audit-pass's report path, one is about the targetclass it admits, one is about a surface
claude-memory:auditcould not reach. They are independent —see "what does not subsume what" below.
The false premise, and the machinery gated on the wrong thing (#2229, #2230)
reference/report-location-and-schema.md:13said the report goes under${CLAUDE_PLUGIN_DATA}, "whichresolves outside any target repository". False by construction: that directory resolves to
~/.claude/plugins/data/{id}/(plugins reference,verified 2026-08-11) and no documented setting relocates it. The sentence is true for an ordinary repo
under
C:/Projects/…, and it was stated as a universal while a whole reachable target class — anythingat or above
~, a dotfiles repo being the obvious one — falsifies it.The consequence was not cosmetic. Every obligation that exists for a report landing inside the target
was written under the
--report-tobullet: name-based refusal, the pre-write exclusion entry, theoperator disclosure, the digest exclusion, assertion 2.4. The flag is one way the condition arises; it
was never the definition. So a run against a target at or above
~wrote its report into its own scanset with no exclusion entry and no disclosure, and then reported
indeterminateon its owndeterminism gate — every time, purely for doing what it was asked.
Both are re-gated on the predicate
report_path ⊆ target_root, evaluated against the resolved pathon every run:
exclusion-set.mdClass 4 is stated as that predicate rather than as a two-item list, so a memberadded later inherits the rule instead of needing someone to remember to extend a list.
report-location-and-schema.md§2,SKILL.md's argument list,SKILL.md's "never writes into its ownscan set without
--report-to" absolute, anddeterminism-tiers.md's digest exclusion all follow.git status --porcelainis empty") wasfalsified by the same class as 2.4, not just 2.4 — that is the folded
F14residual. 2.6 added asthe default-path twin of the redirect disclosure, so a contained write is never silent.
README.md:184carried the same false claim and is corrected (found by sweeping the plugin for thestring, not by the ledger).
The target class the contract cannot describe (#2231)
SKILL.md:42callstarget"the git repository to audit"; the gate at:46required only that it"resolve to the active project root". A non-git directory satisfied the second and not the first, and
nothing refused it — after which the contract went quiet in five places: no no-git state-key fallback
(only a no-remote one), a scan baseline defined as HEAD plus the state digest, a Class 3 exclusion
derived from
git worktree listwith no fallback (Class 1 has one), assertion 2.1 stated overgit status --porcelain, and — the one that is a permanent capability loss rather than a missingderivation — suppression enacted only by the team layer, which is the tracked layer. An operator could
accept a finding on such a target and have the acceptance silently fail to persist, forever.
The gate now enforces both halves. This closes a target class deliberately, and the issue records
that the operator settled the refuse-vs-specify fork toward refuse — the alternative obliges the contract
to promise a capability it cannot deliver there. The refusal reuses the existing channel shape
(non-zero, before Phase 0, naming the resolved path and the reason) rather than minting an exit code, and
it states the cost out loud, naming the suppression consequence, so it does not read as arbitrary.
Verified before writing: there is no existing non-git refusal in
audit-passto narrow —grepforexit 4,non-git,not a gitreturns nothing. The exit-4 non-git diagnostic that exists in this repois
source-control'sscripts/worktree-create.sh:258-261, a different plugin and a different finding.This is net-new machinery.
What does not subsume what
Refusing non-git targets does not retire the containment work, and the changelog says so explicitly.
The class that falsifies the containment claim is a git repository rooted at or above
$HOME.report_path ⊆ target_rootholds there by construction, independent of gitignore, and that target shapeis entirely ordinary.
The eval suite was grading the defect (#2228)
This row had to land in the same change, not after it. Eval 1 expectation 3 graded "Writes the report
under
CLAUDE_PLUGIN_DATArather than into the scan set", and eval 20 graded the digest exclusion asa
--report-toproperty. Both encode the premise the other fixes remove, so the suite passed adefect-exhibiting run and would have failed the corrected one.
The discriminator is not the report location — it is the pre-write exclusion record plus the
disclosure, which the old contract performs on neither the default path nor at all. Rewritten so:
bare-invocation-is-read-onlydirty-file-edited-without-changing-the-count--report-to, so a contained default path is excluded toonon-git-target-is-refused-not-half-specified(new)All 29 shipped cases were read at HEAD to discharge the packet's own "only eval 1 was audited" blindspot.
No others carry the premise.
The surface neither delegate audited (#2232,
claude-memory)claude-memory:auditStep 1 discovery was twofindcommands rooted at the cwd, so it saw project scopeonly. Meanwhile
claude-config'saudit-instructionspartitions memory-layer hygiene to this skill andnames
~/.claude/rules/in the handoff explicitly(
audit-instructions/reference/criteria.md:96). One skill delegated a user-global surface by name; thereceiving skill's discovery could not reach it.
~/.claude/CLAUDE.mdloads in every session of everyproject and was audited by nothing — and under-coverage reads as a clean report.
Corrected rationale, recorded so it is not shipped wrong. The filed report argued from
claude-memory'sreference/criteria.md:224, the C9 carve-out. Read in context, that line excludespersonal files from C9 as "not repo-scoped", which cuts against the argument. The seam above is the
load-bearing mechanism and needs no interpretation. (Two different files are named
criteria.mdhere, intwo different plugins.)
Discovery becomes a script because the fix has a second half inline
findcannot carry: every file istagged with the scope it loads from. Widening without that would fire project-scoped C9 on
~/.claude/CLAUDE.mdand FAIL it for not stating a repo's build and test commands — tradingunder-coverage for a manufactured false positive. Step 2 now routes on the tag and carves C9 out for user
scope; R-checks apply at both scopes, because a user rule loads every session in every project; Step 3
gains a cross-scope conflict pass, since both layers load together and the report must say which scope
each side came from (only one is yours to edit on behalf of the repo). The config-root resolution reuses
what
resolve-memory-dir.shalready carries rather than re-deriving it.What review added
Eight threads across three rounds, all resolved, six follow-up commits. Four were defects in my own
work and are worth naming, because three of them were the same shape — a claim in a comment that the
code did not implement:
~,.claude/rulesand~/.claude/rulesare one directory, so every rule was emitted twice under two path spellings — aduplicate finding each, and a Step 3 comparison of a file against itself. Landing on the exact target
shape this PR's other half calls "entirely ordinary". Fixed with canonical comparison and a third
scope value,
both, emitted once and satisfying either--scopefilter.CLAUDE.mdcould not collide, and used that to justify not guarding it. Itcan:
~/.claudetracked as the repo makes the depth-1CLAUDE.mdand<config_root>/CLAUDE.mdonefile. Guarded, with a
canon_filethat resolves the containing directory and re-appends the basenamerather than depending on
readlink -f.rules dirs too. It does not — project rules there resolve to
<config_root>/.claude/rules, not<config_root>/rules— and my own fixture already agreed with the code rather than the comment. Thetruth is a clean asymmetry now stated as such: each layout collides exactly one surface, which is
why the two comparisons are computed independently rather than from one flag. Pinned in both
directions, including a layout-2 fixture carrying both a
rules/and a.claude/rules/.both-scoped rule against whicheverCLAUDE.mdshares its scope" pointed at nothing:both-scoped rules arise only in the~-rootedlayout, which is exactly where the two
CLAUDE.mdfiles stay distinct. Such a rule now comparesagainst every
CLAUDE.mdin scope, attributing each finding to the scope of the one it overlapped.Two more, both genuine over-claims in the prose:
paths:-scoped rule — it is absent until amatching file is read, the same distinction
orphan-rule-check.shalready draws. Left unfixed itwould have manufactured repo-relative currency, redundancy and contradiction findings for rules that
cannot apply here. Step 2 and Step 3 now require co-residency to be established from the rule's own
frontmatter.
with no
${CLAUDE_PROJECT_DIR}, the documented default isgit rev-parse --show-toplevel, whichfails outside a repository, so there was no resolved root for the message. It falls back to the current
directory for the diagnostic only — never as a target — and eval 30 grades that.
And R1 became ambiguous the moment this PR put two
CLAUDE.mdfiles in scope, which was a hole thisPR opened, so
reference/criteria.mdR1 is updated rather than only the workflow: R1 pairs within ascope, and cross-scope overlap stays with Step 3, which owns it and names each side.
Trust surface
No new grant, no new hook, no new network read. One new local script, read-only, advisory (always exits
0), which reads
${CLAUDE_CONFIG_DIR:-$HOME/.claude}— a path this plugin'sresolve-memory-dir.shalready reads. Its test suite pins
CLAUDE_CONFIG_DIRto a fixture in every case, so running the testsnever reads or reports on the real
~/.claude. Theaudit-passchanges narrow what the skill acceptsand add disclosure; nothing widens.
Test plan
Real fail-before / pass-after for
F6. The new test's user-scope assertions fail against the shippedinline discovery and pass against the script. Demonstrated against the same fixture:
The suite — 44 checks, including the Git Bash drive-letter case and both dotfiles layouts:
Static gates on the rest. The
audit-passhalf is contract prose plusevals.jsonand has noexecutable test; these are the honest gates, run on the final tree:
Both
audit-passwarnings pre-date this change: thefresh-eyes-exemptdirective at what is now:340is unmodified from
origin/main(it sat at:307before this diff shifted it), and the line-countwarning was already firing at 353 lines.
Related
Closes #2228
Closes #2229
Closes #2230
Closes #2231
Closes #2232
Inbox item:
20260811-020411-claude-config-audit-pass-report-path-inside-scan-set.md(batch-4 ledgerI8, rowsF16,F1,F2,F4,F6; rowF14folded intoF2, no separate issue).F4was held inthe batch decision queue as
DQ-1and released by the operator toward the refuse branch.Two bumps, because two plugins changed:
claude-config0.30.0 → 0.31.0 (refusal and re-gatedmachinery are behavior),
claude-memory0.7.1 → 0.8.0 (two surfaces newly in scope). Not shared.I8rows left filed-not-fixed:F3(no scripts, MED),F5(lease, MED/SELF_REPORTED),F7(I6 ownsit — same defect, do not file twice),
F8/F9/F10/F13(DOC_ONLY),F11(I6-adjacent),F12,G1andS1(routed to item20260811-021645, other plugins),H1(UPSTREAM). Issue filing for thoseis held pending the operator's call on batch-4 volume (RECONCILE OR-4).