Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/claude-config/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "claude-config",
"version": "0.31.0",
"version": "0.32.0",
"description": "Seven configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-instructions (locally-owned instruction surfaces vs current model capability — proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane — posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target — three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate — delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns).",
"author": {
"name": "Melodic Software",
Expand Down
81 changes: 81 additions & 0 deletions plugins/claude-config/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,87 @@
All notable changes to the `claude-config` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.32.0]

Two behavior changes, hence the minor: `permission-rule-check.sh` refuses an unresolvable scan root
instead of sweeping the user profile, and `audit-prompting-postures` writes its report to a
project-keyed path instead of one fixed name.

### Fixed

- **`audit-permission-grants` P2 stated a rule the docs do not contain, and emitted it on rule classes
it is false for.** The check's **Why** read "Bash rules match literally with no `~`/`$HOME`/env
expansion". Grepping the complete raw markdown of both
[permissions](https://code.claude.com/docs/en/permissions) and
[skills](https://code.claude.com/docs/en/skills) (fetched with `curl` to a file, 2026-08-11) finds no
such sentence on either page — the claim is **unsupported**, not merely over-broad — and two
documented behaviors contradict it. Claude Code substitutes `${CLAUDE_SKILL_DIR}` and
`${CLAUDE_PROJECT_DIR}` in Bash rules in `allowed-tools`, which the skills page presents as *the* way
to run a bundled script without a prompt; and a leading assignment of certain known-safe environment
variables is stripped, so `Bash(npm test *)` matches `NODE_ENV=test npm test`. The skill was telling
authors to remove the documented zero-prompt pattern.
- **And the same sentence was emitted on `Read` and `Edit` findings, where it is false twice over.**
Probing the shipped detector confirms P2 fires on a `Read(<home>/notes.md)` or
`Edit(<home>/src/**)` rule carrying the Bash-scoped message — but those classes use gitignore
pattern syntax and **do** resolve `~/`: the permissions page's own example has
`Read(~/Documents/*.pdf)` matching `<home>/Documents/*.pdf`. One message string serves every class,
so it now carries only what is true of all of them — 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 the
`permission-rule-hygiene` convention, which already held the corrected doctrine including the
`${CLAUDE_PROJECT_DIR}` v2.1.196 substitution floor and the fact that `${CLAUDE_PLUGIN_ROOT}` is not
substituted at all. The wrong text was pinned by a passing assertion, so the test moved with it.

### Changed

- **`permission-rule-check.sh` refuses an unresolvable scan root instead of falling through to `$PWD`.**
Outside a git repository `$PWD` is whatever directory the session happens to stand in — on a
developer machine, usually the user profile — and both scans walk the root with `find` with no depth
bound and 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`). The ladder is now fixture dir → git toplevel →
`${CLAUDE_PROJECT_DIR}` and nothing after it; an unresolvable root exits **2**, reusing the
environment-gap channel the contract already documents for a missing `jq` rather than minting a new
code, so the advisory exit-0-for-findings contract is untouched. **`--count` refuses too** — a `0`
printed by a scan that never resolved a root reads exactly like a clean bill. The refusal names what
it tried and how to fix it.
- All five "always exits 0" statements moved together — `reference/criteria.md`, and the script's
header comment, usage block, and `--help` text — since a refusal branch contradicts each. `SKILL.md`
carried no such claim to update: `grep` finds none there, and its exit-related line documents the jq
exit 2.
- **`audit-prompting-postures` keys its report per project.** It persisted to a single
`${CLAUDE_PLUGIN_DATA}/audit-prompting-postures/last-audit.md`, and `${CLAUDE_PLUGIN_DATA}` resolves
to `~/.claude/plugins/data/{id}/` where `{id}` is the *plugin* identifier, never the project. The
skill's only durable deliverable was therefore overwritten by the next run from any other root — the
audit artifact destroyed by ordinary use of the skill. The path now carries a `<state-key>`, and the
report opens with a three-line header (resolved root, scope filter, UTC timestamp) so a surviving file
is self-describing rather than merely un-overwritten.

**The scheme is `audit-pass`'s, reused rather than reinvented** —
`<repo-identity>/<worktree-discriminator>` from its run-state reference — because a second scheme for
one concern is the drift this batch exists to remove. One rung is added: that ladder has
git-with-remote and git-without-remote and no non-repo rung, which `audit-pass` does not need because
it refuses non-git targets, while this skill is report-only and audits them.

**The derivation is written as commands to run, never as a condition over `${CLAUDE_PROJECT_DIR}`.**
That placeholder substitutes inline in skill content, so the model never sees the literal token and
cannot evaluate "when set" — the originally filed fix sketch would have introduced that defect while
removing this one.

**A remote URL is arbitrary text that becomes directory components here, so it is validated before
use.** Only the shape the scheme means is accepted — path segments of `[a-z0-9._-]` each starting
alphanumeric. Everything else keys by hash instead, still deterministically. Without that check a
relative filesystem remote (`git remote add origin ../central.git`) yields the identity `../central`
and the report lands *outside* this skill's directory; absolute-local and Windows-path remotes fail
the same way. And the remote is read from **the first configured remote** — `git remote | head -1` —
not from one named `origin`, because a repo whose only remote is `upstream` has a remote and must not
drop to the local rung. Both were found in review against the first draft, which did exactly that.

### Added

- Evals 4 and 5 for `audit-prompting-postures`: two roots must produce two surviving reports keyed by
the reused scheme, and a non-repo root must still key and still self-describe.

## [0.31.0]

`audit-pass` changes what it does on two target classes, which is why this is a minor: a target that is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ bash "${CLAUDE_PLUGIN_ROOT}/skills/audit-permission-grants/scripts/permission-ru

It scans frontmatter `allowed-tools` and settings `permissions.allow` across the consuming repo and
prints one finding per fragile grant (`<severity> [<check>] <source>: <detail>`). `--count` prints the
count. It requires `jq`; a missing `jq` exits 2 (report the environment gap rather than a clean bill).
count. **Exit 2 is the environment-gap channel — report the gap rather than a clean bill.** Two things
raise it: a missing `jq`, and a scan root that resolves to neither a git toplevel nor
`$CLAUDE_PROJECT_DIR`. On the second, say the scan did not run and give the fix — run from inside the
repository you mean to scan, or set `$PERMISSION_HYGIENE_FIXTURE_DIR` explicitly. Never report "no
fragile permission grants found" on an exit 2.
`settings.local.json` is parsed for its `permissions.allow` array only — never echoed wholesale.

If a scope filter was given, run the full detector and present only the matching checks (P1/P2 map to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ skill/command/agent frontmatter `allowed-tools` and the `permissions.allow` arra
`.claude/settings.json` and `.claude/settings.local.json`, plus any plugin `settings.json`, and emits
one finding per fragile grant. Frontmatter files under a `vendor/` path segment are skipped: they are
vendored upstream references, not loadable skills/agents/commands, so their `allowed-tools` never take
effect and a finding on them would be a false positive. It is advisory (always exits 0); `--count`
prints the finding count.
effect and a finding on them would be a false positive. Findings are advisory and never fail the run,
so a completed scan exits 0 in either mode; `--count` prints the finding count. **An environment gap
exits 2 instead of reporting a clean bill** — a missing `jq`, or a scan root that resolves to neither a
git toplevel nor `$CLAUDE_PROJECT_DIR`. There is no fallback to the current directory, because outside
a repository that is usually the user profile and scanning it would walk the whole home tree and still
exit 0.
`settings.local.json` is parsed for its `permissions.allow` array only — never read or echoed wholesale
(it may hold tokens).

Expand Down Expand Up @@ -60,10 +64,40 @@ Windows), `/home/<name>/…`, `/Users/<name>/…`, or `C:\Users\<name>\…`.
**How to check**: run the detector. `${CLAUDE_PROJECT_DIR}/…`, `~/…`, and `//…` forms are not flagged
(they expand or are portable anchors); only concrete usernames match.

**Why**: Bash rules match literally with no `~`/`$HOME`/env expansion, so the rule breaks on other
machines/usernames and leaks a username into version control. See convention anti-pattern 2.

**Recommend**: replace with a machine-independent bare-name rule.
**Why**: the rule names a concrete user home, so it breaks on any other machine or username — and after
a skill migrates into a plugin, since the install path changes — and it leaks a username into version
control. That portability break is the whole of the finding, and it holds for every rule class this
check fires on.

**Do not state it as "no expansion".** No such rule is documented on the permissions page, and the
blanket form is false for the file tools. Match the mechanism to the rule class:

| Rule class | What actually happens |
| --- | --- |
| `Bash(...)` | A glob over the literal command string ([permissions](https://code.claude.com/docs/en/permissions#bash)) — with the two documented exceptions below. |
| `Read(...)` / `Edit(...)` | gitignore pattern syntax, which **does** resolve anchors: `~/path` from the home directory, `//path` from the filesystem root, `/path` from the settings source ([permissions](https://code.claude.com/docs/en/permissions#read-and-edit)). The page's own example: `Read(~/Documents/*.pdf)` matches `<home>/Documents/*.pdf`. |

The two exceptions on Bash rules:

1. **Token substitution in `allowed-tools`.** Claude Code substitutes `${CLAUDE_SKILL_DIR}` and
`${CLAUDE_PROJECT_DIR}` in both a skill's markdown content and Bash rules in `allowed-tools`
([skills](https://code.claude.com/docs/en/skills#available-string-substitutions)) — the documented
way to run a bundled script without a prompt, e.g.
`allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/render.sh *)`. Two limits the convention records:
`${CLAUDE_PROJECT_DIR}` substitution requires Claude Code **v2.1.196 or later** (below that floor the
rule stays a literal string and never matches), and `${CLAUDE_PLUGIN_ROOT}` is **not** substituted at
all, so a rule written with it is inert.
2. **Leading env-assignment stripping**, and it is scoped: an assignment of certain known-safe variables
is stripped, so `Bash(npm test *)` matches `NODE_ENV=test npm test`. An **allow** rule will not match
past an assignment of any other variable; a **deny** or **ask** rule matches past any leading
assignment ([permissions](https://code.claude.com/docs/en/permissions#process-wrappers)).

Full doctrine, and the source this row syncs from: the
[permission-rule-hygiene convention](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/permission-rule-hygiene/README.md)
anti-pattern 2.

**Recommend**: replace with a portable form — `${CLAUDE_SKILL_DIR}` for a skill's own bundled script, a
bare-name command on PATH, or for `Read`/`Edit` rules the `~/` home anchor.

## P3: Plugin self-granted permissions [warning]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,20 @@
# so a self-granted permission rule is silently ignored; the operative
# allow-rule has to be added by the operator to ~/.claude/settings.json.
#
# Advisory: prints findings, ALWAYS exits 0 (findings never fail the run).
# Requires jq for the settings-file half; exits 2 when jq is absent.
# Advisory about FINDINGS: they never fail the run, so a scan that completes exits 0
# whether or not it found anything. Environment gaps are the exception and exit 2 —
# missing jq, and an unresolvable scan root (below).
#
# Root resolution: $PERMISSION_HYGIENE_FIXTURE_DIR, else the cwd's git
# toplevel, else $CLAUDE_PROJECT_DIR, else $PWD. Never the plugin's own dir.
# Root resolution: $PERMISSION_HYGIENE_FIXTURE_DIR, else the cwd's git toplevel, else
# $CLAUDE_PROJECT_DIR. Never the plugin's own dir, and NEVER $PWD.
#
# Why there is no $PWD fallback: outside a git repository $PWD is whatever directory
# the session happens to stand in, which on a developer machine is typically the user
# profile. Both scans below walk the root with `find` with no depth bound and stderr
# discarded, so that fallback turned an unresolved root into an unbounded sweep of the
# user's home that still exited 0 — a timeout or a swallowed permission error was
# indistinguishable from a clean bill. An unresolvable root is an environment gap, so
# it is reported as one (exit 2) rather than scanned on a guess.
#
# Usage:
# permission-rule-check.sh # human-readable findings, one per line
Expand All @@ -47,8 +56,12 @@ Usage: permission-rule-check.sh [--count|--help]
Scans skill/command/agent frontmatter `allowed-tools` and the `permissions.allow`
arrays of .claude/settings.json and .claude/settings.local.json for P1 (auto-mode
-dropped interpreter/blanket rules), P2 (hardcoded machine paths), and plugin
settings.json for P3 (unsupported self-granted `permissions`). Advisory — exit 0.
Requires jq (exit 2 when absent).
settings.json for P3 (unsupported self-granted `permissions`).

Findings are advisory and never fail the run, so a completed scan exits 0 in both
modes. Environment gaps exit 2 instead of reporting a clean bill: missing jq, and a
scan root that resolves to neither a git toplevel nor $CLAUDE_PROJECT_DIR. Set
$PERMISSION_HYGIENE_FIXTURE_DIR to scan an explicit directory.
EOF
}

Expand All @@ -72,7 +85,30 @@ if [[ -n "${PERMISSION_HYGIENE_FIXTURE_DIR:-}" ]]; then
ROOT="$PERMISSION_HYGIENE_FIXTURE_DIR"
else
ROOT="$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r')"
[[ -n "$ROOT" ]] || ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
[[ -n "$ROOT" ]] || ROOT="${CLAUDE_PROJECT_DIR:-}"
fi

# No $PWD fallback — see the header. Refuse rather than sweep an unknown tree, and
# refuse in BOTH modes: a `--count` of 0 from a scan that never resolved a root reads
# exactly like a clean bill, which is the confusion this refusal exists to remove.
if [[ -z "$ROOT" ]]; then
cat >&2 <<'EOF'
ERROR: no scan root resolved — refusing to scan.

Tried, in order: $PERMISSION_HYGIENE_FIXTURE_DIR, the current directory's git
toplevel, then $CLAUDE_PROJECT_DIR. None resolved, and there is deliberately no
fallback to the current directory: outside a repository that is usually the user
profile, and scanning it would walk the whole home tree and still report success.

Fix by running from inside the repository you mean to scan, or set
$PERMISSION_HYGIENE_FIXTURE_DIR to the directory to scan explicitly.
EOF
exit 2
fi

if [[ ! -d "$ROOT" ]]; then
printf 'ERROR: scan root does not exist or is not a directory: %s\n' "$ROOT" >&2
exit 2
fi
Comment thread
kyle-sexton marked this conversation as resolved.

# --- Detection patterns -------------------------------------------------------
Expand Down Expand Up @@ -136,7 +172,7 @@ scan_rule() {
[[ -n "$m" ]] && emit warning P1 "$src" "'$m' is an interpreter/runner-led grant, not the portable bare-name pattern; Claude Code drops the broad forms of this shape (blanket, package-manager runners, and wildcarded/globbed-target interpreters) on entering auto mode. Expose the guarded script as a bare PATH command and allow that, e.g. Bash(babysit_merge.sh:*)."
done < <(printf '%s\n' "$text" | grep -oE "$P1_ERE" 2>/dev/null | sort -u)
while IFS= read -r m; do
[[ -n "$m" ]] && emit error P2 "$src" "hardcoded machine path in '$m' — Bash rules match literally (no ~/\$HOME/env expansion), so this breaks on other machines/usernames and leaks a username into source control. Use a machine-independent bare-name rule."
[[ -n "$m" ]] && emit error P2 "$src" "hardcoded machine path in '$m' — the rule names a concrete user home, so it breaks on other machines and usernames and leaks a username into source control. Portable forms: \${CLAUDE_SKILL_DIR} for a skill's own bundled script (substituted in allowed-tools Bash rules), a bare-name command on PATH, or the ~/ home anchor for Read/Edit rules."
done < <(printf '%s\n' "$text" | grep -oE "$P2_ERE" 2>/dev/null | sort -u)
}

Expand Down
Loading