From 7da900a5557d19f90509b0c0a481ae1025325a26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 21:17:01 +0000 Subject: [PATCH 1/2] fix(repo-fleet-hygiene): disclose skipped intermediate symlink dirs Discovery still does not follow symlinks or Windows junctions, but each skipped intermediate path is now an UNKNOWN discovery-symlink-skip finding and counted on the Discovery skips header (#2711). Co-authored-by: Kyle Sexton --- .../.claude-plugin/plugin.json | 2 +- plugins/repo-fleet-hygiene/CHANGELOG.md | 10 ++++++ .../repo-fleet-hygiene/skills/audit/SKILL.md | 5 +++ .../audit/reference/confidence-model.md | 1 + .../skills/audit/reference/security-review.md | 4 ++- .../skills/audit/scripts/audit-fleet.sh | 31 ++++++++++++++++--- .../skills/audit/scripts/audit-fleet.test.sh | 24 ++++++++++++++ 7 files changed, 70 insertions(+), 7 deletions(-) diff --git a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json index ca7227b6a7..e567f84194 100644 --- a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json +++ b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "repo-fleet-hygiene", - "version": "0.22.4", + "version": "0.22.5", "description": "Cross-repository Git/GitHub fleet discovery, evidence rollup, and a gated apply verb that executes a prior fleet action plan behind one confirmation. Audit stays read-only and confidence-tiered; apply mutates only with --apply plus interactive confirmation or --yes.", "author": { "name": "Melodic Software", diff --git a/plugins/repo-fleet-hygiene/CHANGELOG.md b/plugins/repo-fleet-hygiene/CHANGELOG.md index 21ec710678..e4a420a73c 100644 --- a/plugins/repo-fleet-hygiene/CHANGELOG.md +++ b/plugins/repo-fleet-hygiene/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to `repo-fleet-hygiene` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.22.5] + +### Fixed + +- **Symlinked/junctioned intermediate directories under discovery are disclosed, not silent (#2711).** + Discovery still does not follow links. Each skipped intermediate symlink (or Windows directory + junction, which tests as a symlink under Git Bash) becomes an `UNKNOWN` `discovery-symlink-skip` + finding and is counted on the `Discovery skips:` header line. Symlinked discovery roots remain + refused as before. + ## [0.22.4] ### Fixed diff --git a/plugins/repo-fleet-hygiene/skills/audit/SKILL.md b/plugins/repo-fleet-hygiene/skills/audit/SKILL.md index 30f249cf94..5bd922f4fd 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/SKILL.md +++ b/plugins/repo-fleet-hygiene/skills/audit/SKILL.md @@ -250,6 +250,11 @@ Related fleet contracts that remain separate: marker) degrades the same way: an `UNKNOWN` `discovery-skip` finding, header skip counts, and the rest of the fleet is still audited. An explicitly named `--repo` that is not a working tree still hard-fails. +- A symlinked or junctioned intermediate directory under `--root` is not followed (same non-following + bound as before) but is disclosed as an `UNKNOWN` `discovery-symlink-skip` finding and counted on + the discovery-skips header line. Windows directory junctions test as symlinks under Git Bash, so + they take this path. Symlinked discovery *roots* remain a hard refusal (CLI) or `stale-config-entry` + (configured). - `gh` missing/unauthenticated or API/timeout failure: continue Git/worktree checks, report GitHub evidence as `UNKNOWN`, and make no merged/migration claim. Compatible `timeout`/`gtimeout` is preferred; otherwise use the collector's finite TERM-to-KILL Bash watchdog. diff --git a/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md b/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md index c5a67e101a..bc5ab2ff1a 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md +++ b/plugins/repo-fleet-hygiene/skills/audit/reference/confidence-model.md @@ -62,6 +62,7 @@ failure rather than a discovery. | `local-ancestry-unavailable` | `git merge-base --is-ancestor` failed with an error status | `UNKNOWN` | Do not infer local ancestry | | `stale-config-entry` | A config-sourced `fleet.root`/`fleet.repo` path is missing or not a Git working tree | `UNKNOWN` | Entry skipped, rest of the fleet still audited; correct or remove the entry | | `discovery-skip` | A path discovered under `--root` is unreadable or not a Git working tree despite a `.git` marker | `UNKNOWN` | Path skipped, rest of the fleet still audited; inspect unexpected `.git` markers | +| `discovery-symlink-skip` | An intermediate directory under `--root` is a symbolic link (or a Windows directory junction, which tests as a symlink under Git Bash) | `UNKNOWN` | Path skipped without descending; rest of the fleet still audited. Pass an explicit `--root`/`--repo` for the link target if that tree should be in scope | ## What the tiers depend on diff --git a/plugins/repo-fleet-hygiene/skills/audit/reference/security-review.md b/plugins/repo-fleet-hygiene/skills/audit/reference/security-review.md index 79e8735038..96cf95c193 100644 --- a/plugins/repo-fleet-hygiene/skills/audit/reference/security-review.md +++ b/plugins/repo-fleet-hygiene/skills/audit/reference/security-review.md @@ -81,7 +81,9 @@ confirmation or `--yes`, re-derives OIDs before every delete, and skips fail-clo - Branch refs and tips are buffered as NUL-delimited records with the `for-each-ref` exit status. A partial producer failure discards every record, emits `UNKNOWN`, and does not increment the audited repository count. -- Filesystem discovery is bounded and does not follow symbolic links. +- Filesystem discovery is bounded and does not follow symbolic links. Skipped intermediate + symlink (or Windows junction) directories are disclosed as `UNKNOWN` `discovery-symlink-skip` + findings and counted on the discovery-skips header line; the walk never descends through them. - A 404 cannot be used to claim deletion/transfer because GitHub intentionally uses 404 for access-sensitive cases; it remains `UNKNOWN`. - A same-named branch in another repository cannot inherit PR status because every PR query includes diff --git a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh index f3a1d02e83..b7d5f6d05d 100755 --- a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh +++ b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh @@ -1016,6 +1016,10 @@ DISCOVERY_SKIP_PATHS=() DISCOVERY_SKIP_REASONS=() DISCOVERY_NONREPO_COUNT=0 DISCOVERY_UNREADABLE_COUNT=0 +# Intermediate directories that are symbolic links (or Windows junctions that test as symlinks +# under Git Bash). Discovery still does not follow them; each path is disclosed rather than omitted +# silently (#2711). +DISCOVERY_SYMLINK_PATHS=() # Paths where core.bare=true coincides with working-tree content and/or linked worktrees. Rejecting # these as "not a Git working tree" would omit the one administrative anomaly this collector exists @@ -1208,12 +1212,20 @@ discover_repositories() { fi [[ "$depth" -lt "$MAX_DEPTH" ]] || return 0 for child in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do - [[ -d "$child" && ! -L "$child" ]] || continue + # Unmatched globs leave literal patterns; skip those. Broken symlinks still match -L. + [[ -e "$child" || -L "$child" ]] || continue name="$(basename "$child")" case "$name" in . | .. | .git | node_modules | vendor | .venv) continue ;; - *) discover_repositories "$child" $((depth + 1)) ;; esac + # Symlinked/junctioned intermediate dirs: do not descend, but record for disclosure (#2711). + # Windows directory junctions satisfy both -d and -L under Git Bash, so they take this arm. + if [[ -L "$child" && -d "$child" ]]; then + DISCOVERY_SYMLINK_PATHS+=("$child") + continue + fi + [[ -d "$child" ]] || continue + discover_repositories "$child" $((depth + 1)) done } @@ -1267,7 +1279,7 @@ done # contains no repositories is a valid empty audit ("0 repositories found"), not an error — the # operator named that tree (#2599). Hard-fail only when nothing was ever in scope: no roots walked, # no repos, and no stale/skip/bare-live entry to report. -if [[ ${#TARGETS[@]} -eq 0 && ${#STALE_CONFIG_PATHS[@]} -eq 0 && ${#DISCOVERY_SKIP_PATHS[@]} -eq 0 && ${#BARE_LIVE_TREE_PATHS[@]} -eq 0 && ${#ROOT_LABELS[@]} -eq 0 ]]; then +if [[ ${#TARGETS[@]} -eq 0 && ${#STALE_CONFIG_PATHS[@]} -eq 0 && ${#DISCOVERY_SKIP_PATHS[@]} -eq 0 && ${#DISCOVERY_SYMLINK_PATHS[@]} -eq 0 && ${#BARE_LIVE_TREE_PATHS[@]} -eq 0 && ${#ROOT_LABELS[@]} -eq 0 ]]; then fail "no Git working trees found in the requested scope" fi @@ -2430,8 +2442,8 @@ done # (or a directory is unreadable), the path is skipped rather than aborting. Report the counts so # silence is not mistaken for a complete walk. if [[ ${#ROOT_LABELS[@]} -gt 0 ]]; then - printf 'Discovery skips: %s non-repository, %s unreadable\n' \ - "$DISCOVERY_NONREPO_COUNT" "$DISCOVERY_UNREADABLE_COUNT" + printf 'Discovery skips: %s non-repository, %s unreadable, %s symlink\n' \ + "$DISCOVERY_NONREPO_COUNT" "$DISCOVERY_UNREADABLE_COUNT" "${#DISCOVERY_SYMLINK_PATHS[@]}" fi # Config-sourced entries that failed per-entry validation during argument processing (the header @@ -2454,6 +2466,15 @@ for ((skip_index = 0; skip_index < ${#DISCOVERY_SKIP_PATHS[@]}; skip_index++)); "No action required for ordinary non-repositories; inspect unexpected .git markers, then rerun" done +# Symlinked/junctioned intermediate directories under --root: still not followed, but never silent (#2711). +for ((symlink_index = 0; symlink_index < ${#DISCOVERY_SYMLINK_PATHS[@]}; symlink_index++)); do + printf '\n' + emit_finding UNKNOWN discovery-symlink-skip "${DISCOVERY_SYMLINK_PATHS[$symlink_index]}" \ + "symlinked intermediate directory skipped (discovery does not follow symbolic links; Windows directory junctions also test as symlinks under Git Bash)" \ + "Path skipped; the rest of the fleet was audited" \ + "Pass an explicit --root/--repo for the link target if that tree should be in scope, or replace the junction/symlink with a real directory" +done + # Bare repositories that still have working-tree content or linked worktrees (#2602). Reported # before per-repo analysis so the anomaly is visible even when no worktree could be audited. for ((bare_index = 0; bare_index < ${#BARE_LIVE_TREE_PATHS[@]}; bare_index++)); do diff --git a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh index 8aa78c32af..726a3c08cc 100755 --- a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh +++ b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh @@ -1837,6 +1837,30 @@ else printf 'FAIL: symlink discovery root is refused rather than reporting zero repositories\n%s\n' "$(cat "$symlink_out")" >&2 failures=$((failures + 1)) fi + +# Intermediate symlink/junction dirs under --root are skipped without descending, but must be +# disclosed (#2711). Linux symlinks are the portable fixture; Windows junctions take the same +# -d/-L arm under Git Bash. +sym_mid_root="$TMP/sym-mid-root" +sym_mid_hidden="$TMP/sym-mid-hidden" +mkdir -p "$sym_mid_root/keep" "$sym_mid_hidden/buried-repo/.git" +ln -s "$sym_mid_hidden" "$sym_mid_root/via-link" +sym_mid_out="$TMP/sym-mid-out.txt" +if REPO_FLEET_TEST_FAST_TIMEOUTS=1 bash "$SCRIPT" --root "$sym_mid_root" --detail >"$sym_mid_out" 2>&1; then + if grep -Fq "Finding: discovery-symlink-skip" "$sym_mid_out" && + grep -Fq "Target: $sym_mid_root/via-link" "$sym_mid_out" && + grep -Fq "Windows directory junctions" "$sym_mid_out" && + grep -Fq "Discovery skips: 0 non-repository, 0 unreadable, 1 symlink" "$sym_mid_out" && + ! grep -Fq "buried-repo" "$sym_mid_out"; then + printf 'PASS: intermediate symlink under --root is disclosed without descending\n' + else + printf 'FAIL: intermediate symlink under --root is disclosed without descending\n%s\n' "$(cat "$sym_mid_out")" >&2 + failures=$((failures + 1)) + fi +else + printf 'FAIL: intermediate symlink under --root unexpectedly aborted the run\n%s\n' "$(cat "$sym_mid_out")" >&2 + failures=$((failures + 1)) +fi # Unreadable/non-executable roots are the same false-empty class. unreadable_root="$TMP/unreadable-root" mkdir -p "$unreadable_root" From 06dba3c441d0f19457de74cde360741058676ec4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 21:26:12 +0000 Subject: [PATCH 2/2] fix(repo-fleet-hygiene): count fleet-level UNKNOWN in Fleet verdict ShellCheck SC2249 required a default case on the discovery name filter. Fleet-level discovery-symlink-skip (and other idx=-1 UNKNOWN) findings already printed as BLOCKED but were excluded from fleet_blocked, so a symlink-only root could still report Fleet verdict CLEAN. Co-authored-by: Kyle Sexton --- plugins/repo-fleet-hygiene/CHANGELOG.md | 4 +++- .../skills/audit/scripts/audit-fleet.sh | 13 +++++++++++-- .../skills/audit/scripts/audit-fleet.test.sh | 1 + 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/repo-fleet-hygiene/CHANGELOG.md b/plugins/repo-fleet-hygiene/CHANGELOG.md index e4a420a73c..c55eef2176 100644 --- a/plugins/repo-fleet-hygiene/CHANGELOG.md +++ b/plugins/repo-fleet-hygiene/CHANGELOG.md @@ -11,7 +11,9 @@ All notable changes to `repo-fleet-hygiene` are documented here. Format follows Discovery still does not follow links. Each skipped intermediate symlink (or Windows directory junction, which tests as a symlink under Git Bash) becomes an `UNKNOWN` `discovery-symlink-skip` finding and is counted on the `Discovery skips:` header line. Symlinked discovery roots remain - refused as before. + refused as before. Fleet-level `UNKNOWN` findings (including these skips) now also move the + overall `Fleet verdict` off `CLEAN`, so a root whose only content is a skipped symlink cannot + report a clean walk. ## [0.22.4] diff --git a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh index b7d5f6d05d..733412ccdc 100755 --- a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh +++ b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh @@ -1217,6 +1217,7 @@ discover_repositories() { name="$(basename "$child")" case "$name" in . | .. | .git | node_modules | vendor | .venv) continue ;; + *) ;; # other names fall through to the symlink/dir probes below esac # Symlinked/junctioned intermediate dirs: do not descend, but record for disclosure (#2711). # Windows directory junctions satisfy both -d and -L under Git Bash, so they take this arm. @@ -2569,16 +2570,24 @@ for ((ri = 0; ri < ${#R_DISCOVERED[@]}; ri++)); do printf '%s\n' '---' done -# Fleet-level findings (stale config, duplicate-checkout) get their own rollup row. +# Fleet-level findings (stale config, discovery skips/symlinks, duplicate-checkout) +# get their own rollup row. Their UNKNOWN gaps must also move the overall Fleet +# verdict off CLEAN — repo_verdict -1 already classifies them; count that here. fleet_level_count=0 for ((i = 0; i < ${#F_KIND[@]}; i++)); do [[ "${F_REPO_IDX[$i]}" == "-1" ]] && fleet_level_count=$((fleet_level_count + 1)) done +fleet_level_verdict="$(repo_verdict -1)" if [[ "$fleet_level_count" -gt 0 ]]; then printf 'Repo: fleet-level\n' - print_field Verdict "$(repo_verdict -1)" + print_field Verdict "$fleet_level_verdict" print_field 'Kind counts' "$(repo_kind_counts_text -1)" printf '%s\n' '---' + case "$fleet_level_verdict" in + BLOCKED*) fleet_blocked=$((fleet_blocked + 1)) ;; + CLEAN) ;; + *) fleet_candidates=$((fleet_candidates + 1)) ;; + esac fi case "$fleet_blocked" in diff --git a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh index 726a3c08cc..ebbc54d3ad 100755 --- a/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh +++ b/plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.test.sh @@ -1851,6 +1851,7 @@ if REPO_FLEET_TEST_FAST_TIMEOUTS=1 bash "$SCRIPT" --root "$sym_mid_root" --detai grep -Fq "Target: $sym_mid_root/via-link" "$sym_mid_out" && grep -Fq "Windows directory junctions" "$sym_mid_out" && grep -Fq "Discovery skips: 0 non-repository, 0 unreadable, 1 symlink" "$sym_mid_out" && + grep -Fq "Fleet verdict: BLOCKED" "$sym_mid_out" && ! grep -Fq "buried-repo" "$sym_mid_out"; then printf 'PASS: intermediate symlink under --root is disclosed without descending\n' else