Skip to content

repo-hygiene:clean 0.10.11 — audit: destructive guard gaps, PowerShell matcher bypass, silent 200-PR truncation #3346

Description

@kyle-sexton

Post-use behavioral audit of repo-hygiene:clean 0.10.11, produced by plugin-quality:audit
after driving the skill through a full fleet cleanup: 65 working trees, 17 object stores,
14 GitHub repos, 830 branches
, on Windows 11 / Git Bash + PowerShell 7.6.5.

Findings were produced by a fresh-context auditor reading the installed source, then the three HIGH
findings were independently re-verified against source by the dispatching session before filing.

Sealed evidence packet (local, on the auditing machine):
~/.claude/plugins/data/plugin-quality-melodic-software/evidence/62c98632-.../repo-hygiene-clean/20260824T195700Z/
(evidence.md, audit-notes.md 37 KB, contract.md; seal verified changed=0 missing=0 unsealed=0)


HIGH

D1 — The destructive guard does not cover the operations the frontmatter says it covers

SKILL.md:7-11 states the mutating scripts "stay behind the PreToolUse destructive guard and the
permission flow". scripts/destructive-guard.sh:41-55 (is_destructive) matches only: rm with
both recursive and force flags; git clean with a force flag; git reset --hard;
git checkout -- ; git stash drop|clear; and Remove-Item …-Recurse.

Driven with synthetic PreToolUse payloads:

Command Guard exit
rm -rf /tmp/x 2 (blocked)
bash …/git-tree-reset.sh --apply 0 (allowed)
bash …/clean-batch.sh --tier all --apply --batch-plan /tmp/p 0 (allowed)
bash …/remove-path.sh --apply /d/repos/foo 0 (allowed)
git branch -D feature/x 0 (allowed)
git push origin --delete feature/x 0 (allowed)
git worktree remove --force /d/worktrees/x 0 (allowed)

None of the six mutating scripts the frontmatter names is gated. Neither is branch deletion — the
operation SKILL.md:142 and context/git-branch-cleanup.md §4.7 own end to end.
git push --delete is the only irreversible step in that path and the guard is blind to it.

The guard's own header honestly declares itself "a best-effort NET, not a security boundary" with
"known coverage gaps … accepted" — that posture is defensible. The defect is the frontmatter
claiming otherwise, plus the specific gap around branch and remote deletion, which is the skill's
own documented happy path rather than an obfuscation-class bypass.

D2 — matcher: "Bash" while the guard's regex targets PowerShell spellings

SKILL.md:19 declares matcher: "Bash". destructive-guard.sh:54 matches
Remove-Item[[:space:]].*-Recurse — proving the author intended PowerShell coverage. PowerShell is
a separate canonical tool, so Remove-Item -Recurse -Force issued through it never reaches the
hook. Not hypothetical: the audited machine has a first-class PowerShell tool and names PowerShell
as its primary shell.

The hooks documentation gives the remedy verbatim: the matcher Bash|PowerShell covers the
PowerShell tool as well as Bash. One-token fix.

D3 — gh pr list --limit 200 silently truncates the PR map, defeating the documented squash-merge mitigation

scripts/git-branch-audit.sh:53:

