Skip to content

feat(claude-config,claude-memory): key plugin-data reports per project and contract the rule - #2335

Merged
kyle-sexton merged 2 commits into
mainfrom
feat/2276-plugin-data-report-keying
Aug 12, 2026
Merged

feat(claude-config,claude-memory): key plugin-data reports per project and contract the rule#2335
kyle-sexton merged 2 commits into
mainfrom
feat/2276-plugin-data-report-keying

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Three writers under ${CLAUDE_PLUGIN_DATA} shared one file per machine, and docs/conventions/ had no rule to point at.

The harness fact, verbatimplugins reference, § Persistent data directory, re-fetched as raw markdown 2026-08-12: "The ${CLAUDE_PLUGIN_DATA} directory resolves to ~/.claude/plugins/data/{id}/, where {id} is the plugin identifier with characters outside a-z, A-Z, 0-9, _, and - replaced by -." Keyed to the plugin identifier and nothing else — no project, checkout, worktree, or session segment. A fixed filename there is one file per machine.

#2277claude-memory:audit is the sharp one, because it reads the file back

report mode served whatever audit/last-audit.md currently held and fix mode acted on it. On a machine with two repositories, report in project B could present project A's findings as project B's, and fix could propose edits derived from another repository's memory layer. A wrong answer served, not merely a lost artifact — which is why an append-only history would not close it: serving the newest report is not serving this project's.

All four sites now resolve one keyed path, audit/<state-key>/last-audit.md: the write in context/audit.md, its restatement in reference/criteria.md, and the two reads in SKILL.md and context/fix.md.

#2276claude-config:audit-instructions printed a wrong number, not just a lost file

Its Phase D header must carry a per-surface token delta "versus the previous catalog version". Under collision that prior file exists but belongs to a different project's surface set, so the skill computed and printed a figure instead of declining. A silently wrong number in a report header is the worse half of this defect. The path is now keyed, and the two absent-prior cases are separated: no report at this project's key → omit the delta with a stated reason; an unkeyed leftover → name it to the operator, never use it as a baseline.

It was the last writer in this plugin on a fixed path. audit-pass has keyed since it shipped; #2250 moved audit-prompting-postures.

The scheme is audit-pass's — but the third adopter would have been the third copy

audit-pass specifies <repo-identity>/<worktree-discriminator> in prose; #2250 copied a ~40-line shell block into audit-prompting-postures. Rather than paste it twice more, it ships as lib/state-key.sh with a 23-case suite — byte-identical across both plugins and registered in scripts/cross-plugin-source-registry.txt, following the existing lib/managed-scope.sh precedent, so the copies cannot drift silently.

The suite pins what the prose asserted and nothing checked, including the security property: a remote URL becomes directory components in the resulting path, so ../../../etc, an absolute local path, and a Windows path are hashed rather than embedded, and the suite asserts no .. and no backslash survives into a key.

Two decisions worth stating plainly

  1. Legacy unkeyed reports are neither served nor migrated. claude-memory previously moved the pre-rename health/ directory to audit/ and read it. Both older layouts carry no project segment, so nothing records which repository produced them; adopting one into a project's key invents the attribution keying exists to remove. Both read paths now decline, name the leftover file's path as something the operator may delete, and offer a fresh audit. This is a behavior change on upgrade — an operator holding a report under the old layout is told to re-run rather than shown the old one. It is why claude-memory takes a minor bump.
  2. The derivation lives in SKILL.md, and the spokes point at it. SKILL.md is unambiguously skill content, doc-confirmed for placeholder substitution. Whether substitution reaches bundled context/*.md and reference/*.md loaded on demand is plugin-quality:audit asserts ${CLAUDE_PLUGIN_DATA} does not substitute in skill markdown; plugins-reference now says it does #1568's open question, and this fix deliberately does not depend on the answer: SKILL.md derives the path once, the three spokes refer to it by section name and restate no token of their own.

#2278 — the convention

docs/conventions/plugin-data-report-keying/ (README + CHANGELOG, registered in docs/PLUGIN-PHILOSOPHY.md's registry — one row added, nothing reformatted). It separates the two failure modes that need different fixes:

Collision Overwrite
Symptom Project B served project A's content Yesterday's artifact gone
Fixed by Keying Retention

and states that a non-destructive history closes only the second. Rule 3 — never serve or derive from an artifact you cannot attribute — is the one that governs migration.

RKD-06 is carried as the convention's worked example, not filed as a defect. bug-report:write keys on the kebab-cased basename of the project root; the line already states the hazard and then picks a colliding key (two same-named checkouts share one directory). The originating item filed it explicitly as "context, not a defect to fix", and that is honored: it appears in rule 1c and in the adoption table, and no bug-report file is touched.

The adoption table also records two things I checked rather than assumed: claude-config:unhobble solves the same problem a different way (basename as a label, canonical checkout identity recorded in the manifest and verified before every phase — acceptable because its artifact is never served), and machine-health:audit is a deliberate non-adopter whose roots are passed in by the caller for a hazard that skill documents. machine-health is cited for retention shape only.

Test plan

New suite, 23 cases, all green:

$ bash plugins/claude-config/lib/state-key.test.sh
PASS: case 1: https remote normalizes
PASS: case 2: ssh and https identities agree
PASS: case 3: first remote is used whatever its name
PASS: case 3: did not fall through to local
PASS: case 4: no remote keys local
PASS: case 5: non-repo keys nonrepo
PASS: case 6: no .. in the key
PASS: case 6: traversal remote is hashed
PASS: case 6: hashed key is deterministic
PASS: case 7: absolute path not embedded
PASS: case 8: no backslash in the key
PASS: case 9: same repository identity
PASS: case 9: different worktree discriminator
PASS: case 10: key is a plain relative path (nonrepo/69c19dcafcf7/69c19dca)
PASS: case 10: key is a plain relative path (github.com/acme/widget/91b15dd8)
PASS: case 11: stdout is exactly the key
PASS: case 12: unknown argument exits 2

All 23 checks passed.

Executed against this checkout, not only fixtures:

$ bash plugins/claude-config/lib/state-key.sh --explain
rung:          remote
remote:        https://github.com/melodic-software/claude-code-plugins
repo root:     C:/Projects/melodic/worktrees/lane-cc-wave2
identity:      github.com/melodic-software/claude-code-plugins
discriminator: 39f67c08
github.com/melodic-software/claude-code-plugins/39f67c08

Repo gates:

$ shellcheck --rcfile=.shellcheckrc -x plugins/claude-config/lib/state-key.sh plugins/claude-config/lib/state-key.test.sh plugins/claude-memory/lib/state-key.sh
SHELLCHECK CLEAN

$ bash scripts/check-shell-portability.sh --paths <the three files>
No unexcused GNU-only constructs in 3 shell file(s).

$ bash scripts/check-cross-plugin-source-drift.sh --check
No unregistered or drifted cross-plugin source clusters found.

$ CHECK_SKILL_SKILLS_ROOT=plugins/claude-memory/skills bash plugins/skill-quality/scripts/check-skill.sh audit
CHECK-SKILL audit: PASS — 0 errors, 1 warning(s)

$ CHECK_SKILL_SKILLS_ROOT=plugins/claude-config/skills bash plugins/skill-quality/scripts/check-skill.sh audit-instructions
CHECK-SKILL audit-instructions: PASS — 0 errors, 2 warning(s)

$ bash plugins/skill-quality/scripts/check-evals-quality.sh <both evals.json>
check-evals-quality: PASS (0 warning(s) across 1 file(s))   # x2

$ npx markdownlint-cli2 <every changed markdown file>
Summary: 0 issues in 0 files

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

All check-skill.sh warnings are pre-existing on those skills, not introduced here.

No behavioral test applies to the prose half (the four keyed sites and the convention) — those are model-facing instructions. They are covered by three eval cases instead, listed below, which is the mechanism this repo uses for that layer.

Security review note

lib/state-key.sh is a new read-only surface: it runs git remote, git config --get, git rev-parse --show-toplevel, and a hash. No write, no network, no execution of anything it reads. It adds no permission rule, no allowed-tools entry, and no hook.

The one security-relevant property is deliberate and tested: its output becomes directory components in a caller's path, so an attacker-influenced remote URL is a path-traversal vector. The identity is accepted only as lowercase segments of [a-z0-9._-] each starting alphanumeric, and anything else is replaced by a sha256 prefix — deterministic, and inside the namespace. Cases 6, 7, 8 and 10 assert that no .., no backslash, and no absolute or space-bearing form survives into a key. This hardening is inherited from #2250, where an unvalidated version of the same derivation was caught in review normalizing a report path outside its skill's namespace.

Trust-surface direction is narrowing, not widening: claude-memory:audit previously read and acted on a machine-global file it could not attribute to a project, and now refuses to.

Bumps: claude-config0.35.0 (new lib/, behavior change), claude-memory0.9.0 (behavior change including the migration removal). docs/conventions/ carries its own contract version (1.0.0) and no plugin bump.

Test plan — eval coverage added

The keying property had zero eval coverage in either plugin.

  • claude-memory:audittwo-repos-do-not-share-one-report, legacy-unkeyed-report-is-not-served-or-adopted, and report-without-prior-audit rewritten to assert the per-project derived path rather than "the most recent saved audit".
  • claude-config:audit-instructionssecond-project-neither-overwrites-nor-borrows-a-delta, which pins the delta-omission as well as the path.

Related

Closes #2276
Closes #2277
Closes #2278

Inbox item: 2026-08-10-claude-config-report-keying-and-dispatch (RKD-01, RKD-03, RKD-04, RKD-05, RKD-06), plus 20260811-020411-claude-config-audit-pass-report-path-inside-scan-set § F7, which contributes the datum that made this an inconsistency inside one plugin rather than a fleet-wide omission.
Ledger: .work/handoff-inbox-batch-4/ledgers/I6-report-keying-dispatch.md · .../I8-audit-pass-report-path.md § F7.

Adjacent, not duplicated: #2250 (closed) is the same defect on audit-prompting-postures and the template followed here. #2229 / #2230 (closed) covered audit-pass's containment defect — containment within a run and collision across runs are different defects. #1568 owns the substitution-scope question this fix routes around rather than assumes. #1182 is hook-config-delivery adoption tracking, unrelated.

…t and contract the rule

Three writers under `${CLAUDE_PLUGIN_DATA}` shared one file per machine, and
`docs/conventions/` had no rule to point at.

`claude-memory:audit` is the sharp one, because it READS its report back.
`report` mode served whatever `audit/last-audit.md` currently held and `fix`
mode acted on it — so on a machine with two repositories, project B could be
shown project A's findings and offered edits derived from another repository's
memory layer. A wrong answer served, not merely a lost artifact, which is why an
append-only history would not close it: serving the newest report is not serving
this project's. All four sites now resolve one keyed path, derived in SKILL.md
and referred to by the spokes rather than restated.

`claude-config:audit-instructions` loses more than the artifact too. Its Phase D
header carries a per-surface token delta "versus the previous catalog version";
under collision that prior file belongs to a different project's surface set, so
the skill printed a number instead of declining. A silently wrong figure in a
report header is worse than a missing one.

The scheme is `audit-pass`'s, not a new one — but the third adopter would have
been the third copy of a ~40-line shell block, so it ships as `lib/state-key.sh`
with a 23-case suite, byte-identical across both plugins and registered in
`scripts/cross-plugin-source-registry.txt`. The suite pins what the prose
asserted and nothing checked, including the security property: a remote URL
becomes directory components, so `../../../etc`, an absolute local path, and a
Windows path are hashed rather than embedded, with no `..` and no backslash
surviving into a key.

Two decisions worth stating. Legacy unkeyed reports — including the pre-rename
`health/` layout `claude-memory` used to migrate — are neither served nor
adopted: they carry no project segment, so nothing records which repository
produced them, and moving one under a key would invent the attribution keying
exists to remove. And the derivation lives in `SKILL.md`, which is unambiguously
skill content, with the spokes pointing at it; whether substitution reaches
bundled spoke files is #1568's open question and this fix does not depend on the
answer.

`docs/conventions/plugin-data-report-keying/` carries the rule, separating the
two failure modes that need different fixes (collision needs keying, overwrite
needs retention) and naming the "looks scoped but isn't" case — keying on a
project-root basename — as context rather than as a defect filed against the
plugin that has it.

Closes #2276
Closes #2277
Closes #2278

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

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

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

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Security review

  • Gather context (PR diff, changed files)
  • Review lib/state-key.sh (new read-only surface, path-derivation logic)
  • Review lib/state-key.test.sh
  • Review SKILL.md / context / reference doc changes for keying logic
  • Review scripts/cross-plugin-source-registry.txt change
  • Post final findings

Scope: plugins/claude-config/lib/state-key.sh, plugins/claude-memory/lib/state-key.sh (byte-identical copies), lib/state-key.test.sh, and the four skill spokes that consume the derived key. Everything else in the diff (CHANGELOG/plugin.json bumps, the new docs/conventions/ prose, the registry line) is non-executable / non-security-relevant.


1. IMPORTANT — the identity validator can be bypassed via an embedded newline in the git remote URL, letting unsanitized .. segments survive into the emitted key

File: plugins/claude-config/lib/state-key.sh (identical logic also present at plugins/claude-memory/lib/state-key.sh)

identity=$(printf '%s' "$remote" |
sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' |
tr '[:upper:]' '[:lower:]')
# A remote URL is arbitrary text and becomes DIRECTORY COMPONENTS here, so
# accept it only in the shape the scheme means — segments of [a-z0-9._-] each
# starting alphanumeric. That rejects `../central` (a relative filesystem
# remote, which would otherwise write outside the caller's namespace),
# absolute local paths, and backslashes. Anything rejected still keys
# deterministically, by hash.
if printf '%s' "$identity" | grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$'; then
rung="remote"
else
identity="remote/$(hash12 "$remote")"
rung="remote-hashed"
fi

identity=$(printf '%s' "$remote" |
  sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' |
  tr '[:upper:]' '[:lower:]')
if printf '%s' "$identity" | grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$'; then
  rung="remote"
else
  identity="remote/$(hash12 "$remote")"
  rung="remote-hashed"
fi

The script's own header and this PR's description are explicit that this guard is the security boundary: "A remote URL is arbitrary text that becomes DIRECTORY COMPONENTS in the caller's path… Any identity that is not a plain lowercase segment path is replaced by a hash" — and the test suite (case 6/7/8) asserts no .., no absolute path, and no backslash survives into the key.

The bug: grep -qE '^pattern$' anchors ^/$ per line, not to the whole (possibly multi-line) buffer, and grep -q succeeds as soon as any line matches. git config values support C-style escapes inside double quotes (\n, \t, \", \\ per git-config(1)), so git config remote.origin.url can be made to hold a value containing a literal embedded newline byte, e.g. a config value written as:

url = "github.com/safe/repo\n../../../etc/passwd"

git config --get decodes that \n escape to a real newline when read back. Bash command substitution (remote=$(git config --get ...)) strips only trailing newlines, preserving the embedded one. sed (line-oriented) and tr both pass a multi-line string through unchanged in shape. The subsequent grep -qE '^...$' then sees two "lines" — github.com/safe/repo (matches the safe-segment pattern) and ../../../etc/passwd (does not) — and returns success because the first line matched, even though the second did not. Because grep -q exits 0, the code takes rung="remote" and keeps the entire unmodified multi-line $identity, including the unsanitized ../../../etc/passwd portion, which is then printed by printf '%s/%s\n' "$identity" "$discriminator" (line 175) as part of the emitted state key.

When a caller concatenates that key onto ${CLAUDE_PLUGIN_DATA} (exactly what the four spokes in this PR do — e.g. plugins/claude-memory/skills/audit/SKILL.md's ${CLAUDE_PLUGIN_DATA}/audit/<state-key>/last-audit.md), the resulting path string contains real /-delimited .. components later in the string (the embedded \n only corrupts the boundary segment it falls inside, it doesn't stop the slash-delimited segments that follow it from being interpreted as genuine parent-directory references by the filesystem). That defeats exactly the property this validator, and its test suite, claims to guarantee — a crafted remote can walk a report path out of the plugin's own data directory.

None of the 23 test cases exercise a multi-line/embedded-newline identity (case 6 only covers a single-line traversal remote, which the whole-line regex correctly rejects and hashes) — this is a real gap in the stated invariant, not just an undertested edge case.

Suggested fix: stop using line-oriented grep for a security-anchoring check on a value that can legitimately contain newlines. Either reject any $remote/$identity containing a newline outright before the shape check (e.g. case "$identity" in *$'\n'*) identity="remote/$(hash12 "$remote")"; rung="remote-hashed" ;; *) ... esac), or switch the check to bash's own [[ "$identity" =~ ^...$ ]] (POSIX extended regex via glibc, which anchors to the whole string, not per line, absent REG_NEWLINE) instead of piping through grep -qE.

Fix this →

Confidence: Moderate-high on the mechanics (git config quoted-value escape decoding, bash command-substitution newline preservation, and grep's per-line anchoring are all standard, well-documented behavior); lower on real-world exploitability, since it requires an attacker to influence the local git remote config value (not just the repo's tracked content) — e.g. a malicious clone/setup script, a compromised dotfiles sync, or an agent operating against a repository whose .git/config was authored by an untrusted process. I was not able to execute a live proof-of-concept in this review sandbox (command execution required interactive approval that wasn't available here), so this is inspection-based, not empirically confirmed.


No other security-relevant issues found. The rest of state-key.sh correctly hashes the local/nonrepo rungs (never embeds unvalidated text), --root is validated as an existing directory before cd, and the script performs no writes, network calls, or execution of its inputs. Supply-chain/action-pin and workflow-permission concerns are out of scope for this lane per the review brief (deferred to zizmor), and this PR touches no .github/workflows/* files.

@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: 4da81ee881

ℹ️ 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/lib/state-key.sh
Comment thread docs/conventions/plugin-data-report-keying/README.md
…ared state-key resolver

Shipping lib/state-key.sh while leaving a hand-rolled copy of the same
derivation in a sibling skill of the same plugin is exactly the drift the
convention this PR adds forbids — and the CHANGELOG entry claimed a
consolidation the diff had not made. The ~40-line block is replaced by the one
call; its verified properties stay stated in the skill, since a reader needs to
know what the key guarantees, and only the restated algorithm is gone.

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

Copy link
Copy Markdown
Contributor Author

Update — migrated onto the shared resolver in this PR (39725b8).

Self-review caught that the CHANGELOG entry described a consolidation the diff had not made: lib/state-key.sh shipped while audit-prompting-postures/SKILL.md still carried its own ~40-line copy of the same derivation (#2250's). That would have left two implementations of one scheme inside one plugin — precisely what docs/conventions/plugin-data-report-keying/ rule 1 forbids, in the PR that introduces it.

The block is now the one call. Its verified properties stay stated in the skill (a reader needs to know what the key guarantees); only the restated algorithm is gone. check-skill.sh audit-prompting-posturesPASS, 0 errors, 0 warnings; SKILL.md 144 lines.

Two things checked while there, both clean:

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Security review

  • Gather context (PR diff, changed files)
  • Review lib/state-key.sh (new read-only surface, path-derivation logic)
  • Review lib/state-key.test.sh
  • Review SKILL.md / context / reference doc changes for keying logic
  • Review scripts/cross-plugin-source-registry.txt change
  • Post final findings

Scope: plugins/claude-config/lib/state-key.sh, plugins/claude-memory/lib/state-key.sh (byte-identical, confirmed via diff), lib/state-key.test.sh, and the doc/skill spokes that consume the derived key (audit-instructions/SKILL.md, audit-prompting-postures/SKILL.md, claude-memory/skills/audit/{SKILL.md,context/audit.md,context/fix.md,reference/criteria.md}). This is a fresh pass against the current head (39725b8, the third commit — the audit-prompting-postures migration onto the shared resolver). CHANGELOG/plugin.json bumps, the docs/conventions/ prose, and the registry line are non-executable / non-security-relevant.


1. IMPORTANT — identity validator can be bypassed via an embedded newline in the git remote URL; still present at current HEAD, and now also reachable from audit-prompting-postures

Files: plugins/claude-config/lib/state-key.sh and the byte-identical plugins/claude-memory/lib/state-key.sh

identity=$(printf '%s' "$remote" |
sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' |
tr '[:upper:]' '[:lower:]')
# A remote URL is arbitrary text and becomes DIRECTORY COMPONENTS here, so
# accept it only in the shape the scheme means — segments of [a-z0-9._-] each
# starting alphanumeric. That rejects `../central` (a relative filesystem
# remote, which would otherwise write outside the caller's namespace),
# absolute local paths, and backslashes. Anything rejected still keys
# deterministically, by hash.
if printf '%s' "$identity" | grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$'; then
rung="remote"
else
identity="remote/$(hash12 "$remote")"
rung="remote-hashed"
fi

identity=$(printf '%s' "$remote" |
sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' |
tr '[:upper:]' '[:lower:]')
# A remote URL is arbitrary text and becomes DIRECTORY COMPONENTS here, so
# accept it only in the shape the scheme means — segments of [a-z0-9._-] each
# starting alphanumeric. That rejects `../central` (a relative filesystem
# remote, which would otherwise write outside the caller's namespace),
# absolute local paths, and backslashes. Anything rejected still keys
# deterministically, by hash.
if printf '%s' "$identity" | grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$'; then
rung="remote"
else
identity="remote/$(hash12 "$remote")"
rung="remote-hashed"
fi

identity=$(printf '%s' "$remote" |
  sed -e 's#^[a-z+]*://##' -e 's#^[^@/]*@##' -e 's#:#/#' -e 's#\.git$##' |
  tr '[:upper:]' '[:lower:]')
if printf '%s' "$identity" | grep -qE '^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$'; then
  rung="remote"
else
  identity="remote/$(hash12 "$remote")"
  rung="remote-hashed"
fi

This is unchanged from the commit reviewed in this thread's first pass (4da81ee) — the new commit on this HEAD only edited audit-prompting-postures/SKILL.md prose to call the shared script instead of restating it inline, so the underlying gap carries forward and now has a third caller.

The mechanism: grep -qE '^...$' anchors ^/$ per line, and grep -q succeeds if any line matches. Per git-config(1), a double-quoted config value may contain a \n escape, which git config --get decodes to a literal embedded newline on read-back. Bash command substitution only strips trailing newlines, so remote=$(git config --get ...) preserves an embedded one, and the line-oriented sed/tr pass it through unchanged in shape. A remote value written as "github.com/safe/repo\n../../../etc/passwd" then produces an $identity where line 1 (github.com/safe/repo) matches the safe-segment pattern — grep -q exits 0 — and the entire unmodified multi-line $identity, including the unsanitized ../../../etc/passwd portion, is kept and printed as the state key (line 175: printf '%s/%s\n' "$identity" "$discriminator"). Concatenated onto ${CLAUDE_PLUGIN_DATA} by any of the three callers (audit-prompting-postures, audit-instructions, claude-memory:audit), the resulting path string contains real /-delimited .. components that a filesystem will interpret as genuine parent-directory references — defeating the exact property this validator and its test suite (state-key.test.sh case 6/7/8) claim to guarantee.

state-key.test.sh has no case for an embedded-newline identity — case 6 only covers a single-line traversal remote (git remote add origin ../../../etc), which the whole-line-shaped regex correctly rejects. This is a real gap in the tested invariant, not just an undertested edge case.

Suggested fix: stop using line-oriented grep for this anchor check on a value that can legitimately contain a newline. Either reject any $identity containing a newline before the shape check (case "$identity" in *$'\n'*) identity="remote/$(hash12 "$remote")"; rung="remote-hashed" ;; *) ... esac), or switch to bash's own [[ "$identity" =~ ^...$ ]], which anchors to the whole string rather than per line. Apply the same fix to both copies and add a covering case to state-key.test.sh (drift-checked via scripts/cross-plugin-source-registry.txt, so the copies won't diverge once fixed).

Confidence: Moderate-high on the mechanics (git-config quoted-value escape decoding, bash command-substitution newline preservation, and grep's per-line anchoring are all standard, documented behavior) — lower on real-world exploitability, since it requires an attacker to influence the local .git/config value (a malicious clone/setup script, compromised dotfiles sync, or an agent operating against a repo whose config was authored by an untrusted process), not just tracked repo content. I could not execute a live proof-of-concept in this sandbox either — git commands targeting a scratch directory outside the main checkout require interactive approval that isn't available here — so this remains inspection-based, not empirically confirmed.

Fix this →


2. SUGGESTION — a hash-tool failure inside the pipeline doesn't stop the derivation, so it can silently emit a malformed key

File: plugins/claude-config/lib/state-key.sh (and the byte-identical claude-memory copy)

sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256
else
echo "ERROR: no sha256sum or shasum on PATH — cannot derive a state key" >&2
exit 2
fi
}
hash12() { printf '%s' "$1" | sha256 | cut -c1-12; }
hash8() { printf '%s' "$1" | sha256 | cut -c1-8; }

sha256() does exit 2 when neither sha256sum nor shasum is on PATH, but it runs as a non-last stage of a pipe (printf ... | sha256 | cut ...) inside a command substitution assignment (discriminator=$(hash8 ...)). Without set -e, a failing assignment's command substitution doesn't halt the script, so the derivation continues with an empty hash segment and still exits 0 — e.g. a malformed key like local/<hash>/ with an empty discriminator, silently collapsing worktree discrimination. This only triggers in the (today, essentially theoretical) case where a host has neither hash tool, so it's low real-world severity, and it's an availability/attribution-degradation issue rather than an attacker-controlled one — flagging as a suggestion since a Codex review comment on this PR already caught the same spot.


No other security-relevant issues found. The local/nonrepo rungs never embed unvalidated text (both are pure hash output), --root is validated as an existing directory before cd, argument parsing has no injection surface, and the doc/skill spokes (SKILL.md/context/*.md/reference/*.md) that reference the derived key are prose only — no shell interpolation of unsanitized content beyond the call into state-key.sh itself. The claude-memory:audit legacy-report handling (context/audit.md, context/fix.md) correctly declines to read or migrate the unattributable pre-rename health/ layout rather than serving it, which is the fix this PR claims for #2277. scripts/cross-plugin-source-registry.txt's new entry correctly pins both copies as drift-checked. Supply-chain/action-pin and workflow-permission concerns are out of scope for this lane per the review brief (deferred to zizmor), and this PR touches no .github/workflows/* files.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Update — audit-prompting-postures migrated onto the shared resolver in this PR (39725b8).

Self-review caught that the CHANGELOG entry described a consolidation the diff had not made: lib/state-key.sh shipped while audit-prompting-postures/SKILL.md still carried its own ~40-line copy of the same derivation (the one #2250 landed). That would have left two implementations of one scheme inside one plugin — precisely what docs/conventions/plugin-data-report-keying/ rule 1 forbids, in the PR that introduces it.

The block is now the one call. Its verified properties stay stated in the skill — a reader needs to know what the key guarantees — and only the restated algorithm is gone.

$ CHECK_SKILL_SKILLS_ROOT=plugins/claude-config/skills bash plugins/skill-quality/scripts/check-skill.sh audit-prompting-postures
INFO: SKILL.md 144/500 lines
INFO: markdownlint clean
CHECK-SKILL audit-prompting-postures: PASS — 0 errors, 0 warning(s)

Two things checked while there, both clean:

(An earlier revision of this comment was posted with a mangled line from shell substitution — this is the corrected text.)

@kyle-sexton
kyle-sexton merged commit 970cfea into main Aug 12, 2026
35 checks passed
@kyle-sexton
kyle-sexton deleted the feat/2276-plugin-data-report-keying branch August 12, 2026 04:17
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

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


Code review

Scope: correctness, error handling, test coverage, and maintainability per this repo's REVIEW.md code-review lane (security findings are out of scope here — the dedicated security lane already posted its own review above).

  • Gather context (diff, CLAUDE.md / REVIEW.md guidance)
  • Review lib/state-key.sh correctness (non-security aspects)
  • Review lib/state-key.test.sh coverage
  • Review skill/spoke doc changes (SKILL.md, context/, reference/) for correctness & maintainability
  • Review CHANGELOG/plugin.json bumps and cross-plugin registry entry
  • Post findings

Posted two inline findings on plugins/claude-config/lib/state-key.sh (identical logic also lives in plugins/claude-memory/lib/state-key.sh):

  1. 🔴 Importantline 116: sha256()'s exit 2 only terminates the pipeline subshell it runs in, not the top-level script (no set -e, and sha256 isn't the pipeline's last stage). On a system lacking both sha256sum and shasum, this silently produces a malformed key like local/ and still exits 0, contradicting the script's own documented "Exit: 0 always on a successful derivation" contract.
  2. 🟡 Nitlines 123-126: the "first configured remote" framing (repeated in docs/conventions/plugin-data-report-keying/README.md line 66) implies insertion order, but git remote actually lists names alphabetically. Doesn't affect determinism/security, just reader expectations on multi-remote repos — and it's untested (case 3 only covers a single-remote repo).

Both are inspection-based: this review's sandbox blocks the shell mutations (ln, mktemp, git init, PATH manipulation) needed to empirically reproduce a missing-sha256sum environment or construct a multi-remote fixture, so I could not execute a live repro for either — the reasoning rests on reading the script (pipeline/subshell semantics, absence of set -e) and on documented git remote behavior, not on a run I performed here.

3. 🟡 Suggestion — the PR's own new Rule 4 isn't applied to either writer it adds

File: docs/conventions/plugin-data-report-keying/README.md#L154-L159 (Rule 4, also pinned in the convention's CHANGELOG.md#L37-L38)

Rule 4 states: "A component whose sole durable output lives there should say so once, near the path, rather than letting an operator discover it [that uninstalling the plugin from its last scope deletes it, absent --keep-data]." I grepped both report-path sections this PR ships or touches — audit-instructions/SKILL.md's Phase D and claude-memory/skills/audit/SKILL.md's "Report location" — and neither mentions --keep-data or uninstall at all (confirmed via grep -n "keep-data\|uninstall" across the changed skill files: zero matches). Both are exactly the "sole durable output" case Rule 4 targets (the audit report is this component's only persisted artifact). Rule 4 isn't marked [SPEC] in the convention (a "should", not a hard obligation), so this is a completeness/consistency gap rather than a contract violation — but it's a bit of a blind spot for a PR whose entire purpose is standing up this contract to not apply one of its own four rules to either concrete adopter it ships.


Everything else reviewed cleanly:

  • The two lib/state-key.sh copies are byte-identical (diff confirms), consistent with the registry entry added to scripts/cross-plugin-source-registry.txt.
  • The audit-prompting-postureslib/state-key.sh migration correctly removes the ~40-line hand-rolled duplicate and preserves the verified-properties prose.
  • criteria.md, both evals.json additions (sequential IDs, no collisions), and the context/audit.md / context/fix.md spoke updates are internally consistent with what SKILL.md now derives once and the spokes reference.
  • Version bumps (claude-config 0.34.0→0.35.0, claude-memory 0.8.1→0.9.0) and their CHANGELOG entries, scoped to just this PR's commits, are consistent with the stated behavior changes.
  • Every citation in the new docs/conventions/plugin-data-report-keying/README.md adoption table that I could check against the actual target file (unhobble's manifest-based verification, machine-health:audit's append-only history, topic-docs's non-repo slug fallback) matches the cited file's real content — nothing fabricated.

shasum -a 256
else
echo "ERROR: no sha256sum or shasum on PATH — cannot derive a state key" >&2
exit 2

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Important — error-handling

sha256()'s exit 2 on line 116 only exits the subshell that runs sha256, not the top-level script: hash12()/hash8() invoke it as the middle stage of a pipeline (printf ... | sha256 | cut -c1-12), and every element of a pipeline runs in its own subshell in bash unless it's the last stage under lastpipe (not enabled here, and irrelevant since sha256 isn't the last stage regardless). The script also never sets -e, and a failing command inside a $(...) assignment isn't otherwise checked.

Concretely, on a minimal image lacking both sha256sum and shasum: sha256 prints its error to stderr and exits 2 inside its own subshell; cut -c1-12 still runs (on empty stdin) and produces an empty string; hash12/hash8 return empty; and the caller ends up with a malformed key such as local/ or nonrepo//<8-hash-of-nothing>. The top-level script still exits 0 and prints that malformed key on stdout, contradicting the header's own documented contract at line 43 ("Exit: 0 always on a successful derivation… there is no 'cannot key' outcome. 2 on a bad argument or an unusable --root") — this is an unusable derivation, but it's reported as success. A caller (e.g. the ${CLAUDE_PLUGIN_DATA}/audit/<state-key>/... writers this PR wires up) would then read/write under that malformed path without any signal that the key is degenerate.

Same logic is byte-identical in plugins/claude-memory/lib/state-key.sh, so it needs the same fix in both (or just the canonical copy, then re-sync per the registry note).

Note: I read this from static analysis only — the sandbox for this review run blocks the shell mutations (ln, mktemp, PATH manipulation) needed to empirically reproduce a missing-sha256sum/shasum environment, so I could not execute a live repro here. The subshell/pipeline semantics and the "no -e" observation are directly verifiable by reading the script, though.

Suggested fix: have sha256() communicate failure to the caller rather than relying on exit inside a piped subshell — e.g. have hash12/hash8 check set -o pipefail (already set globally) and propagate a non-zero status with an explicit || exit 2 at each call site, or restructure so the sha256-availability check runs once at the top of the script (outside any pipeline) and the script exits 2 immediately if neither tool is present.

Comment on lines +123 to +126
# "the FIRST configured remote" — not necessarily one named `origin`. A repo
# whose only remote is `upstream` still has a remote and must not fall through
# to the local rung.
remote_name=$(git remote 2>/dev/null | tr -d '\r' | head -1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit — documentation-accuracy

The comment ("the FIRST configured remote… not necessarily one named origin") and its restatement in docs/conventions/plugin-data-report-keying/README.md line 66 ("the first configured remote URL") describe git remote's output as configuration/insertion order. It isn't: git remote with no arguments lists remote names sorted alphabetically, not in the order they were added to .git/config. On a repo with two remotes named e.g. zzz-mirror (added first) and aaa-upstream (added later), git remote | head -1 returns aaa-upstream, not the one actually configured first.

This doesn't break the security property or determinism (the same repo state always yields the same key), so it's a documentation-precision nit rather than a functional bug — but a reader relying on the "first configured" framing to predict which remote wins on a multi-remote repo would predict wrong. Test case 3 in state-key.test.sh only exercises a single-remote (upstream-only) repo, so this alphabetical-vs-insertion-order distinction isn't covered either.

I wasn't able to run git remote against a constructed multi-remote fixture in this sandbox (repo-mutating git commands require approval I don't have in this review run), so this is based on documented/known git behavior rather than an execution I performed here.

Suggested fix: reword the comment/README to say "the alphabetically-first remote name" (or whichever is actually intended), or if insertion order is the actually-desired semantic, resolve it from .git/config's section order instead of git remote's sorted output.

@github-actions

Copy link
Copy Markdown

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

kyle-sexton added a commit that referenced this pull request Aug 12, 2026
)

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

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

## Summary

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

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

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

## Test plan

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

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

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

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

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

## Related

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

Inbox item:
`20260811-024628-claude-config-audit-permission-grants-defects-and-fleet-grant-hygiene`.
Ledger:
`.work/handoff-inbox-batch-4/ledgers/I10-permission-grants-fleet.md` §
A2, A3, A19.

---------

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

Labels

None yet

Projects

None yet

1 participant