done < <(gh pr list --state all --json headRefName,state,number,headRefOid --limit 200 2>/dev/null |

context/git-branch-cleanup.md ("Squash-merge handling") correctly identifies that
git branch --merged misses squash merges and names gh pr list as the mitigation. That
mitigation is capped at the 200 most recent PRs across all states, and the truncation is never
reported — no count, no warning, no PRLimitHit: line. Past 200 PRs an older squash-merged branch
loses its PR row and lands in LIKELY-SAFE or REVIEW.

Scale context from the audited fleet: 416 branches carried a merged PR, and
claude-code-plugins alone has 1943 PRs. The operator hit exactly this bug class independently
with their own 1000-PR cap: re-fetching uncapped moved 187 branches from "needs a decision" to
"safe". Squarely in range.

Both gh and jq are also suppressed with 2>/dev/null on that line, so an unauthenticated or
missing gh yields an empty map indistinguishable from "no PRs exist" (see D8).


MEDIUM and below

  • D4 [MEDIUM-HIGH]SKILL.md:110 carries an unconditional
    REPO_ROOT=$(git rev-parse --show-toplevel). In a non-repo cwd this exits 128 on the
    documented Action: menu path. Verified: the resolver itself always exits 0; the 128 is this
    line. Guard it (2>/dev/null || true) and move it into the tiers that need a repo root.
  • D5 [MEDIUM]clean-batch.sh never calls preflight.sh, though SKILL.md §1.5 and
    context/action-router.md both promise it for the caches/build/all tiers. Three documents
    promise a step no code runs. Call it once before the batch loop (not per repo), or remove the
    promise.
  • D6 [MEDIUM]CLEAN_GUARD_ACK=1 is documented as the recovery path with no mention of the
    host permission layer above it. On the audited machine the auto-mode classifier independently
    denied Stop-Process, rm -rf, a batched worktree unlock/remove script, and
    git stash drop — so the documented recovery silently does not complete. The ack prefix also
    breaks prefix-anchored allow rules.
  • D7 [MEDIUM] — the five allowed-tools grants never match the path they were written for.
  • D8 [LOW-MEDIUM] — silent degradation of the gh path (see D3's 2>/dev/null).
  • D9 [LOW]reference/ecosystems.md is unreachable from the hub.

Gaps (missing capability, not defects)

  • G1 [MEDIUM] — no lossy-but-deletable verdict state, and no guaranteed pre-deletion tip
    capture. During the audited run an investigating agent returned 81 branches as DROP and flagged
    5 of them, in prose rather than in the verdict column, as carrying work that had landed
    nowhere. The deletion pass read the column and removed all five from local and origin. They
    were recovered only because branch tip SHAs happened to have been captured beforehand. A third
    state (drop-but-lossy), surfaced as its own block before the deletion confirmation, plus an
    emitted Tip: <sha> per branch, makes that reversible by construction rather than by luck.
  • G2 [MEDIUM] — no remote-branch analysis. The git tier is remote-tracking prune only;
    nothing inspects or deletes branches on origin. "Clean up feature branches, local and remote" is
    not servable today.
  • G3 [MEDIUM] — branch audit is cwd-only (no --repo) and excluded from every batch tier.
  • G4 [MEDIUM] — the WORKTREE tier routes nowhere: it correctly refuses to offer worktree-held
    branches and says to use the worktree tool, but nothing hands off. 49 of 830 branches were
    blocked behind this; all 29 merged-PR worktrees had to be found by hand.
  • G5 [LOW-MEDIUM] — no scan tier in the batch form.
  • G6 [LOW-MEDIUM] — no progress output and no parallelism in clean-batch.sh (the audited
    dry-run ran ~40 minutes with no indication of progress).
  • G7 [LOW] — stash dedup across a fleet is manual.
  • G8 [LOW] — no canonical-clone realignment. 3 of 15 canonical clones were off their default
    branch, which also makes those branches permanently undeletable by the audit.
  • G9 [LOW] — per-branch spawn design (see the environment note below).

Environment factors — explicitly NOT charged to the plugin

Measured on the audited machine, recorded so the plugin is not blamed for them:

Measurement Before Defender exclusions After
/bin/true ×100 (Git Bash) 323 ms/spawn 235 ms/spawn
git rev-parse ×100 366 ms/spawn 301 ms/spawn
hostname.exe ×100 (PowerShell, native) 204 ms/spawn

85% of wall time was wait, not CPU. Causes were Windows Defender with zero exclusions and a
user statusline at refreshInterval: 2 against a 3.2-second render. Neither is the plugin's
fault. The plugin-owned half is only G9: git-branch-audit.sh is 187 lines with 14 git call sites
spawning per branch, so an environment multiplier lands on a per-item design. The 17-store branch
audit exceeded a 10-minute budget on its first attempt.

Correct and well-engineered behaviors (recorded for balance)

  • The apply-manifest staleness guard worked: a worktree clean at survey time had gained 44
    uncommitted files by apply and was correctly skipped, not removed.
  • remove-path.sh correctly refused a directory containing nested git repos.
  • git worktree remove correctly refused branches held by worktrees with live work (3 refusals,
    all correct).
  • The batch plan genuinely functioned as the gated set across a ~40-minute dry-run→apply gap.
  • Dry-run-first was never bypassed by the scripts themselves.
  • The dry-run/apply byte summaries (planned=1800702976 vs removed=…42665984) were correct
    1673 MiB of the total lived in one worktree removed between the two phases — but the two numbers
    invite a "the sweep failed" misreading. Worth a note in the apply output.

Blindspots — what this audit does NOT cover

One fleet run of the selective tiers only. Never exercised: tree / tree-batch (the
destructive tier), standalone scan.sh, and the --include-deps / --include-secrets /
--allow-unpushed flags. Findings touching those paths are source-reading only, not behavioral.

Suggested order (cheapest → most ambitious)

  1. matcher: "Bash|PowerShell" (D2) — one token, closes a silent bypass
  2. Guard SKILL.md:110's git rev-parse (D4)
  3. Correct the frontmatter's guard claim (D1)
  4. Raise the PR limit + emit PRCount: / PRDataTruncated: / PRDataUnavailable: (D3, D8)
  5. Progress counter in the batch loop (G6)
  6. Emit Tip: <sha> per branch and require tip capture before any deletion batch (G1)
  7. Document the host permission layer above the ack (D6)
  8. Preflight in the batch path, or drop the promise (D5)
  9. Third drop-but-lossy verdict state (G1)
  10. --repo flag + batched branch audit, collapsing per-branch git calls (G3 + G9)
  11. Remote-branch analysis (G2) — largest capability gap; land it after 6 and 9 since its bugs are irreversible
  12. Canonical-clone realignment tier (G8)
  13. Route the WORKTREE tier to an installed worktree surface (G4)

🤖 Generated with Claude Code

https://claude.ai/code/session_01TUApY97FHg7Ws7wEgGeCfZ

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions